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

Issue:
Merge Dev Back Into Release 1.2.0



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 34c87c7..3dcd127 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
 ### Example user template template
 ### Example user template
 
+.DS_Store
+
 # IntelliJ project files
 .idea
 *.iml
@@ -234,3 +236,6 @@ fabric.properties
 
 # Android studio 3.1+ serialized cache file
 .idea/caches/build_file_checksums.ser
+
+# Test files
+*_scratch.ipynb
diff --git a/README.md b/README.md
index b030afe..d01245c 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,28 @@
 # `endaq-python`: A comprehensive, user-centric Python API for working with enDAQ data and devices
 
-`endaq-python` is essentially a meta-package, combining several function-specific sub-packages, and providing a central repository from which to build the documentation.
-
-This package consists of several submodules:
-* `endaq.calc` ([repo](https://github.com/MideTechnology/endaq-python-calc)): A computational backend for vibration analysis.
-* `endaq.cloud` ([repo](https://github.com/MideTechnology/endaq-python-cloud)): Tools for interacting with enDAQ Cloud services.
-* `endaq.ide` ([repo](https://github.com/MideTechnology/endaq-python-ide)): High-level utility functions to aid in importing and inspecting enDAQ IDE recording files.
-* `endaq.plot` ([repo](https://github.com/MideTechnology/endaq-python-plot)):  A package comprising a collection of plotting utilities for sensor data analysis.
-<!-- * `endaq.io` ([repo](https://github.com/MideTechnology/endaq-python-io)) -->
+
+## Installation
+
+endaq is available on PYPI via `pip`:
+
+    pip install endaq
+
+For the most recent features that are still under development, you can also use `pip` to install endaq directly from GitHub:
+
+    pip install git+https://github.com/MideTechnology/endaq-python.git@development
+
+## Contents
+
+This package consists of several submodules, you can read more about how these are used in their respective readme files:
+* `endaq.calc` ([readme](https://github.com/MideTechnology/endaq-python/tree/main/endaq/calc)): A computational backend for vibration analysis.
+* `endaq.cloud` ([readme](https://github.com/MideTechnology/endaq-python/tree/main/endaq/cloud)): Tools for interacting with enDAQ Cloud services.
+* `endaq.ide` ([readme](https://github.com/MideTechnology/endaq-python/tree/main/endaq/ide)): High-level utility functions to aid in importing and inspecting enDAQ IDE recording files.
+* `endaq.plot` ([readme](https://github.com/MideTechnology/endaq-python/tree/main/endaq/plot)):  A package comprising a collection of plotting utilities for sensor data analysis.
+
+## Docs
+
+The docs for this package can be found [here](https://docs.endaq.com/en/latest/).
+
+## 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 9f8d2b5..2c54740 100644
--- a/docs/api_ref.rst
+++ b/docs/api_ref.rst
@@ -9,5 +9,6 @@ enDAQ API Reference
    endaq/calc
    endaq/cloud
    endaq/plot
+   endaq/batch
 
    endaq/cloud_API_wrapper
diff --git a/docs/endaq/batch.rst b/docs/endaq/batch.rst
new file mode 100644
index 0000000..2f17798
--- /dev/null
+++ b/docs/endaq/batch.rst
@@ -0,0 +1,11 @@
+#############
+`endaq.batch`
+#############
+
+
+.. automodule:: endaq.batch
+  :members:
+  :imported-members:
+
+.. autoclass:: endaq.batch.calc.OutputStruct
+  :members:
diff --git a/endaq/__init__.py b/endaq/__init__.py
index 0de5b10..20156d5 100644
--- a/endaq/__init__.py
+++ b/endaq/__init__.py
@@ -2,3 +2,4 @@
 import endaq.cloud
 import endaq.ide
 import endaq.plot
+import endaq.batch
diff --git a/endaq/batch/__init__.py b/endaq/batch/__init__.py
new file mode 100644
index 0000000..e57f9d8
--- /dev/null
+++ b/endaq/batch/__init__.py
@@ -0,0 +1,1 @@
+from .calc import GetDataBuilder
diff --git a/endaq/batch/analyzer.py b/endaq/batch/analyzer.py
new file mode 100644
index 0000000..fcc625c
--- /dev/null
+++ b/endaq/batch/analyzer.py
@@ -0,0 +1,598 @@
+import logging
+import sys
+import warnings
+
+if sys.version_info[:2] >= (3, 8):
+    from functools import cached_property
+else:
+    from backports.cached_property import cached_property
+
+
+import numpy as np
+import pandas as pd
+
+import endaq.calc
+from endaq.calc import psd, stats, shock, integrate, filters
+
+from endaq.batch import quat
+from endaq.batch.utils import ide_utils
+
+
+class Analyzer:
+    """
+    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.
+    """
+
+    # 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
+
+    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,
+    ):
+        """
+        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:
+            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:
+            raise ValueError(
+                "only one of `accel_end_time` and `accel_end_margin` may be set at once"
+            )
+
+        self._channels = ide_utils.dict_chs_best(
+            (
+                (utype, ch_struct)
+                for (utype, ch_struct) in ide_utils.chs_by_utype(doc)
+                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
+
+    # ==========================================================================
+    # Data Processing, just to make init cleaner
+    # ==========================================================================
+
+    @cached_property
+    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}")
+            return pd.DataFrame(
+                np.empty((0, 3), dtype=np.float),
+                index=pd.Series([], 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,
+                "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")
+
+        if self._accel_start_margin is not None:
+            margin = int(
+                np.ceil(
+                    ch_struct.fs * self._accel_start_margin / np.timedelta64(1, "s")
+                )
+            )
+            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"))
+            )
+            aData = aData.iloc[: (-margin or None)]
+        elif self._accel_end_time is not None:
+            aData = aData.loc[: self._accel_end_time]
+
+        aData = filters.butterworth(aData, low_cutoff=self._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()
+
+    @cached_property
+    def _microphoneData(self):
+        """Populate the _microphone* fields, including splitting and extending data."""
+        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"),
+                columns=pd.Series(["mic"], name="axis"),
+            )
+
+        units = ch_struct.units[1]
+        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
+
+    @cached_property
+    def _velocityData(self):
+        aData = self._accelerationData
+        if aData.size == 0:
+            return aData
+
+        if not self._accel_highpass_cutoff:
+            logging.warning(
+                "no highpass filter used before integration; "
+                "velocity calculation may be unstable"
+            )
+
+        return integrate._integrate(aData)
+
+    @cached_property
+    def _displacementData(self):
+        vData = self._velocityData
+        if vData.size == 0:
+            return vData
+
+        if not self._accel_highpass_cutoff:
+            logging.warning(
+                "no highpass filter used before integration; "
+                "displacement calculation may be unstable"
+            )
+
+        return integrate._integrate(vData)
+
+    @cached_property
+    def _PVSSData(self):
+        aData = self._accelerationData
+        if aData.size == 0:
+            pvss = aData[:]
+            pvss.index.name = "frequency (Hz)"
+            return pvss
+
+        freqs = endaq.calc.logfreqs(
+            aData, self._pvss_init_freq, self._pvss_bins_per_octave
+        )
+        freqs = freqs[
+            (freqs >= self._accelerationFs / self._accelerationData.shape[0])
+        ]
+        pv = shock.shock_spectrum(
+            self._accelerationData,
+            freqs,
+            damp=0.05,
+            mode="pvss",
+        )
+
+        return pv
+
+    @cached_property
+    def _PVSSResultantData(self):
+        aData = self._accelerationData
+        if aData.size == 0:
+            pvss = self._accelerationResultantData[:]
+            pvss.index.name = "frequency (Hz)"
+            return pvss
+
+        freqs = endaq.calc.logfreqs(
+            aData, self._pvss_init_freq, self._pvss_bins_per_octave
+        )
+        freqs = freqs[
+            (freqs >= self._accelerationFs / self._accelerationData.shape[0])
+        ]
+        pv = shock.shock_spectrum(
+            self._accelerationData,
+            freqs,
+            damp=0.05,
+            mode="pvss",
+            aggregate_axes=True,
+        )
+
+        return pv
+
+    @cached_property
+    def _PSDData(self):
+        aData = self._accelerationData
+        if aData.size == 0:
+            psdData = aData[:]
+            psdData.index.name = "frequency (Hz)"
+            return psdData
+
+        return endaq.calc.psd.welch(
+            aData,
+            bin_width=self._psd_freq_bin_width,
+            window=self._psd_window,
+            average="median",
+        )
+
+    @cached_property
+    def _VCCurveData(self):
+        """Calculate Vibration Criteria (VC) Curves for the accelerometer."""
+        psdData = self._PSDData
+        if psdData.size == 0:
+            vcData = psdData[:]
+            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,
+        )
+
+    @cached_property
+    def _pressureData(self):
+        """Populate the _pressure* fields, including splitting and extending data."""
+        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"),
+                columns=pd.Series(["Pressure"], name="axis"),
+            )
+
+        units = ch_struct.units[1]
+        try:
+            conversionFactor = {  # core units = kPa
+                "pa": 1e-3,
+                "psi": 6.89476,
+                "atm": 101.325,
+            }[units.lower()]
+        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
+
+    @cached_property
+    def _temperatureData(self):
+        """Populate the _temperature* fields, including splitting and extending data."""
+        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"),
+                columns=pd.Series(["Temperature"], name="axis"),
+            )
+
+        units = ch_struct.units[1]
+        try:
+            conversionFactor, conversionOffset = {  # core units = degrees C
+                "\xb0c": (1, 0),
+                "\xb0k": (1, 273.15),
+                "\xb0f": (5 / 9, -32 * (5 / 9)),
+            }[units.lower()]
+        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(
+            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"),
+                columns=pd.Series(["X", "Y", "Z"], name="axis"),
+            )
+
+        self._gyroName = ch_struct.channel.name
+        self._gyroFs = ch_struct.fs
+
+        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()
+
+            data = pd.DataFrame(
+                (180 / np.pi)
+                * quat.quat_to_angvel(quat_raw, 1 / self._gyroFs, qaxis=1),
+                index=quat_df.index,
+                columns=pd.Series(["X", "Y", "Z"], name="axis"),
+            )
+
+            def strip_invalid_prefix(data, prefix_len):
+                """Search prefix for invalid data and remove it (if any)."""
+                data_array = data.to_numpy()
+                data_mag = stats.L2_norm(data_array[: 4 * prefix_len], axis=-1)
+                # the derivative method for `quat_to_angvel` uses the *average*
+                # of adjacent differences
+                # -> any rotation spikes will result in two adjacent,
+                # nearly-equal peaks
+                data_agg = 0.5 * (data_mag[:-1] + data_mag[1:])
+                argmax_prefix = np.argmax(data_agg[:prefix_len])
+
+                # Prefix data is considered "anomalous" if it is much larger
+                # than any surrounding data
+                if data_agg[argmax_prefix] > 2 * data_mag[prefix_len:].max():
+                    data = data.iloc[argmax_prefix + 2 :]
+
+                return data
+
+            data = strip_invalid_prefix(
+                data, prefix_len=max(4, int(np.ceil(0.25 * self._gyroFs)))
+            )
+        elif units.lower() in ("dps", "deg/s"):
+            data = ch_struct.to_pandas(time_mode="timedelta")
+        else:
+            raise ValueError(f'unknown gyroscope channel units "{units}"')
+
+        return data
+
+    @cached_property
+    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"),
+                columns=pd.Series(["Latitude", "Longitude"], name="axis"),
+            )
+
+        units = ch_struct.units[1]
+        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
+
+    @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"),
+                columns=pd.Series(["GPS Speed"], name="axis"),
+            )
+
+        units = ch_struct.units[1]
+        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
+
+    # ==========================================================================
+    # Analyses
+    # ==========================================================================
+
+    @cached_property
+    def accRMSFull(self):
+        """Accelerometer Tri-axial RMS."""
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            rms = self._accelerationData.apply(
+                stats.rms, axis="rows", raw=True
+            )  # RuntimeWarning: Mean of empty slice.
+
+        rms["Resultant"] = stats.L2_norm(rms.to_numpy())
+        rms.name = "RMS Acceleration"
+        return self.MPS2_TO_G * rms
+
+    @cached_property
+    def velRMSFull(self):
+        """Velocity Tri-axial RMS, after applying a 0.1Hz highpass filter."""
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            rms = self._velocityData.apply(
+                stats.rms, axis="rows", raw=True
+            )  # RuntimeWarning: Mean of empty slice.
+
+        rms["Resultant"] = stats.L2_norm(rms.to_numpy())
+        rms.name = "RMS Velocity"
+        return self.MPS_TO_MMPS * rms
+
+    @cached_property
+    def disRMSFull(self):
+        """Displacement Tri-axial RMS, after applying a 0.1Hz highpass filter."""
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            rms = self._displacementData.apply(
+                stats.rms, axis="rows", raw=True
+            )  # RuntimeWarning: Mean of empty slice.
+
+        rms["Resultant"] = stats.L2_norm(rms.to_numpy())
+        rms.name = "RMS Displacement"
+        return self.M_TO_MM * rms
+
+    @cached_property
+    def accPeakFull(self):
+        """Peak instantaneous tri-axial acceleration"""
+        max_abs = self._accelerationData.abs().max(axis="rows")
+        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
+
+    @cached_property
+    def pseudoVelPeakFull(self):
+        """Peak Pseudo Velocity"""
+        max_pv = self._PVSSData.max(axis="rows")
+        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
+
+    @cached_property
+    def gpsLocFull(self):
+        """Average GPS location"""
+        data = self._gpsPositionData
+        # 0's occur when gps doesn't have a "lock" -> remove them
+        data = data.iloc[np.all(data.to_numpy() != 0, axis=1)]
+        if data.size == 0:
+            gpsPos = pd.Series(
+                [np.nan, np.nan],
+                index=pd.Series(["Latitude", "Longitude"], name="axis"),
+            )
+        else:
+            gpsPos = data.iloc[-1]
+
+        gpsPos.name = "GPS Position"
+        return gpsPos
+
+    @cached_property
+    def gpsSpeedFull(self):
+        """Average GPS speed"""
+        data = self._gpsSpeedData
+        # 0's occur when gps doesn't have a "lock" -> remove them
+        data = data.iloc[data.to_numpy() != 0]
+
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            gpsSpeed = data.mean()  # RuntimeWarning: Mean of empty slice.
+
+        gpsSpeed.name = "GPS Speed"
+        return gpsSpeed
+
+    @cached_property
+    def gyroRMSFull(self):
+        """Gyroscope RMS"""
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            rms = self._gyroscopeData.apply(
+                stats.rms, axis="rows", raw=True
+            )  # RuntimeWarning: Mean of empty slice.
+        rms["Resultant"] = stats.L2_norm(rms.to_numpy())
+        rms.name = "RMS Angular Velocity"
+        return rms
+
+    @cached_property
+    def micRMSFull(self):
+        """Microphone RMS"""
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            mic = self._microphoneData.apply(
+                stats.rms, axis="rows", raw=True
+            )  # RuntimeWarning: Mean of empty slice.
+
+        mic.name = "RMS Microphone"
+        return mic
+
+    @cached_property
+    def tempFull(self):
+        """Average Temperature"""
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            temp = self._temperatureData.mean()  # RuntimeWarning: Mean of empty slice.
+
+        temp.name = "Average Temperature"
+        return temp
+
+    @cached_property
+    def pressFull(self):
+        """Average Pressure"""
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            press = self._pressureData.mean()  # RuntimeWarning: Mean of empty slice.
+
+        press.name = "Average Temperature"
+        return press
diff --git a/endaq/batch/calc.py b/endaq/batch/calc.py
new file mode 100644
index 0000000..1897eed
--- /dev/null
+++ b/endaq/batch/calc.py
@@ -0,0 +1,580 @@
+from __future__ import annotations
+
+from functools import partial
+import logging
+import os
+
+import numpy as np
+import pandas as pd
+
+import endaq.ide
+from endaq.calc import stats as calc_stats
+from endaq.calc import psd as calc_psd
+
+from endaq.batch.analyzer import Analyzer
+
+
+def _make_meta(dataset):
+    """Generate a pandas object containing metadata for the given recording."""
+    serial_no = dataset.recorderInfo["RecorderSerial"]
+    start_time = np.datetime64(dataset.sessions[0].utcStartTime, "s") + np.timedelta64(
+        dataset.sessions[0].firstTime, "us"
+    )
+
+    return pd.Series(
+        [serial_no, start_time],
+        index=["serial number", "start time"],
+        name=dataset.filename,
+    )
+
+
+def _make_psd(analyzer, 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:
+        return None
+
+    df_psd = analyzer._PSDData
+    if bins_per_octave is not None:
+        df_psd = calc_psd.to_octave(
+            df_psd,
+            fstart=(fstart or 1),
+            octave_bins=bins_per_octave,
+            agg=np.mean,
+        )
+
+    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
+
+    return df_psd.stack(level="axis").reorder_levels(["axis", "frequency (Hz)"])
+
+
+def _make_pvss(analyzer):
+    """
+    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:
+        return None
+
+    df_pvss = analyzer._PVSSData
+    df_pvss["Resultant"] = analyzer._PVSSResultantData
+    df_pvss = df_pvss * analyzer.MPS_TO_MMPS
+
+    return df_pvss.stack(level="axis").reorder_levels(["axis", "frequency (Hz)"])
+
+
+def _make_metrics(analyzer):
+    """
+    Format the channel metrics of a recording into a pandas object.
+
+    The following units listed by type are used for the metrics:
+    - acceleration - g (gravity = 9.80665 meters per square second)
+    - velocity - millimeters per second
+    - displacement - millimeters
+    - rotation speed - degrees per second
+    - GPS position - degrees latitude/longitude
+    - GPS speed - km/h (kilometers per hour)
+    - audio - unitless
+    - temperature - degrees Celsius
+    - pressure - kiloPascals
+    """
+    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,
+        ],
+        axis="columns",
+    )
+
+    # Format data into desired shape
+    df.columns.name = "calculation"
+    series = df.stack().reorder_levels(["calculation", "axis"])
+
+    return series
+
+
+def _make_peak_windows(analyzer, margin_len):
+    """
+    Store windows of the main accelerometer channel about its peaks in a pandas
+    object.
+
+    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:
+        return None
+
+    data = analyzer._accelerationData[:]
+    data["Resultant"] = analyzer._accelerationResultantData
+    data = analyzer.MPS2_TO_G * data
+
+    dt = 1 / analyzer._accelerationFs
+
+    data_noidx = data.reset_index(drop=True)
+    peak_indices = data_noidx.abs().idxmax(axis="rows")
+    aligned_peak_data = pd.concat(
+        [
+            pd.Series(
+                data[col].to_numpy(),
+                index=(data_noidx.index - peak_indices[col]),
+                name=col,
+            )
+            for col in data.columns
+        ],
+        axis="columns",
+    )
+    aligned_peak_data = aligned_peak_data.loc[-margin_len : margin_len + 1]
+    aligned_peak_data.index = pd.Series(
+        aligned_peak_data.index * dt, name="peak offset"
+    )
+    aligned_peak_data.columns = pd.MultiIndex.from_frame(
+        pd.DataFrame(
+            {
+                "axis": aligned_peak_data.columns.values,
+                "peak time": data.index[peak_indices],
+            }
+        )
+    )
+
+    # Format results
+    result = (
+        aligned_peak_data.stack()
+        .stack()
+        .reorder_levels(["axis", "peak time", "peak offset"])
+    )
+
+    return result
+
+
+def _make_vc_curves(analyzer):
+    """
+    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["Resultant"] = calc_stats.L2_norm(df_vc.to_numpy(), axis=1)
+
+    return df_vc.stack(level="axis").reorder_levels(["axis", "frequency (Hz)"])
+
+
+class GetDataBuilder:
+    """
+    The main interface for the calculations.
+
+    This object has two types of functions:
+
+    - *configuration functions* - these determine what calculations will be
+      performed on IDE recordings, and pass in any requisite parameters for said
+      calculations. This includes the following functions:
+
+      - ``add_psd``
+      - ``add_pvss``
+      - ``add_metrics``
+      - ``add_peaks``
+      - ``add_vc_curves``
+
+    - *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.
+
+      This includes the functions ``_get_data`` & ``aggregate_data``, which
+      operates on one & multiple file(s), respectively.
+
+    A typical use case will look something like this:
+
+    .. code-block:: python
+
+        filenames = [...]
+
+        calc_output = (
+            GetDataBuilder(accel_highpass_cutoff=1)
+            .add_psd(freq_bin_width=1)
+            .add_pvss(init_freq=1, bins_per_octave=12)
+            .add_metrics()
+            .add_peaks(margin_len=100)
+            .add_vc_curves(init_freq=1, bins_per_octave=3)
+            .aggregate_data(filenames)
+        )
+        file_data = calc_output.dataframes
+    """
+
+    def __init__(
+        self,
+        *,
+        preferred_chs=[],
+        accel_highpass_cutoff,
+        accel_start_time=None,
+        accel_end_time=None,
+        accel_start_margin=None,
+        accel_end_margin=None,
+    ):
+        """
+        :param preferred_chs: a sequence of channels; each channel listed is
+            prioritized over others of the same type of physical measurement
+            (e.g., acceleration, temperature, pressure, etc.)
+        :param accel_highpass_cutoff: the cutoff frequency used when
+            pre-filtering acceleration data
+        :param accel_start_time: the relative timestamp before which to reject
+            recording data; cannot be used in conjunction with
+            `accel_start_margin`
+        :param accel_end_time: the relative timestamp after which to reject
+            recording data; cannot be used in conjunction with
+            `accel_end_margin`
+        :param accel_start_margin: the number of samples before which to reject
+            recording data; cannot be used in conjunction with
+            `accel_start_time`
+        :param accel_end_margin: the numper of samples after which to reject
+            recording data; cannot be used in conjunction with
+            `accel_end_time`
+        """
+        if accel_start_time is not None and 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:
+            raise ValueError(
+                "only one of `accel_end_time` and `accel_end_margin` may be set at once"
+            )
+
+        self._metrics_queue = {}  # dict maintains insertion order, unlike set
+
+        self._analyzer_kwargs = dict(
+            preferred_chs=preferred_chs,
+            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,
+        )
+
+        # Even unused parameters MUST be set; used to instantiate `Analyzer` in `_get_data`
+        self._psd_freq_bin_width = None
+        self._psd_freq_start_octave = None
+        self._psd_bins_per_octave = None
+        self._psd_window = None
+        self._pvss_init_freq = None
+        self._pvss_bins_per_octave = None
+        self._peak_window_margin_len = None
+        self._vc_init_freq = None
+        self._vc_bins_per_octave = None
+
+    def add_psd(
+        self,
+        *,
+        freq_bin_width=None,
+        freq_start_octave=None,
+        bins_per_octave=None,
+        window="hanning",
+    ):
+        """
+        Add the acceleration PSD to the calculation queue.
+
+        :param freq_bin_width: the desired spacing between adjacent PSD samples;
+            a default is provided only if `bins_per_octave` is used, otherwise
+            this parameter is required
+        :param freq_start_octave: the first frequency to use in octave-spacing;
+            this is only used if `bins_per_octave` is set
+        :param bins_per_octave: the number of frequency bins per octave in a
+            log-spaced PSD; if not set, the PSD will be linearly-spaced as
+            specified by `freq_bin_width`
+        :param window: the window type used in the PSD calculation; see the
+            documentation for ``scipy.signal.welch`` for details
+        """
+        if all(i is None for i in (freq_bin_width, bins_per_octave)):
+            raise ValueError(
+                "must at least provide parameters for one of linear and log-spaced modes"
+            )
+        if freq_bin_width is None:
+            if freq_start_octave is None:
+                freq_start_octave = 1
+
+            fstart_breadth = 2 ** (1 / (2 * bins_per_octave)) - 2 ** (
+                -1 / (2 * bins_per_octave)
+            )
+            freq_bin_width = freq_start_octave / int(5 / fstart_breadth)
+
+        self._metrics_queue["psd"] = None
+        self._psd_freq_bin_width = freq_bin_width
+        self._psd_freq_start_octave = freq_start_octave
+        self._psd_bins_per_octave = bins_per_octave
+        self._psd_window = window
+
+        return self
+
+    def add_pvss(self, *, init_freq, bins_per_octave):
+        """
+        Add the acceleration PVSS (Pseudo Velocity Shock Spectrum) to the
+        calculation queue.
+
+        :param init_freq: the first frequency sample in the spectrum
+        :param bins_per_octave: the number of samples per frequency octave
+        """
+        self._metrics_queue["pvss"] = None
+        self._pvss_init_freq = init_freq
+        self._pvss_bins_per_octave = bins_per_octave
+
+        return self
+
+    def add_metrics(self):
+        """Add broad channel metrics to the calculation queue."""
+        self._metrics_queue["metrics"] = None
+
+        if "pvss" not in self._metrics_queue:
+            self._pvss_init_freq = 1
+            self._pvss_bins_per_octave = 12
+
+        return self
+
+    def add_peaks(self, *, margin_len):
+        """
+        Add windows about the acceleration's peak value to the calculation
+        queue.
+
+        :param margin_len: the number of samples on each side of a peak to
+            include in the windows
+        """
+        self._metrics_queue["peaks"] = None
+        self._peak_window_margin_len = margin_len
+
+        return self
+
+    def add_vc_curves(self, *, init_freq, bins_per_octave):
+        """
+        Add Vibration Criteria (VC) Curves to the calculation queue.
+
+        :param init_freq: the first frequency
+        :param bins_per_octave:  the number of samples per frequency octave
+        """
+        self._metrics_queue["vc_curves"] = None
+
+        if "psd" not in self._metrics_queue:
+            self._psd_freq_bin_width = 0.2
+            self._psd_window = "hanning"
+        self._vc_init_freq = init_freq
+        self._vc_bins_per_octave = bins_per_octave
+
+        return self
+
+    def _get_data(self, filename):
+        """
+        Calculate data from a single recording into a pandas object.
+
+        Used internally by `aggregate_data`.
+        """
+        print(f"processing {filename}...")
+
+        data = {}
+        with endaq.ide.get_doc(filename) as ds:
+            analyzer = Analyzer(
+                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,
+            )
+
+            data["meta"] = _make_meta(ds)
+
+            funcs = dict(
+                psd=partial(
+                    _make_psd,
+                    fstart=self._psd_freq_start_octave,
+                    bins_per_octave=self._psd_bins_per_octave,
+                ),
+                pvss=_make_pvss,
+                metrics=_make_metrics,
+                peaks=partial(
+                    _make_peak_windows,
+                    margin_len=self._peak_window_margin_len,
+                ),
+                vc_curves=_make_vc_curves,
+            )
+            for output_type in self._metrics_queue.keys():
+                data[output_type] = funcs[output_type](analyzer)
+
+        return data
+
+    def aggregate_data(self, filenames) -> OutputStruct:
+        """
+        Compile configured data from the given files into a dataframe.
+
+        :param filenames: a sequence of paths of recording files to process
+        """
+        series_lists = zip(
+            *(self._get_data(filename).values() for filename in filenames)
+        )
+
+        print("aggregating data...")
+        meta, *dfs = (
+            pd.concat(
+                series_list,
+                keys=filenames,
+                names=["filename"]
+                + next(s for s in series_list if s is not None).index.names,
+            )
+            if series_list and any(s is not None for s in series_list)
+            else None
+            for series_list in series_lists
+        )
+
+        meta = meta.unstack(level=1)
+
+        def reformat(series):
+            if series is None:
+                return None
+
+            df = series.to_frame().T.melt()
+            df["serial number"] = meta.loc[df["filename"], "serial number"].reset_index(
+                drop=True
+            )
+            df["start time"] = meta.loc[df["filename"], "start time"].reset_index(
+                drop=True
+            )
+
+            return df
+
+        dfs = dict(
+            meta=meta,
+            **{key: reformat(df) for (key, df) in zip(self._metrics_queue.keys(), dfs)},
+        )
+
+        print("done!")
+
+        return OutputStruct(dfs)
+
+
+class OutputStruct:
+    """
+    A data wrapper class with methods for common export operations.
+
+    Objects of this class are generated by ``GetDataBuilder.aggregate_data``.
+    This class is not intended be instantiated manually.
+    """
+
+    def __init__(self, data):
+        self.dataframes = data
+
+    def to_csv_folder(self, folder_path):
+        """
+        Write data to a folder as CSV's.
+
+        :param folder_path: the output directory path for .CSV files
+        """
+        os.makedirs(folder_path, exist_ok=True)
+
+        for k, df in self.dataframes.items():
+            path = os.path.join(folder_path, f"{k}.csv")
+            df.to_csv(path, index=(k == "meta"))
+
+    def to_html_plots(self, folder_path=None, show=False):
+        """
+        Generate plots in HTML.
+
+        :param folder_path: the output directory for saving .HTML
+            plots; if `None` (default), plots are not saved
+        :param show: whether to open plots after generation; defaults to `False`
+        """
+        if not any((folder_path, show)):
+            return
+
+        import plotly.express as px
+
+        if folder_path:
+            os.makedirs(folder_path, exist_ok=True)
+
+        for k, df in self.dataframes.items():
+            if k == "meta":
+                continue
+            if k == "psd":
+                fig = px.line(
+                    df,
+                    x="frequency (Hz)",
+                    y="value",
+                    color="filename",
+                    line_dash="axis",
+                )
+
+                fig.update_xaxes(type="log", title_text="frequency (Hz)")
+                fig.update_yaxes(type="log", title_text="Acceleration (g^2/Hz)")
+                fig.update_layout(title="Acceleration PSD")
+
+            elif k == "pvss":
+                fig = px.line(
+                    df,
+                    x="frequency (Hz)",
+                    y="value",
+                    color="filename",
+                    line_dash="axis",
+                )
+                fig.update_xaxes(type="log", title_text="frequency (Hz)")
+                fig.update_yaxes(type="log", title_text="Velocity (mm/s)")
+                fig.update_layout(title="Pseudo Velocity Shock Spectrum (PVSS)")
+
+            elif k == "metrics":
+                logging.warning("HTML plot for metrics not currently implemented")
+                continue
+
+            elif k == "peaks":
+                fig = px.line(
+                    df,
+                    x=df["peak offset"].dt.total_seconds(),
+                    # ^ plotly doesn't handle timedelta's well
+                    y="value",
+                    color="filename",
+                    line_dash="axis",
+                )
+                fig.update_xaxes(title_text="time relative to peak (s)")
+                fig.update_yaxes(title_text="Acceleration (g)")
+                fig.update_layout(title="Window about Acceleration Peaks")
+
+            elif k == "vc_curves":
+                fig = px.line(
+                    df,
+                    x="frequency (Hz)",
+                    y="value",
+                    color="filename",
+                    line_dash="axis",
+                )
+
+                fig.update_xaxes(type="log", title_text="frequency (Hz)")
+                fig.update_yaxes(
+                    type="log", title_text="1/3-Octave RMS Velocity (μm/s)"
+                )
+                fig.update_layout(title="Vibration Criteria (VC) Curves")
+
+            else:
+                raise RuntimeError(f"no configuration for plotting '{k}' data")
+
+            if not folder_path and show:
+                fig.show()
+            else:
+                fig.write_html(
+                    file=os.path.join(folder_path, f"{k}.html"),
+                    include_plotlyjs="directory",
+                    full_html=True,
+                    auto_open=show,
+                )
diff --git a/endaq/batch/quat.py b/endaq/batch/quat.py
new file mode 100644
index 0000000..f1a4e45
--- /dev/null
+++ b/endaq/batch/quat.py
@@ -0,0 +1,72 @@
+import numpy as np
+
+
+def as_quat_array(q, force_copy=False, qaxis=-1):
+    """Formats a quaternion array."""
+    q = (np.array if force_copy else np.asarray)(q)
+
+    if not (-q.ndim <= qaxis < q.ndim):
+        raise ValueError("invalid quaternion axis")
+
+    if q.ndim == 0 or q.shape[qaxis] != 4:
+        raise ValueError(
+            "improper shape for quaternion data;"
+            " specified axis should contain WXYZ coordinates"
+        )
+
+    return q
+
+
+def quat_mul(q1, q2, qaxis=-1):
+    """Multiply two quaternion arrays."""
+    q1, q2 = as_quat_array(q1, qaxis=qaxis), as_quat_array(q2, qaxis=qaxis)
+    q1, q2 = np.moveaxis(q1, qaxis, -1), np.moveaxis(q2, qaxis, -1)
+
+    result = np.empty(np.broadcast(q1, q2).shape, dtype=np.result_type(q1, q2))
+
+    result[..., 0] = np.sum(q1 * q2 * np.array([1, -1, -1, -1]), axis=-1)
+    result[..., 1] = np.sum(
+        q1 * q2[..., [1, 0, 3, 2]] * np.array([1, 1, 1, -1]), axis=-1
+    )
+    result[..., 2] = np.sum(
+        q1 * q2[..., [2, 3, 0, 1]] * np.array([1, -1, 1, 1]), axis=-1
+    )
+    result[..., 3] = np.sum(
+        q1 * q2[..., [3, 2, 1, 0]] * np.array([1, 1, -1, 1]), axis=-1
+    )
+
+    return np.moveaxis(result, -1, qaxis)
+
+
+def quat_conj(q, qaxis=-1):
+    """Conjugate an array of quaternion."""
+    q = as_quat_array(q, force_copy=True, qaxis=qaxis)
+
+    q = np.moveaxis(q, qaxis, 0)
+    q[1:] *= -1
+    return np.moveaxis(q, 0, qaxis)
+
+
+def quat_inv(q, qaxis=-1):
+    """Invert an array of quaternions."""
+    return quat_conj(q, qaxis=qaxis) / np.sum(q ** 2, axis=qaxis, keepdims=True)
+
+
+def quat_div(q1, q2, qaxis=-1):
+    """Divide two quaternion arrays."""
+    return quat_mul(q1, quat_inv(q2, qaxis=qaxis), qaxis=qaxis)
+
+
+def quat_to_angvel(q, *dt, qaxis=-1):
+    """
+    Calculate the angular velocity for an array of orientation quaternions.
+
+    :param q: quaternion array; requires q.shape[-1] == 4
+    :param dt: the time corresponing to each quaternion sample
+    :return: the angular velocity
+    """
+    q = np.moveaxis(q, qaxis, -1)
+    q_prime = np.gradient(q, *dt, axis=range(q.ndim - 1), edge_order=2)
+    ang_vel = 2 * quat_div(q_prime, q)[..., 1:]
+
+    return np.moveaxis(ang_vel, -1, qaxis)
diff --git a/endaq/batch/utils/__init__.py b/endaq/batch/utils/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/endaq/batch/utils/ebmlite_utils.py b/endaq/batch/utils/ebmlite_utils.py
new file mode 100644
index 0000000..2042898
--- /dev/null
+++ b/endaq/batch/utils/ebmlite_utils.py
@@ -0,0 +1,69 @@
+import ebmlite
+
+
+def iter_hierarchy(ebmldoc, nocache=False):
+    """
+    Iterate depth-first over all elements, yielding for each element its
+    hierarchical path from the root document.
+
+    :param nocache: whether to skip parser caching (defaults to False)
+    :send: whether the current element's descendants should be skipped;
+        defaults to None -> False
+    :send type: bool
+    :yield: the hierarchical path to an element from the document root for
+        every element in the document
+    :yield type: list
+    """
+    should_skip = yield [ebmldoc]
+    if should_skip:
+        return
+
+    try:
+        iter_subelements = ebmldoc.__iter__(nocache=nocache)
+    except AttributeError:
+        return
+
+    for subelement in iter_subelements:
+        iter_subhierarchy = iter_hierarchy(subelement, nocache=nocache)
+
+        should_skip = None  # must start an iterator by sending `None`
+        try:
+            while True:
+                should_skip = yield [ebmldoc] + iter_subhierarchy.send(should_skip)
+        except StopIteration:
+            continue
+
+
+def filter_elements_by_name(doc, elem_names):
+    """Filter hierarchical element paths by element names."""
+    elem_names = [doc.name] + elem_names
+    iter_elem_hierarchy = iter_hierarchy(doc)
+    should_skip = None
+
+    try:
+        while True:
+            element_path = iter_elem_hierarchy.send(should_skip)
+            should_skip = element_path[-1].name != elem_names[len(element_path) - 1]
+            if not should_skip and len(element_path) == len(elem_names):
+                yield element_path
+    except StopIteration:
+        pass
+
+
+def iter_config_attrs(filename, config_id, element_name):
+    """Determine from file whether to perform segment processing."""
+    with open(filename, "rb") as file:
+        doc = ebmlite.loadSchema("mide_ide.xml").load(file)
+        for elem_hierarchy in filter_elements_by_name(
+            doc,
+            [
+                "RecorderConfigurationList",
+                "RecorderConfigurationItem",
+                "ConfigID",
+            ],
+        ):
+            if elem_hierarchy[-1].value != config_id:
+                continue
+            for elem in elem_hierarchy[-2]:
+                if elem.name == element_name:
+                    yield elem.value
diff --git a/endaq/batch/utils/ide_utils.py b/endaq/batch/utils/ide_utils.py
new file mode 100644
index 0000000..c83f6e6
--- /dev/null
+++ b/endaq/batch/utils/ide_utils.py
@@ -0,0 +1,105 @@
+from collections import defaultdict, namedtuple
+import warnings
+
+from endaq.ide import to_pandas
+
+
+# Constant Channel definitions
+UTYPE_GROUPS = {
+    "Acceleration": "acc",  # acceleration
+    "Rotation": "gyr",
+    "Quaternion": "gyr",
+    "Audio": "mic",  # microphone audio
+    "Light": "lit",  # light (visible, UV)
+    "Pressure": "pre",  # pressures
+    "Temperature": "tmp",  # temperature
+    "Relative Humidity": "hum",  # Humidity
+    # Gyroscope; several channel unit types map to this
+    "Location": "gps",  # GPS
+    "GNSS Speed": "spd",  # GPS Ground Speed
+    "GNSS Epoch Time": "epo",  # GPS Epoch Time
+}
+
+
+class NoChannelException(Exception):
+    pass
+
+
+class ChannelStruct(namedtuple("ChannelStruct", "channel, sch_ids")):
+    @property
+    def eventarray(self):
+        return self.channel.getSession()
+
+    @property
+    def fs(self):
+        return self.eventarray.getSampleRate() if len(self.eventarray) > 0 else None
+
+    @property
+    def units(self):
+        return self.channel[self.sch_ids[0]].units
+
+    @property
+    def axis_names(self):
+        return [self.channel.subchannels[i].name for i in self.sch_ids]
+
+    def to_pandas(self, *args, **kwargs):
+        df = to_pandas(self.channel, *args, **kwargs).iloc[:, self.sch_ids]
+        df.columns.name = "axis"
+        return df
+
+
+def chs_by_utype(dataset):
+    """Group subchannels together by channel & utype."""
+    for ch_id, channel in dataset.channels.items():
+        # Group subchannel id's by unit type
+        sch_ids_by_utype = defaultdict(list)
+        for sch_id, subchannel in enumerate(channel.subchannels):
+            try:
+                utype_group = UTYPE_GROUPS[subchannel.units[0]]
+            except KeyError:
+                warnings.warn(
+                    f"skipped recording channel {ch_id}.{sch_id}"
+                    f' of unlisted unit type "{subchannel.units[0]}"',
+                    RuntimeWarning,
+                )
+            else:
+                sch_ids_by_utype[utype_group].append(sch_id)
+
+        # Separate each channel into subchannels of like-unit-type
+        for utype, sch_ids in sch_ids_by_utype.items():
+            yield utype, ChannelStruct(channel, sch_ids)
+
+
+def dict_groups(iterable):
+    """Group an iterable of key-value pairs into a dict of lists of items."""
+    result = defaultdict(list)
+    for k, v in iterable:
+        result[k].append(v)
+    return result
+
+
+def dict_chs_best(iterable, max_key=lambda x: len(x.eventarray)):
+    """
+    Group an iterable of utype-ch_struct pairs into a dict, keeping only those
+    channels with the highest sample length for their utype.
+    """
+    result = {}
+    for utype, ch_struct in iterable:
+        if not utype in result:
+            result[utype] = ch_struct
+            continue
+
+        result[utype] = max(result[utype], ch_struct, key=max_key)
+
+    return result
+
+
+def get_ch_type_best(dataset, utype, max_key=lambda x: len(x.eventarray)):
+    """Get the highest sample-length acceleration channel from a recording."""
+    chs = chs_by_utype(dataset)
+    utype_chs = (ch_struct for (ut, ch_struct) in chs if ut == utype)
+
+    try:
+        return max(utype_chs, key=max_key)
+    except ValueError:
+        raise NoChannelException(f'no channels of type "{utype}" in recording')
diff --git a/endaq/calc/README.md b/endaq/calc/README.md
new file mode 100644
index 0000000..ed62390
--- /dev/null
+++ b/endaq/calc/README.md
@@ -0,0 +1,60 @@
+# endaq-calc - a computational backend for vibration analysis
+
+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.
+
+endaq-calc is a sub-module of the larger enDAQ package. See [the endaq package](https://github.com/MideTechnology/endaq-python) for more details.
+
+## Usage Examples
+
+``` python
+import endaq
+```
+
+### Filters
+``` 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
+``` python
+dfs_integrate = endaq.calc.integrate.integrals(df_accel, n=2, highpass_cutoff=1.0, tukey_percent=0.05)
+```
+
+### PSD
+
+#### Linearly-spaced
+``` python
+df_accel_psd = endaq.calc.psd.welch(df_accel, bin_width=1/11)
+```
+
+#### Octave-spaced
+``` python
+df_accel_psd_oct = endaq.calc.psd.to_octave(df_accel_psd, fstart=1, octave_bins=3)
+```
+
+#### Derivatives & Integrals {#derivatives--integrals}
+``` 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
+``` python
+df_accel_vc = endaq.calc.psd.vc_curves(df_accel_psd, fstart=1, octave_bins=3)
+```
+
+### Shock Analysis
+``` 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
+``` python
+half_sine_params = endaq.calc.shock.enveloping_half_sine(df_accel_pvss, damp=0.05)
+```
+
+## Other Links
+- the endaq package - https://github.com/MideTechnology/endaq-python
+- the enDAQ homepage - https://endaq.com/
diff --git a/endaq/calc/__init__.py b/endaq/calc/__init__.py
new file mode 100644
index 0000000..18ff817
--- /dev/null
+++ b/endaq/calc/__init__.py
@@ -0,0 +1,7 @@
+from . import filters
+from . import integrate
+from . import psd
+from . import shock
+from . import stats
+
+from .utils import *
diff --git a/endaq/calc/filters.py b/endaq/calc/filters.py
new file mode 100644
index 0000000..867ccf6
--- /dev/null
+++ b/endaq/calc/filters.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from typing import Optional, Union, Tuple
+
+import pandas as pd
+import scipy.signal
+
+from endaq.calc import utils
+
+
+def butterworth(
+    df: pd.DataFrame,
+    low_cutoff: Optional[float] = 1.0,
+    high_cutoff: Optional[float] = None,
+    half_order: int = 3,
+) -> pd.DataFrame:
+    """
+    Apply a lowpass and/or a highpass Butterworth filter to an array.
+
+    This function uses Butterworth filter designs, and implements the filter(s)
+    as bi-directional digital biquad filters, split into second-order sections.
+
+    :param df: the input data; cutoff frequencies are relative to the
+        timestamps in `df.index`
+    :param low_cutoff: the low-frequency cutoff, if any; frequencies below this
+        value are rejected, and frequencies above this value are preserved
+    :param high_cutoff: the high-frequency cutoff, if any; frequencies above
+        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
+    :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.
+    """
+    cutoff_freqs: Union[float, Tuple[float, float]]
+    filter_type: str
+
+    if low_cutoff is not None and high_cutoff is not None:
+        cutoff_freqs = (low_cutoff, high_cutoff)
+        filter_type = "bandpass"
+    elif low_cutoff is not None:
+        cutoff_freqs = low_cutoff
+        filter_type = "highpass"
+    elif high_cutoff is not None:
+        cutoff_freqs = high_cutoff
+        filter_type = "lowpass"
+    else:
+        return 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",
+    )
+    array = scipy.signal.sosfiltfilt(sos_coeffs, df.to_numpy(), axis=0)
+
+    return pd.DataFrame(array, index=df.index, columns=df.columns)
diff --git a/endaq/calc/integrate.py b/endaq/calc/integrate.py
new file mode 100644
index 0000000..afe7759
--- /dev/null
+++ b/endaq/calc/integrate.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from typing import List, Optional, Iterable
+import functools
+
+import numpy as np
+import pandas as pd
+import scipy.integrate
+
+from endaq.calc import filters, utils
+
+
+def _integrate(df: pd.DataFrame) -> pd.DataFrame:
+    """Integrate data over an axis."""
+    dt = utils.sample_spacing(df)
+
+    result = df.apply(
+        functools.partial(scipy.integrate.cumulative_trapezoid, dx=dt, initial=0),
+        axis=0,
+        raw=True,
+    )
+    # 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)
+
+    return result
+
+
+def iter_integrals(
+    df: pd.DataFrame,
+    highpass_cutoff: Optional[float] = None,
+    filter_half_order: int = 3,
+    tukey_percent: float = 0,
+) -> Iterable[pd.DataFrame]:
+    """
+    Iterate over conditioned integrals of the given original data.
+
+    :param df: the input data
+    :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
+    :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 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.
+    """
+    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)
+
+
+def integrals(
+    df: pd.DataFrame,
+    n: int = 1,
+    highpass_cutoff: Optional[float] = None,
+    tukey_percent: float = 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 highpass_cutoff: the cutoff frequency for the initial highpass filter;
+        this is used to remove artifacts caused by DC trends
+    :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 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.
+    """
+    return [
+        integ
+        for _, integ in zip(
+            range(n + 1),
+            iter_integrals(
+                df,
+                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
new file mode 100644
index 0000000..0afb798
--- /dev/null
+++ b/endaq/calc/psd.py
@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import typing
+import warnings
+
+import numpy as np
+import pandas as pd
+import scipy.signal
+
+from endaq.calc import utils
+
+
+def _np_histogram_nd(array, bins=10, weights=None, axis=-1, **kwargs):
+    """Compute histograms over a specified axis."""
+    array = np.asarray(array)
+    bins = np.asarray(bins)
+    weights = np.asarray(weights) if weights is not None else None
+
+    # Collect all ND inputs
+    nd_params = {}
+    (nd_params if array.ndim > 1 else kwargs)["a"] = array
+    (nd_params if bins.ndim > 1 else kwargs)["bins"] = bins
+    if weights is not None:
+        (nd_params if weights.ndim > 1 else kwargs)["weights"] = weights
+
+    if len(nd_params) == 0:
+        return np.histogram(**kwargs)[0]
+
+    # Move the desired axes to the back
+    for k, v in nd_params.items():
+        nd_params[k] = np.moveaxis(v, axis, -1)
+
+    # Broadcast ND arrays to the same shape
+    ks, vs = zip(*nd_params.items())
+    vs_broadcasted = np.broadcast_arrays(*vs)
+    for k, v in zip(ks, vs_broadcasted):
+        nd_params[k] = v
+
+    # Generate output
+    bins = nd_params.get("bins", bins)
+
+    result_shape = ()
+    if len(nd_params) != 0:
+        result_shape = v.shape[:-1]
+    if bins.ndim >= 1:
+        result_shape = result_shape + (bins.shape[-1] - 1,)
+    else:
+        result_shape = result_shape + (bins,)
+
+    result = np.empty(
+        result_shape,
+        dtype=(weights.dtype if weights is not None else int),
+    )
+    loop_shape = v.shape[:-1]
+
+    for nd_i in np.ndindex(*loop_shape):
+        nd_kwargs = {k: v[nd_i] for k, v in nd_params.items()}
+        result[nd_i], edges = np.histogram(**nd_kwargs, **kwargs)
+
+    result = np.moveaxis(result, -1, axis)
+    return result
+
+
+def welch(
+    df: pd.DataFrame,
+    bin_width: float = 1,
+    scaling: typing.Literal[None, "density", "spectrum", "parseval"] = None,
+    **kwargs,
+) -> pd.DataFrame:
+    """
+    Perform `scipy.signal.welch` with a specified frequency spacing.
+
+    :param df: the input data
+    :param bin_width: the desired width of the resulting frequency bins, in Hz;
+        defaults to 1 Hz
+    :param scaling: the scaling of the output; `"density"` & `"spectrum"`
+        correspond to the same options in ``scipy.signal.welch``; `"parseval"`
+        will maintain the "energy" between the input & output, s.t.
+        ``welch(df, scaling="parseval").sum(axis="rows")`` is roughly equal to
+        ``df.abs().pow(2).sum(axis="rows")``
+    :param kwargs: other parameters to pass directly to ``scipy.signal.welch``
+    :return: a periodogram
+
+    .. seealso::
+
+        - `SciPy Welch's method <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.welch.html>`_
+          Documentation for the periodogram function wrapped internally.
+        - `Parseval's Theorem <https://en.wikipedia.org/wiki/Parseval's_theorem>`_
+          - the theorem relating the RMS of a time-domain signal to that of its
+          frequency spectrum
+    """
+    dt = utils.sample_spacing(df)
+    fs = 1 / dt
+
+    if scaling == "parseval":
+        kwargs["scaling"] = "density"
+    elif scaling is not None:
+        kwargs["scaling"] = scaling
+
+    freqs, psd = scipy.signal.welch(
+        df.values, fs=fs, nperseg=int(fs / bin_width), **kwargs, axis=0
+    )
+    if scaling == "parseval":
+        psd = psd * freqs[1]
+
+    return pd.DataFrame(
+        psd, index=pd.Series(freqs, name="frequency (Hz)"), columns=df.columns
+    )
+
+
+def differentiate(df: pd.DataFrame, n: float = 1) -> pd.DataFrame:
+    """
+    Perform time-domain differentiation on periodogram data.
+
+    :param df: a periodogram
+    :param n: the time derivative order; negative orders represent integration
+    :return: a periodogram of the time-derivated data
+    """
+    # Involves a division by zero for n < 0
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", RuntimeWarning)
+        factor = (2 * np.pi * df.index.values) ** (2 * n)  # divide by zero
+    if n < 0:
+        factor[df.index == 0] = 0
+
+    return df * factor[..., np.newaxis]
+
+
+def to_jagged(
+    df: pd.DataFrame,
+    freq_splits: np.array,
+    agg: typing.Union[
+        typing.Literal["mean", "sum"],
+        typing.Callable[[np.ndarray, int], float],
+    ] = "mean",
+) -> pd.DataFrame:
+    """
+    Calculate a periodogram over non-uniformly spaced frequency bins.
+
+    :param df: the returned values from ``endaq.calc.psd.welch``
+    :param freq_splits: the boundaries of the frequency bins; must be strictly
+        increasing
+    :param agg: the method for aggregating values into bins; `'mean'` preserves
+        the PSD's area-under-the-curve, `'sum'` preserves the PSD's "energy"
+    :return: a periodogram with the given frequency spacing
+    """
+    freq_splits = np.asarray(freq_splits)
+    if len(freq_splits) < 2:
+        raise ValueError("need at least two frequency bounds")
+    if not np.all(freq_splits[:-1] <= freq_splits[1:]):
+        raise ValueError("frequency bounds must be strictly increasing")
+
+    # Check that PSD samples do not skip any frequency bins
+    spacing_test = np.diff(np.searchsorted(freq_splits, df.index))
+    if np.any(spacing_test < 1):
+        warnings.warn(
+            "empty frequency bins in re-binned PSD; "
+            "original PSD's frequency spacing is too coarse",
+            RuntimeWarning,
+        )
+
+    if isinstance(agg, str):
+        if agg not in ("sum", "mean"):
+            raise ValueError(f'invalid aggregation mode "{agg}"')
+
+        # Reshape frequencies for histogram function
+        f_ndim = np.broadcast_to(df.index.to_numpy()[..., np.newaxis], df.shape)
+
+        # Calculate sum via histogram function
+        psd_jagged = _np_histogram_nd(f_ndim, bins=freq_splits, weights=df, axis=0)
+
+        # Adjust values for mean calculation
+        if agg == "mean":
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore", category=RuntimeWarning)  # x/0
+
+                psd_jagged = np.nan_to_num(  # <- fix divisions by zero
+                    psd_jagged
+                    / np.histogram(df.index, bins=freq_splits)[0][..., np.newaxis]
+                )
+
+    else:
+        psd_binned = np.split(df, np.searchsorted(df.index, freq_splits), axis=0)[1:-1]
+        psd_jagged = np.stack([agg(a, axis=0) for a in psd_binned], axis=0)
+
+    return pd.DataFrame(
+        psd_jagged,
+        index=pd.Series((freq_splits[1:] + freq_splits[:-1]) / 2, name=df.index.name),
+        columns=df.columns,
+    )
+
+
+def to_octave(
+    df: pd.DataFrame, fstart: float = 1, octave_bins: float = 12, **kwargs
+) -> pd.DataFrame:
+    """
+    Calculate a periodogram over log-spaced frequency bins.
+
+    :param df: the returned values from ``endaq.calc.psd.welch``
+    :param fstart: the first frequency bin, in Hz; defaults to 1 Hz
+    :param octave_bins: the number of frequency bins in each octave; defaults
+        to 12
+    :param kwargs: other parameters to pass directly to ``to_jagged``
+    :return: a periodogram with the given logarithmic frequency spacing
+    """
+    max_f = df.index.max()
+
+    octave_step = 1 / octave_bins
+    center_freqs = 2 ** np.arange(
+        np.log2(fstart),
+        np.log2(max_f) + octave_step / 2,
+        octave_step,
+    )
+    freq_splits = 2 ** np.arange(
+        np.log2(fstart) - octave_step / 2,
+        np.log2(max_f) + octave_step,
+        octave_step,
+    )
+    assert len(center_freqs) + 1 == len(freq_splits)
+
+    result = to_jagged(df, freq_splits=freq_splits, **kwargs)
+    result.index = pd.Series(center_freqs, name=df.index.name)
+    return result
+
+
+def vc_curves(
+    accel_psd: pd.DataFrame, fstart: float = 1, octave_bins: float = 12
+) -> pd.DataFrame:
+    """
+    Calculate Vibration Criterion (VC) curves from an acceration periodogram.
+
+    :param accel_psd: a periodogram of the input acceleration
+    :param fstart: the first frequency bin
+    :param octave_bins: the number of frequency bins in each octave; defaults
+        to 12
+    :return: the Vibration Criterion (VC) curve of the input acceleration
+    """
+    """
+    # Theory behind the calculation
+
+    Let x(t) be a real-valued time-domain signal, and X(2πf) = F{x(t)}(2πf)
+    be the Fourier Transform of that signal. By Parseval's Theorem,
+
+        ∫x(t)^2 dt = ∫|X(2πf)|^2 df
+
+    (see https://en.wikipedia.org/wiki/Parseval%27s_theorem#Notation_used_in_physics)
+
+    Rewriting the right side of that equation in the discrete form becomes
+
+        ∫x(t)^2 dt ≈ ∑ |X[k]|^2 • ∆f
+
+    where ∆f = fs/N = (1/∆t) / N = 1/T.
+    Limiting the right side to a range of discrete frequencies (k_0, k_1):
+
+        ∫x(t)^2 dt ≈ [∑; k=k_0 -> k≤k_1] |X[k]|^2 • ∆f
+
+    The VC curve calculation is the RMS over the time-domain. If T is the
+    duration of the time-domain signal, then:
+
+        √((1/T) ∫x(t)^2 dt)
+            ≈ √((1/T) [∑; k=k_0 -> k≤k_1] |X[k]|^2 • ∆f)
+            = ∆f • √([∑; k=k_0 -> k≤k_1] |X[k]|^2)
+
+    If the time-series data is acceleration, then the signal needs to first
+    be integrated into velocity. This can be done in the frequency domain
+    by replacing |X(2πf)|^2 with (1/2πf)^2 |X(2πf)|^2.
+    """
+    df_vel = differentiate(accel_psd, n=-1)
+    df_vel_oct = to_octave(
+        df_vel,
+        fstart=fstart,  # Hz
+        octave_bins=octave_bins,
+        agg=np.sum,
+    )
+
+    # The PSD must already scale by ∆f -> need only scale by √∆f?
+    # TODO make `density` parameter, scale differently depending on mode
+    return np.sqrt(accel_psd.index[1]) * df_vel_oct.apply(np.sqrt)
diff --git a/endaq/calc/shock.py b/endaq/calc/shock.py
new file mode 100644
index 0000000..c4841e0
--- /dev/null
+++ b/endaq/calc/shock.py
@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import typing
+from typing import Tuple
+from collections import namedtuple
+import functools
+import warnings
+
+import numpy as np
+import pandas as pd
+import scipy.signal
+
+from endaq.calc.stats import L2_norm
+from endaq.calc import utils
+
+
+def _rel_displ_transfer_func(
+    omega: float, damp: float = 0.0, dt: float = 1.0
+) -> scipy.signal.ltisys.TransferFunctionDiscrete:
+    """
+    Generate the transfer function
+        H(s) = L{z(t)}(s) / L{y"(t)}(s) = (1/s²)(Z(s)/Y(s))
+    for the PDE
+        z" + (2ζω)z' + (ω²)z = -y"
+
+    .. seealso::
+
+        - `Pseudo Velocity Shock Spectrum Rules For Analysis Of Mechanical Shock, Howard A. Gaberson <https://info.endaq.com/hubfs/pvsrs_rules.pdf>`_
+        - `SciPy transfer functions <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.TransferFunction.html>`_
+          Documentation for the transfer function class used to characterize the
+          relative displacement calculation.
+    """
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", scipy.signal.BadCoefficients)
+
+        return scipy.signal.TransferFunction(
+            [-1],
+            [1, 2 * damp * omega, omega ** 2],
+        ).to_discrete(dt=dt)
+
+
+def rel_displ(accel: pd.DataFrame, omega: float, damp: float = 0.0) -> pd.DataFrame:
+    """
+    Calculate the relative displacement for a SDOF system.
+
+    The "relative" displacement follows the transfer function:
+        H(s) = L{z(t)}(s) / L{y"(t)}(s) = (1/s²)(Z(s)/Y(s))
+    for the PDE:
+        z" + (2ζω)z' + (ω²)z = -y"
+
+    :param accel: the absolute acceleration y"
+    :param omega: the natural frequency ω of the SDOF system
+    :param damp: the damping coefficient ζ of the SDOF system
+    :return: the relative displacement z of the SDOF system
+
+    .. seealso::
+
+        - `Pseudo Velocity Shock Spectrum Rules For Analysis Of Mechanical Shock, Howard A. Gaberson <https://info.endaq.com/hubfs/pvsrs_rules.pdf>`_
+        - `SciPy transfer functions <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.TransferFunction.html>`_
+          Documentation for the transfer function class used to characterize the
+          relative displacement calculation.
+        - `SciPy biquad filter <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.lfilter.html>`_
+          Documentation for the biquad function used to implement the transfer
+          function.
+    """
+    dt = utils.sample_spacing(accel)
+    tf = _rel_displ_transfer_func(omega, damp, dt)
+
+    return accel.apply(
+        functools.partial(scipy.signal.lfilter, tf.num, tf.den, axis=0),
+        raw=True,
+    )
+
+
+def _abs_accel_transfer_func(
+    omega: float, damp: float = 0.0, dt: float = 1.0
+) -> scipy.signal.ltisys.TransferFunctionDiscrete:
+    """
+    Generate the transfer function
+        H(s) = L{x"(t)}(s) / L{y"(t)}(s) = X(s)/Y(s)
+    for the PDE
+        x" + (2ζω)x' + (ω²)x = (2ζω)y' + (ω²)y
+
+    .. seealso::
+
+        - `An Introduction To The Shock Response Spectrum, Tom Irvine, 9 July 2012 <http://www.vibrationdata.com/tutorials2/srs_intr.pdf>`_
+        - `SciPy transfer functions <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.TransferFunction.html>`_
+          Documentation for the transfer function class used to characterize the
+          relative displacement calculation.
+    """
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", scipy.signal.BadCoefficients)
+
+        return scipy.signal.TransferFunction(
+            [0, 2 * damp * omega, omega ** 2],
+            [1, 2 * damp * omega, omega ** 2],
+        ).to_discrete(dt=dt)
+
+
+def abs_accel(accel: pd.DataFrame, omega: float, damp: float = 0.0) -> pd.DataFrame:
+    """
+    Calculate the absolute acceleration for a SDOF system.
+
+    The "absolute acceleration" follows the transfer function:
+        H(s) = L{x"(t)}(s) / L{y"(t)}(s) = X(s)/Y(s)
+    for the PDE:
+        x" + (2ζω)x' + (ω²)x = (2ζω)y' + (ω²)y
+
+    :param accel: the absolute acceleration y"
+    :param omega: the natural frequency ω of the SDOF system
+    :param damp: the damping coefficient ζ of the SDOF system
+    :return: the absolute acceleration x" of the SDOF system
+
+    .. seealso::
+
+        - `An Introduction To The Shock Response Spectrum, Tom Irvine, 9 July 2012 <http://www.vibrationdata.com/tutorials2/srs_intr.pdf>`_
+        - `SciPy transfer functions <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.TransferFunction.html>`_
+          Documentation for the transfer function class used to characterize the
+          relative displacement calculation.
+        - `SciPy biquad filter <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.lfilter.html>`_
+          Documentation for the biquad function used to implement the transfer
+          function.
+    """
+    dt = utils.sample_spacing(accel)
+    tf = _abs_accel_transfer_func(omega, damp, dt)
+
+    return accel.apply(
+        functools.partial(scipy.signal.lfilter, tf.num, tf.den, axis=0),
+        raw=True,
+    )
+
+
+def shock_spectrum(
+    accel: pd.DataFrame,
+    freqs: np.ndarray,
+    damp: float = 0.0,
+    mode: typing.Literal["srs", "pvss"] = "srs",
+    two_sided: bool = False,
+    aggregate_axes: bool = False,
+) -> pd.DataFrame:
+    """
+    Calculate the shock spectrum of an acceleration signal.
+
+    :param accel: the absolute acceleration y"
+    :param freqs: the natural frequencies across which to calculate the spectrum
+    :param damp: the damping coefficient ζ, related to the Q-factor by ζ = 1/(2Q);
+        defaults to 0
+    :param mode: the type of spectrum to calculate:
+
+        - `'srs'` (default) specifies the Shock Response Spectrum (SRS)
+        - `'pvss'` specifies the Pseudo-Velocity Shock Spectrum (PVSS)
+    :param two_sided: whether to return for each frequency:
+        both the maximum negative and positive shocks (`True`),
+        or simply the maximum absolute shock (`False`; default)
+    :param aggregate_axes: whether to calculate the column-wise resultant (`True`)
+        or calculate spectra along each column independently (`False`; default)
+    :return: the shock spectrum
+
+    .. seealso::
+
+        - `Pseudo Velocity Shock Spectrum Rules For Analysis Of Mechanical Shock, Howard A. Gaberson <https://info.endaq.com/hubfs/pvsrs_rules.pdf>`_
+        - `An Introduction To The Shock Response Spectrum, Tom Irvine, 9 July 2012 <http://www.vibrationdata.com/tutorials2/srs_intr.pdf>`_
+        - `SciPy transfer functions <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.TransferFunction.html>`_
+          Documentation for the transfer function class used to characterize the
+          relative displacement calculation.
+        - `SciPy biquad filter <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.lfilter.html>`_
+          Documentation for the biquad function used to implement the transfer
+          function.
+    """
+    if two_sided and aggregate_axes:
+        raise ValueError("cannot enable both options `two_sided` and `aggregate_axes`")
+    freqs = np.asarray(freqs)
+    if freqs.ndim != 1:
+        raise ValueError("target frequencies must be in a 1D-array")
+    omega = 2 * np.pi * freqs
+
+    if mode == "srs":
+        make_tf = _abs_accel_transfer_func
+    elif mode == "pvss":
+        make_tf = _rel_displ_transfer_func
+    else:
+        raise ValueError(f"invalid spectrum mode {mode:r}")
+
+    results = np.empty(
+        (2,) + freqs.shape + ((1,) if aggregate_axes else accel.shape[1:]),
+        dtype=np.float64,
+    )
+
+    dt = utils.sample_spacing(accel)
+    T_padding = 1 / (
+        freqs.min() * np.sqrt(1 - damp ** 2)
+    )  # uses lowest damped frequency
+    if not two_sided:
+        T_padding /= 2
+
+    zi = np.zeros((2,) + accel.shape[1:])
+    zero_padding = np.zeros((int(T_padding // dt) + 1,) + accel.shape[1:])
+
+    for i_nd in np.ndindex(freqs.shape):
+        tf = make_tf(omega[i_nd], damp, dt)
+        rd, zf = scipy.signal.lfilter(tf.num, tf.den, accel.to_numpy(), zi=zi, axis=0)
+        rd_padding, _ = scipy.signal.lfilter(
+            tf.num, tf.den, zero_padding, zi=zf, axis=0
+        )
+
+        if aggregate_axes:
+            rd = L2_norm(rd, axis=-1, keepdims=True)
+            rd_padding = L2_norm(rd_padding, axis=-1, keepdims=True)
+
+        results[(0,) + i_nd] = -np.minimum(rd.min(axis=0), rd_padding.min(axis=0))
+        results[(1,) + i_nd] = np.maximum(rd.max(axis=0), rd_padding.max(axis=0))
+
+    if mode == "pvss":
+        results = results * omega[..., np.newaxis]
+
+    if aggregate_axes or not two_sided:
+        return pd.DataFrame(
+            np.maximum(results[0], results[1]),
+            index=pd.Series(freqs, name="frequency (Hz)"),
+            columns=(["resultant"] if aggregate_axes else accel.columns),
+        )
+
+    return namedtuple("PseudoVelocityResults", "neg pos")(
+        *(
+            pd.DataFrame(
+                r, index=pd.Series(freqs, name="frequency (Hz)"), columns=accel.columns
+            )
+            for r in results
+        )
+    )
+
+
+def enveloping_half_sine(
+    pvss: pd.DataFrame,
+    damp: float = 0.0,
+) -> Tuple[pd.Series, pd.Series]:
+    """
+    Characterize a half-sine pulse whose PVSS envelopes the input.
+
+    :param pvss: the PVSS to envelope
+    :param damp: the damping factor used to generate the input PVSS
+    :return: a tuple of amplitudes and periods, each pair of which describes a
+        half-sine pulse
+
+    .. seealso::
+
+        `Pseudo Velocity Shock Spectrum Rules For Analysis Of Mechanical Shock, Howard A. Gaberson <https://info.endaq.com/hubfs/pvsrs_rules.pdf>`_
+    """
+
+    def amp_factor(damp):
+        """
+        Calculate the PVSS amplitude attenuation on a half-sine pulse from the
+        damping coefficient.
+
+        The PVSS of a half-sine pulse differs based on the damping coefficient
+        used. While the high-frequency rolloff is relatively consistent, the
+        flat low-frequency amplitude is attenuated at higher damping values.
+        This function calculates this attenuation for a given damping
+        coefficient.
+        """
+        # This calculates the PVSS value as ω->0. However, since it necessarily
+        # computes the maximum of a function *over time*, and ω is only found
+        # therein in the multiplicative factor (ωt), it is mathematically
+        # equivalent to compute this maximum for any arbitrary ω>0. Thus we
+        # choose ω=1 for convenience, w/o loss of generality.
+        a = np.exp(1j * np.arccos(-damp))  # = -damp + 1j * np.sqrt(1 - damp**2)
+        # From WolframAlpha: https://www.wolframalpha.com/input/?i=D%5BPower%5Be%2C%5C%2840%29-d+*t%5C%2841%29%5D+sin%5C%2840%29Sqrt%5B1-Power%5Bd%2C2%5D%5D*t%5C%2841%29%2Ct%5D+%3D+0&assumption=%22ListOrTimes%22+-%3E+%22Times%22&assumption=%7B%22C%22%2C+%22e%22%7D+-%3E+%7B%22NamedConstant%22%7D&assumption=%7B%22C%22%2C+%22d%22%7D+-%3E+%7B%22Variable%22%7D&assumption=%22UnitClash%22+-%3E+%7B%22d%22%2C+%7B%22Days%22%7D%7D
+        t_max = (2 / np.imag(a)) * np.arctan2(np.imag(a), 1 - np.real(a))
+        PVSS_max = (1 / np.imag(a)) * np.imag(np.exp(a * t_max))
+        return PVSS_max
+
+    max_pvss = pvss.max()
+    max_f_pvss = pvss.mul(pvss.index, axis=0).max()
+
+    return namedtuple("HalfSinePulseParameters", "amplitude, period")(
+        amplitude=2 * np.pi * max_f_pvss,
+        period=max_pvss / (4 * amp_factor(damp) * max_f_pvss),
+    )
diff --git a/endaq/calc/stats.py b/endaq/calc/stats.py
new file mode 100644
index 0000000..c3e8fb0
--- /dev/null
+++ b/endaq/calc/stats.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import typing  # for `SupportsIndex`, which is Python3.8+ only
+from typing import Union
+from collections.abc import Sequence
+
+import numpy as np
+import pandas as pd
+
+
+def L2_norm(
+    array: np.ndarray,
+    axis: Union[None, typing.SupportsIndex, Sequence[typing.SupportsIndex]] = None,
+    keepdims: bool = False,
+) -> np.ndarray:
+    """
+    Compute the L2 norm (a.k.a. the Euclidean Norm).
+
+    :param array: the input array
+    :param axis: the axis/axes along which to aggregate; if `None` (default),
+        the L2 norm is computed along the flattened array
+    :param keepdims: if `True`, the axes which are reduced are left in the
+        result as dimensions of size one; if `False` (default), the reduced
+        axes are removed
+    :return: an array containing the computed values
+    """
+    return np.sqrt(np.sum(np.abs(array) ** 2, axis=axis, keepdims=keepdims))
+
+
+def max_abs(
+    array: np.ndarray,
+    axis: Union[None, typing.SupportsIndex, Sequence[typing.SupportsIndex]] = None,
+    keepdims: bool = False,
+) -> np.ndarray:
+    """
+    Compute the maximum of the absolute value of an array.
+
+    This function should be equivalent to, but generally use less memory than
+    ``np.amax(np.abs(array))``.
+
+    Specifically, it generates the absolute-value maximum from ``np.amax(array)``
+    and ``-np.amin(array)``. Thus instead of allocating space for the
+    intermediate array ``np.abs(array)``, it allocates for the axis-collapsed
+    smaller arrays ``np.amax(array)`` & ``np.amin(array)``.
+
+    .. note:: This method does **not** work on complex-valued arrays.
+
+    :param array: the input data
+    :param axis: the axis/axes along which to aggregate; if `None` (default),
+        the absolute maximum is computed along the flattened array
+    :param keepdims: if `True`, the axes which are reduced are left in the
+        result as dimensions with size one; if `False` (default), the reduced
+        axes are removed
+    :return: an array containing the computed values
+    """
+    # Forbid complex-valued data
+    if np.iscomplexobj(array):
+        raise ValueError("`max_abs` does not accept complex arrays")
+
+    return np.maximum(
+        np.amax(array, initial=-np.inf, axis=axis, keepdims=keepdims),
+        -np.amin(array, initial=np.inf, axis=axis, keepdims=keepdims),
+    )
+
+
+def rms(
+    array: np.ndarray,
+    axis: Union[None, typing.SupportsIndex, Sequence[typing.SupportsIndex]] = None,
+    keepdims: bool = False,
+) -> np.ndarray:
+    """
+    Calculate the root-mean-square (RMS) along a given axis.
+
+    :param array: the input array
+    :param axis: the axis/axes along which to aggregate; if `None` (default),
+        the RMS is computed along the flattened array
+    :param keepdims: if `True`, the axes which are reduced are left in the
+        result as dimensions with size one; if `False` (default), the reduced
+        axes are removed
+    :return: an array containing the computed values
+    """
+    return np.sqrt(np.mean(np.abs(array) ** 2, axis=axis, keepdims=keepdims))
+
+
+def rolling_rms(
+    df: Union[pd.DataFrame, pd.Series], window_len: int, *args, **kwargs
+) -> Union[pd.DataFrame, pd.Series]:
+    """
+    Calculate a rolling root-mean-square (RMS) over a pandas `DataFrame`.
+
+    This function is equivalent to, but computationally faster than the following::
+
+        df.rolling(window_len).apply(endaq.calc.stats.rms)
+
+    :param df: the input data
+    :param window_len: the length of the rolling window
+    :param args: the positional arguments to pass into ``df.rolling().mean``
+    :param kwargs: the keyword arguments to pass into ``df.rolling().mean``
+    :return: the rolling-windowed RMS
+
+    .. seealso::
+        
+        - `Pandas Rolling Mean <https://pandas.pydata.org/docs/reference/api/pandas.core.window.rolling.Rolling.mean.html>`_
+          - official documentation for ``df.rolling().mean``
+        - `Pandas Rolling Standard Deviation method <https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.core.window.rolling.Rolling.std.html>`_
+          - similar to this function, but first removes the windowed mean before squaring
+    """
+    return df.pow(2).rolling(window_len).mean(*args, **kwargs).apply(np.sqrt, raw=True)
diff --git a/endaq/calc/utils.py b/endaq/calc/utils.py
new file mode 100644
index 0000000..5d9f3fe
--- /dev/null
+++ b/endaq/calc/utils.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import annotations
+
+import typing
+from typing import Optional, Union
+import warnings
+
+import numpy as np
+import pandas as pd
+import scipy.signal
+
+
+def sample_spacing(
+    df: 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 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 convert == "to_seconds" and isinstance(dt, (np.timedelta64, pd.Timedelta)):
+        dt = dt / np.timedelta64(1, "s")
+
+    return dt
+
+
+def logfreqs(
+    df: pd.DataFrame, init_freq: Optional[float] = None, bins_per_octave: float = 12
+) -> np.ndarray:
+    """
+    Calculate a sequence of log-spaced frequencies for a given dataframe.
+
+    :param df: the input data
+    :param init_freq: the initial frequency in the sequence; if `None` (default),
+        use the frequency corresponding to the data's duration
+    :param bins_per_octave: the number of frequencies per octave
+    :return: an array of log-spaced frequencies
+    """
+    dt = sample_spacing(df)
+    T = dt * len(df.index)
+
+    if init_freq is None:
+        init_freq = 1 / T
+    elif 1 / init_freq > T:
+        warnings.warn(
+            "the data's duration is too short to accurately represent an"
+            f" initial frequency of {init_freq:.3f} Hz",
+            RuntimeWarning,
+        )
+
+    return 2 ** np.arange(
+        np.log2(init_freq),
+        np.log2(1 / dt) - 1,
+        1 / bins_per_octave,
+    )
+
+
+def to_dB(data: np.ndarray, reference: float, squared: bool = False) -> np.ndarray:
+    """
+    Scale data into units of decibels.
+
+    Decibels are a log-scaled ratio of some value against a reference;
+    typically this is expressed as follows:
+
+    .. math:: dB = 10 \\log10\\left( \\frac{x}{x_{\\text{ref}}} \\right)
+
+    By convention, "decibel" units tend to operate on units of *power*. For
+    units that are proportional to power *when squared* (e.g., volts, amps,
+    pressure, etc.), their "decibel" representation is typically doubled (i.e.,
+    :math:`dB = 20 \\log10(...)`). Users can specify which scaling to use
+    with the `squared` parameter.
+
+    .. note::
+        Decibels can **NOT** be calculated from negative values.
+
+        For example, to calculate dB on arbitrary time-series data, typically
+        data is first aggregated via a total or a rolling RMS or PSD, and the
+        non-negative result is then scaled into decibels.
+
+    :param data: the input data
+    :param reference: the reference value corresponding to 0dB
+    :param squared: whether the input data & reference value are pre-squared;
+        defaults to `False`
+
+    .. seealso::
+        - ``endaq.calc.stats.rms``
+        - ``endaq.calc.stats.rolling_rms``
+        - ``endaq.calc.psd.welch``
+    """
+    if reference <= 0:
+        raise ValueError("reference value must be strictly positive")
+
+    data = np.asarray(data)
+    if np.any(data < 0):
+        raise ValueError(
+            "cannot compute decibels from negative values (see the docstring"
+            " for details)"
+        )
+
+    return (10 if squared else 20) * (np.log10(data) - np.log10(reference))
+
+
+dB_refs = {
+    "SPL": 2e-5,  # Pascal
+    "audio_intensity": 1e-12,  # W/m²
+}
+
+
+def resample(df: pd.DataFrame, sample_rate: Optional[float] = None) -> pd.DataFrame:
+    """
+    Resample a dataframe to a desired sample rate (in Hz)
+
+    :param df: The DataFrame to resample, indexed by time
+    :param sample_rate: The desired sample rate to resample the given data to.
+     If one is not supplied, then it will use the same as it currently does, but
+     make the time stamps uniformally spaced
+    :return: The resampled data in a DataFrame
+    """
+    if sample_rate is None:
+        num_samples_after_resampling = len(df)
+    else:
+        dt = sample_spacing(df)
+        num_samples_after_resampling = int(dt * len(df) * sample_rate)
+
+    resampled_data, resampled_time = scipy.signal.resample(
+        df,
+        num_samples_after_resampling,
+        t=df.index,
+    )
+    resampled_df = pd.DataFrame(
+        resampled_data,
+        index=resampled_time,
+        columns=df.columns,
+    )
+    return resampled_df
diff --git a/endaq/cloud/API_wrapper.py b/endaq/cloud/API_wrapper.py
new file mode 100644
index 0000000..d2d60dd
--- /dev/null
+++ b/endaq/cloud/API_wrapper.py
@@ -0,0 +1,271 @@
+import argparse
+import pathlib
+import sys
+import textwrap
+import urllib.parse
+
+import requests
+import os
+from dotenv import load_dotenv, find_dotenv
+import csv
+
+load_dotenv(find_dotenv())
+
+URL = 'https://qvthkmtukh.execute-api.us-west-2.amazonaws.com/master'
+PARAMETERS = {
+        "x-api-key": os.environ.get('API_KEY')
+    }
+
+
+def attributes_file(data, output_path):
+    """
+    Write the given attributes to a file.
+
+    :param data: All the attributes for the files
+    :param output_path: File path
+    """
+    with open(output_path + 'attributes.csv', 'w', newline='') as att_file:
+        csv_writer = csv.writer(att_file)
+        csv_writer.writerow(['file_id', 'attribute'])
+        for x in data:
+            for y in x['attributes']:
+                csv_writer.writerow([x['id'], y])
+
+
+def account_info(verbose):
+    """Request account info from the cloud and print the results."""
+    if verbose:
+        print('GET', URL + '/api/v1/account/info')
+    response = requests.get(URL + '/api/v1/account/info', headers=PARAMETERS)
+    print(response.json())
+
+
+def all_files(att, limit, output_path, verbose):
+    """
+    Request file data from the cloud and write the output to a file.
+
+    :param att: list of attributes to be shown
+    :param limit: how many files to be listed
+    :param output_path: location of output files
+    """
+    query_data = {k: v for k, v in dict(limit=limit, attributes=att).items() if v}
+    query = ('?' + urllib.parse.urlencode(query_data)) if query_data else ''
+
+    if verbose:
+        print('GET', URL + '/api/v1/files' + query)
+    response = requests.get(URL + '/api/v1/files' + query, headers=PARAMETERS)
+    data = response.json()['data']
+    if att:
+        attributes_file(data, output_path)
+
+    with open(output_path + 'files.csv', 'w', newline='') as data_file:
+        csv_writer = csv.writer(data_file)
+        headers = list(data[0].keys())
+        headers.remove('attributes')
+        csv_writer.writerow(headers)
+        for x in data:
+            del x['attributes']
+            csv_writer.writerow(x.values())
+
+
+def download_file(file_id, output_path=None):
+    """Download the file with the given ID."""
+    # Request a download link for the file
+    request_url = f"{URL}/api/v1/files/download/{file_id}"
+    response = requests.get(request_url, headers=PARAMETERS)
+    try:
+        response.raise_for_status()
+    except requests.HTTPError as ex:
+        raise RuntimeError(f"failed to retrieve file download link ({response.status_code})")
+
+    # Request the contents of the file
+    response_json = response.json()
+    download_url = response_json["url"]
+    download_filename = response_json["file_name"]
+    download_response = requests.get(download_url, stream=True)
+    try:
+        download_response.raise_for_status()
+    except requests.HTTPError as ex:
+        raise RuntimeError(f"failed to download file ({download_response.status_code})")
+
+    # Save the contents of the file to disk
+    output_path = pathlib.Path(output_path or ".")
+    file_path = output_path / download_filename
+    with open(file_path, "wb") as file:
+        for chunk in download_response.iter_content(chunk_size=None):
+            file.write(chunk)
+
+
+def file_by_id(id_, output_path, verbose):
+    """
+    Request a specific file's data from the cloud and write the output to files.
+
+    :param id_: File ID
+    :param output_path: Output directory
+    """
+    if verbose:
+        print('GET', URL + '/api/v1/files/' + id_)
+    response = requests.get(URL + '/api/v1/files/' + id_, headers=PARAMETERS)
+    data = response.json()
+    if 'code' in data:
+        raise ValueError(f'invalid file ID "{id_}"')
+
+    with open(output_path + 'attributes.csv', 'w', newline='') as att_file:
+        csv_writer = csv.writer(att_file)
+        headers = list(data['attributes'][0].keys())
+        csv_writer.writerow(headers)
+        for x in data['attributes']:
+            csv_writer.writerow(x.values())
+
+    del data['attributes']
+    with open(output_path + 'file_' + id_ + '.csv', 'w', newline='') as data_file:
+        csv_writer = csv.writer(data_file)
+        headers = data.keys()
+        csv_writer.writerow(headers)
+        csv_writer.writerow(data.values())
+
+
+def devices(output_path, limit, verbose):
+    """
+    Request devices' data from the cloud and write the output to a file.
+
+    :param output_path: output file location
+    :param limit: number of devices shown
+    """
+    if verbose:
+        print('GET', URL + '/api/v1/devices/')
+    if limit == '':
+        response = requests.get(URL + '/api/v1/devices/', headers=PARAMETERS)
+    else:
+        response = requests.get(URL + '/api/v1/devices?limit=' + limit, headers=PARAMETERS)
+
+    data = response.json()['data']
+    with open(output_path + 'devices.csv', 'w', newline='') as data_file:
+        csv_writer = csv.writer(data_file)
+        headers = list(data[0].keys())
+        csv_writer.writerow(headers)
+        for x in data:
+            csv_writer.writerow(x.values())
+
+
+def device_by_id(id_, output_path, verbose):
+    """
+    Request a specific device's data from the cloud and write the output to a file.
+    
+    :param id_: ID of specific device
+    :param output_path: output location for information
+    """
+    if verbose:
+        print('GET', URL + '/api/v1/devices/' + id_)
+    response = requests.get(URL + '/api/v1/devices/' + id_, headers=PARAMETERS)
+
+    data = response.json()
+    if 'code' in data:
+        raise ValueError(f'invalid device ID "{id_}"')
+    data_file = open(output_path + 'device_' + id_ + '.csv', 'w', newline='')
+    csv_writer = csv.writer(data_file)
+    headers = data.keys()
+    csv_writer.writerow(headers)
+    csv_writer.writerow(data.values())
+
+
+def post_attribute(id_, name, type_, value, verbose):
+    """
+    Post attribute data to the cloud and print the results.
+
+    :param id_: file ID
+    :param name: attribute name
+    :param type_: attribute type
+    :param value: attribute value
+    """
+    if verbose:
+        print('POST', URL + '/api/v1/attributes')
+    attributes = {"name": name,
+                  "type": type_,
+                  "value": value,
+                  "file_id": id_}
+    att = requests.post(URL + '/api/v1/attributes', headers=PARAMETERS, json={"attributes": [attributes]})
+    print(att.json())
+
+
+def make_env(key):
+    with open('.env', 'w') as f:
+        f.write(f'API_KEY={key}')
+
+
+def main():
+    parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
+                                     description=textwrap.dedent('''\
+    Commands:
+        - files               Outputs file information for selected number of files and attributes
+        - file-id             Outputs file information for file with specified ID to output file
+        - devices             Ouputs device information for selected number of files
+        - device-id           Output device information for device with specified ID to output file
+        - account             Prints out account information
+        - attributes          Adds an attribute to a specified file
+        - set-env             Creates a .env file with passed in API key *NOT SECURE*
+    '''))
+    parser.add_argument('command', choices=['files', 'download', 'file-id', 'devices', 'device-id', 'account', 'attribute',
+                                            'set-env'])
+    parser.add_argument('--id', '-i', default='', help='Device or File id')
+    parser.add_argument('--attributes', '-a', default='', help='What attributes you want to view; default is none, '
+                                                               'can be all, or att1,att2,...')
+    parser.add_argument('--limit', '-l', default='', help='How many devices or files you want returned; Max 100')
+    parser.add_argument('--key', '-k', default='', help='API key if it is not in the .env file or you want to set env')
+    parser.add_argument('--name', '-n', help='Name of new attribute')
+    parser.add_argument('--type', '-t', help='Type of element the new attribute is')
+    parser.add_argument('--value', '-v', help='Value of new attribute')
+    parser.add_argument('--verbose', '-V', action='store_true', help='Shows API url calls')
+
+    parser.add_argument('--url', '-u', default='', help=argparse.SUPPRESS)
+    parser.add_argument('--output', '-o', default='')
+    args = parser.parse_args()
+
+    # Specifies output directory if one is passed in
+    output = args.output or './output/'
+    if not os.path.exists(output):
+        os.makedirs(output)
+
+    # changes API url if one is passed in. (ONLY FOR DEVS)
+    global URL
+    if args.url != '':
+        URL = args.url
+
+    # changes API key if one is passed in
+    if args.key != '':
+        PARAMETERS.update({"x-api-key": args.key})
+    elif os.environ.get('API_KEY') is None:
+        sys.exit('Create a .env with API Key or pass it in with --key')
+
+    if args.command == 'account':
+        account_info(args.verbose)
+    elif args.command == 'download':
+        download_file(args.id, output)
+    elif args.command == 'files':
+        all_files(args.attributes, args.limit, output, args.verbose)
+        print('output can be found in output/files.csv and output/attributes.csv')
+    elif args.command == 'file-id' and args.id != '':
+        file_by_id(args.id, output, args.verbose)
+        print(f'output can be found in output/file_{args.id}.csv and output/attributes.csv')
+    elif args.command == 'devices':
+        devices(output, args.limit, args.verbose)
+        print('output can be found in devices.csv')
+    elif args.command == 'device-id' and args.id != '':
+        device_by_id(args.id, output, args.verbose)
+        print(f'output can be found in device_{args.id}.csv')
+    elif args.command == 'attribute':
+        if args.name is not None and args.type is not None and args.value is not None and args.id is not None:
+            post_attribute(args.id, args.name, args.type, args.value, args.verbose)
+        else:
+            sys.exit('id, name, type, and value can\'t be empty')
+    elif args.command == 'set-env':
+        if args.key == '':
+            sys.exit('Need -k or --key for this command')
+        else:
+            make_env(args.key)
+    else:
+        sys.exit('Use -h for help with commands.')
+
+
+if __name__ == '__main__':
+    main()
diff --git a/endaq/cloud/README.md b/endaq/cloud/README.md
new file mode 100644
index 0000000..a59b96d
--- /dev/null
+++ b/endaq/cloud/README.md
@@ -0,0 +1,62 @@
+# endaq-cloud
+
+The `cloud` sub-module for `endaq-python`
+
+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.
+
+## Tools:
+
+### 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.
+
+To access the cloud, this tool requires an API key associated with a user's enDAQ Cloud account, which can be provided in two ways:
+
+* (recommended) add to the `endaq.cloud` project directory a `.env` file, formatted like so:
+
+		API_KEY=<Your Key>
+
+* pass in an API key through the command line using the `--key` option
+
+#### warning
+For security reasons, it is generally discouraged to make an authentication key visible on-screen or accessible through the clipboard, such as when using the `--key` option; we provide the `key` option solely as a convenience.
+
+#### Commands
+
+- files               Outputs file information for selected number of files and attributes
+- file-id             Outputs file information for file with specified ID to output file
+- devices             Ouputs device information for selected number of files
+- device-id           Output device information for device with specified ID to output file
+- account             Prints out account information
+- attributes          Adds an attribute to a specified file
+- set-env             Creates a .env file with passed in API key *NOT SECURE*
+
+#### Usages
+
+Flag | Description 
+---|---
+-h                  | Command Line Help
+--id, -i            | File or Device ID
+--limit, -l         | File or Device output limit; Max 100 default 50
+--key, -k           | API Key
+--attributes, -a    | Attributes to be outputted; options = all or att1,att2...; default is none
+--name, -n          | Attribute Name
+--type, -t          | Attribute Type; options = int, float, string, boolean
+--value, -v         | Attribute Value
+--verbose, -V       | Prints out URL API calls
+
+
+- endaq-cloud set-env -k <API_KEY>
+- endaq-cloud download -i <FILE_ID> -o <OUTPUT_FOLDER>
+- endaq-cloud files -a <ATTRIBUTES_TO_GET> -l <FILE_OR_DEVICE_OUTPUT_LIMIT>
+- endaq-cloud file-id -i **# additional argument needed**
+- endaq-cloud devices -l <FILE_OR_DEVICE_OUTPUT_LIMIT>
+- endaq-cloud device-id -i **# additional argument needed**
+- endaq-cloud account
+- endaq-cloud attribute -n <ATTRIBUTE_NAME> -t <ATTRIBUTE_TYPE> -v <ATTRIBUTE_VALUE> -i **# additional arguments needed**
+
+
+
+### Utilities:
+
+Convienience functions for interacting with the cloud API and the enDAQ custom cloud report generation.
diff --git a/endaq/cloud/__init__.py b/endaq/cloud/__init__.py
new file mode 100644
index 0000000..468c910
--- /dev/null
+++ b/endaq/cloud/__init__.py
@@ -0,0 +1,2 @@
+from .core import *
+from .scripting import *
diff --git a/endaq/cloud/core.py b/endaq/cloud/core.py
new file mode 100644
index 0000000..d11e8c1
--- /dev/null
+++ b/endaq/cloud/core.py
@@ -0,0 +1,331 @@
+"""
+Core enDAQ Cloud communication API
+"""
+from __future__ import annotations
+
+from typing import Optional, Union
+
+from idelib.dataset import Dataset
+import numpy as np
+import pandas as pd
+import requests
+import json
+import re
+import urllib.request
+import shutil
+
+
+__all__ = [
+    'EndaqCloud',
+    'count_tags',
+    'json_table_to_df',
+]
+
+
+ENV_PRODUCTION = "https://qvthkmtukh.execute-api.us-west-2.amazonaws.com/master"
+ENV_STAGING = "https://p377cock71.execute-api.us-west-2.amazonaws.com/staging"
+ENV_DEVELOP = "https://mnsz98xs64.execute-api.us-west-2.amazonaws.com/develop"
+
+
+class EndaqCloud:
+    """
+    A representation of a connection to an enDAQ Cloud account, providing a
+    high-level interface for accessing its contents.
+    """
+
+    def __init__(self,
+                 api_key: Optional[str] = None,
+                 env: Optional[str] = None,
+                 test: bool = True):
+        """
+        Constructor for an `EndaqCloud` object, which provides access to an
+        enDAQ Cloud account.
+
+        :param api_key: The Endaq Cloud API associated with your cloud.endaq.com account.
+         If you do not have one created yet, they can be created on the following web page:
+         https://cloud.endaq.com/account/api-keys
+        :param env: The cloud environment to connect to, which can be production, staging, or development.
+         These can be easily accessed with the variables `ENV_PRODUCTION`, `ENV_STAGING`, and `ENV_DEVELOP`
+        :param test: If `True` (default), the connection to enDAQ Cloud will
+            be tested before being returned. A failed test will generate a
+            meaningful error message describing the problem.
+        """
+        self.api_key = api_key
+        self.domain = env or ENV_PRODUCTION
+
+        self.file_table = None
+
+        self._account_id = self._account_email = None
+        if test:
+            info = self.get_account_info()
+            if not info.get('id') or not info.get('email'):
+                # TODO: change this exception; it's placeholder.
+                raise RuntimeError("Failed to connect to enDAQ Cloud: response was {!r}".format(info))
+
+
+    def get_account_info(self) -> dict:
+        """
+        Get information about the connected account. Sets or updates the
+        values of `account_id` and `account_email`.
+
+        :return: If successful, a dictionary containing (at minimum) the keys
+            `email` and `id`.
+        """
+        response = requests.get(self.domain + "/api/v1/account/info",
+                                headers={"x-api-key": self.api_key}).json()
+        # Cache the ID and email. Don't clobber if the request failed
+        # (just in case - it's unlikely).
+        self._account_id = response.get('id', self._account_id)
+        self._account_email = response.get('email', self._account_email)
+        return response
+
+
+    @property
+    def account_id(self) -> Optional[str]:
+        """ The enDAQ Cloud account's unique ID. """
+        if self._account_id is None:
+            self.get_account_info()
+        return self._account_id
+
+
+    @property
+    def account_email(self) -> Optional[str]:
+        """ The email address associated with the enDAQ Cloud account. """
+        if self._account_email is None:
+            self.get_account_info()
+        return self._account_email
+
+
+    def get_file(self,
+                 file_id: Union[int, str],
+                 local_name: Optional[str] = None) -> Dataset:
+        """
+        Download the specified file to local_name if provided, use the file
+        name from the cloud if no local name is provided.
+        
+        .. todo:: This should be made to match `endaq.ide.get_doc()`
+
+        :param file_id: The file's cloud ID.
+        :param local_name: The downloaded file's destination pathname; defaults
+            to the file's original basename & located in the directory in which
+            the Python interpreter was launched
+        :return: The imported file, as an `idelib.Dataset`.
+        """
+        file_url = self.domain + "/api/v1/files/download/" + file_id
+        response = requests.get(file_url, headers={"x-api-key": self.api_key}).json()
+        download_url = response['url']
+        if local_name is None:
+            local_name = response['file_name']
+
+        with urllib.request.urlopen(download_url) as response, open(local_name, 'wb') as out_file:
+            shutil.copyfileobj(response, out_file)
+
+        f = open(local_name, 'rb')
+
+        return Dataset(f)
+
+    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.
+
+        :param limit: The maximum number of files to get info for.  The most recent files will be gotten
+        :param attributes: Either a list of strings denoting the attributes to get, or a string with comma seperated
+         attribute names to get.  If 'all' is specified (the default) then all attributes will be gotten
+        :return: A list of the json objects
+        """
+        if not isinstance(limit, int):
+            raise TypeError(f"the `limit` parameter must be type 'int' but type '{type(limit)}' was given.")
+
+        if limit < 1:
+            raise ValueError(f"the `limit` parameter must be at least 1, but '{limit}' was given.")
+
+        if isinstance(attributes, str):
+            attributes = attributes.split(',')
+        attributes = [str(a).strip() for a in attributes]
+
+        j = 0
+        json_data = []
+        while True:
+            params = {
+                'limit': min(100, limit - j*100),
+                'attributes': attributes,
+            }
+
+            if j != 0:
+                params['next_token'] = response["nextToken"]
+
+            response = requests.get(self.domain + "/api/v1/files",
+                                    params=params,
+                                    headers={"x-api-key": self.api_key}).json()
+
+            try:
+                json_data += response['data']
+            except KeyError:
+                raise KeyError("the 'data' attribute was not present in the json response from the cloud.")
+
+            j += 1
+
+            if response["nextToken"] is None or j * 100 >= limit:
+                break
+
+        return json_data
+
+    def get_file_table(self,
+                       attributes: Union[list, str] = "all",
+                       limit: int = 100) -> pd.DataFrame:
+        """
+        Get a table of the data that would be similar to that you'd get doing
+        the CSV export on the my recordings page, up to the first `limit`
+        files with attributes matching `attributes`.
+
+        :param limit: The maximum number of files to return.
+        :param attributes: A list of attribute strings (or a single
+            comma-delimited string of attributes) to match.
+        :return: A `DataFrame` of file IDs and relevant information.
+        """
+        json_data = self._get_files_json_response(limit=limit, attributes=attributes)
+
+        self.file_table = json_table_to_df(json_data)
+
+        return self.file_table
+
+    def get_devices(self, limit: int = 100) -> pd.DataFrame:
+        """
+        Get dataframe of devices and associated attributes (part_number,
+        description, etc.) attached to the account.
+
+        :param limit: The maximum number of files to return.
+        :return: A `DataFrame` of recorder information.
+        """
+        json_data = self._get_files_json_response(limit=limit, attributes=[])
+
+        devices = {}
+        for f_data in json_data:
+            if f_data['device'] is not None and len(f_data['device']) > 0:
+                if f_data['device']['serial_number_id'] not in devices:
+                    devices[f_data['device']['serial_number_id']] = f_data['device']
+
+        df = pd.DataFrame(devices).T
+
+        if len(df.columns):
+            df.set_index('serial_number_id')
+
+        return df
+
+    def set_attributes(self,
+                       file_id: Union[int, str],
+                       attributes: list) -> list:
+        """
+        Set the 'attributes' (name/value metadata) of a file.
+
+        :param file_id: The file's cloud ID.
+        :param attributes: A list of dictionaries of the following structure:
+
+        .. code-block:: python
+
+            [{
+                "name": "attr_31",
+                "type" : "float",
+                "value" : 3.3,
+            }]
+
+        :return: The list of the file's new attributes.
+
+        """
+        # NOTE: This was called `post_attributes()` in the Confluence docs.
+        #  'post' referred to the fact it is a POST request, which is
+        #  really an internal detail; 'set' is more appropriate for an API.
+
+        # IDEAS:
+        #   * Use `**kwargs` instead of an `attributes` dict?
+        #   * Automatically assume type, unless value is a tuple containing (value, type)
+        for attrib in attributes:
+            attrib['file_id'] = file_id
+
+        response = requests.post(
+            self.domain + "/api/v1/attributes",
+            headers={"x-api-key": self.api_key},
+            json={'attributes': attributes},
+        )
+
+        return response.json()
+
+
+
+
+def count_tags(df: pd.DataFrame) -> pd.DataFrame:
+    """
+    Given the dataframe returned by ``EndaqCloud.get_file_table()``, provide
+    some info on the tags of the files in that account.
+
+    :param df: A `DataFrame` of file information, as returned by
+        ``EndaqCloud.get_file_table()``.
+    :return: A `DataFrame` summarizing the tags in `df`.
+    """
+    # NOTE: Called `tags_count()` in Confluence docs. Function names should
+    #   generally be verbs or verb phrases.
+    # IDEAS:
+    #   * Make this a @classmethod to make EndaqCloud the primary means of access?
+
+    tags = {}
+    for index, row in df.iterrows():
+        for cur_tag in row['tags']:
+            if cur_tag in tags:
+                tags[cur_tag].append(index)
+            else:
+                tags[cur_tag] = [index]
+
+    for tag in tags:
+        tags[tag] = [len(tags[tag]), ''.join(["'", "','".join(tags[tag]), "'"])]
+
+    return pd.DataFrame(tags, index=pd.Index(['count', 'files'], name='tag')).T
+
+
+
+def json_table_to_df(data: list) -> pd.DataFrame:
+    """
+    Convert JSON parsed from a custom report to a more user-friendly
+    `pandas.DataFrame`.
+
+    :param data: A `list` of data from a custom report's JSON.
+    :return: A formatted `DataFrame`
+    """
+    # NOTE: Steve wanted this as a separate function.
+    #  Also: is this already implemented as `endaq.cloud.utilities.convert_file_data_to_dataframe()`?
+    # IDEAS:
+    #   * Make this a @classmethod to make EndaqCloud class and/or instances the primary means of access?
+    df = pd.DataFrame(data)
+    df['attributes'] = df['attributes'].map(lambda x: {attribs['name']: attribs for attribs in x})
+
+    unique_attributes_and_types = df['attributes'].map(lambda x: [(k, v['type']) for k, v in x.items()]).values
+    unique_attributes_and_types = set(pair for file_info in unique_attributes_and_types for pair in file_info)
+
+    for attrib_name, attrib_type_str in unique_attributes_and_types:
+        if attrib_type_str == 'float':
+            df[attrib_name] = df['attributes'].map(
+                lambda x: None if len(x) == 0 or attrib_name not in x else float(x[attrib_name]['value']))
+        elif attrib_type_str == 'string':
+            try:  # Try and parse the JSON string into an array of floats
+                df[attrib_name] = df['attributes'].map(
+                    lambda x: [] if len(x) == 0 or attrib_name not in x else np.array(
+                        json.loads(re.sub(r'\bnan\b', 'NaN', x[attrib_name]['value'])), dtype=np.float32))
+            except json.JSONDecodeError:  # Save it as a String if it can't be converted to a float array
+                df[attrib_name] = df['attributes'].map(
+                    lambda x: "" if len(x) == 0 or attrib_name not in x else x[attrib_name]['value'])
+
+    # Convert the columns which represent times to pandas datetime type
+    for time_col_name in ['recording_ts', 'created_ts', 'modified_ts', 'archived_ts']:
+        if time_col_name in df:
+            df[time_col_name] = pd.to_datetime(df[time_col_name], unit='s')
+
+    if 'gpsLocationFull' in df:
+        # Add the GPS coordinates as 2 seperate latitude and longitude columns
+        gps_coord_series = df['gpsLocationFull'].map(
+            lambda x: np.array(x.split(','), dtype=np.float32) if len(x) else np.array(2 * [np.nan],
+                                                                                       dtype=np.float32))
+        df['latitudes'] = gps_coord_series.map(lambda x: x[0])
+        df['longitudes'] = gps_coord_series.map(lambda x: x[1])
+
+    df = df.set_index('file_name')
+
+    return df
diff --git a/endaq/cloud/scripting.py b/endaq/cloud/scripting.py
new file mode 100644
index 0000000..353ad56
--- /dev/null
+++ b/endaq/cloud/scripting.py
@@ -0,0 +1,81 @@
+import requests
+from types import FunctionType
+import plotly.io as pio
+import json
+
+from .core import ENV_PRODUCTION, ENV_STAGING, ENV_DEVELOP
+
+
+__all__ = [
+    'create_cloud_dashboard_output',
+    'produce_dashboard_plots',
+]
+
+
+def create_cloud_dashboard_output(name_to_fig: dict) -> str:
+    """
+    A function which makes producing the json based string used to produce custom enDAQ Cloud report
+    dashboards easy.
+
+    :param name_to_fig: A dictionary mapping the desired names/titles of plots to a Plotly figure.
+     The dictionary must have 4 elements, and the ordering DOES matter as it dictates the order
+     the plots will be added to the dashboard.  This means the ordering of Python dictionaries which
+     appears in Python 3.7 is crucial for using this!
+    :return: The json based string which is to be given as the variable 'output' in enDAQ cloud
+     custom dashboards
+    """
+    if not isinstance(name_to_fig, dict):
+        raise TypeError(f"'name_to_fig' parameter must be a dictionary, but was given {type(name_to_fig)} instead")
+
+    if len(name_to_fig) != 4:
+        raise ValueError("The number of (key, value) pairs in 'name_to_fig' must be exactly 4, "
+                         f"but {len(name_to_fig)} are given")
+
+    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:
+    """
+    A function used to simulate a run of a desired enDAQ Cloud custom report script without needing to use
+    cloud.endaq.com
+
+    :param dashboard_script_fn: A function accepting the parameters `files` and `file_download_url`, which has all
+     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 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/'
+    elif environment == 'staging':
+        api_access_url = ENV_STAGING + '/api/v1/'
+    elif environment == 'develop':
+        api_access_url = ENV_DEVELOP + '/api/v1/'
+    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']
+
+    most_recent_file_id = files[-1]['id']
+
+    file_download_url = requests.get(
+        api_access_url + "files/download/" + most_recent_file_id,
+        headers=parameters
+    ).json()['url']
+
+    output = dashboard_script_fn(files=files, file_download_url=file_download_url)
+
+    figures = [pio.from_json(json.dumps(blob)) for blob in json.loads(output)]
+
+    if display_plots:
+        for fig in figures:
+            fig.show()
+
+    return figures
diff --git a/endaq/ide/README.md b/endaq/ide/README.md
new file mode 100644
index 0000000..2b46c16
--- /dev/null
+++ b/endaq/ide/README.md
@@ -0,0 +1,656 @@
+# endaq-ide
+
+`endaq.ide` contains high-level utility functions to aid in importing and inspecting enDAQ IDE recording files. Key functions/features include:
+
+* `get_channel_table()`: Get summary information about the contents of a file.
+* `get_doc()`: Open an IDE, either locally or from a URL.
+* `to_pandas()`: Convert IDE data to a `pandas` `DataFrame`, for use with other packages.
+
+## `endaq.ide` usage examples
+Here are a few examples of `endaq.ide` in use. For more information, see the [full documentation](https://docs.endaq.com/en/latest/).
+
+For brevity, the following examples assume everything has been imported from `endaq.ide`:
+```python
+from endaq.ide import *
+```
+
+### Opening IDE files: `endaq.ide.get_doc()`
+`endaq.ide` includes a convenient shortcut for importing IDE data: `get_doc()`. It can load data from local files, or read data directly from a URL.
+
+
+```python
+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."
+
+
+```python
+doc2 = get_doc("https://drive.google.com/file/d/1t3JqbZGhuZbIK9agH24YZIdVE26-NOF5/view?usp=sharing")
+```
+
+Whether opening a local file or a URL, `get_doc()` can be used to import only a specific interval by way of its `start` and `end` parameters:
+
+
+```python
+doc3 = get_doc("tests/test.ide", start="5s", end="10s")
+```
+
+### Summarizing IDE files: `endaq.ide.get_channel_table()`
+Once an IDE file has been loaded, `endaq.ide.get_channel_table()` will retrieve basic summary information about its contents.
+Some environments, such as Jupyter Notebook or Colab, will automatically display the channel table data. From inside the
+Python Console, use `get_channel_table(doc).data` to display the information, or to access the table's contents directly as a pandas `DataFrame`.
+
+
+```python
+get_channel_table(doc)
+```
+
+<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 measurement type:
+
+
+```python
+get_channel_table(doc, ACCELERATION)
+```
+
+
+
+<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:
+
+
+```python
+get_channel_table(doc, ACCELERATION+TEMPERATURE)
+```
+
+
+
+<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`.
+
+
+```python
+get_channel_table(doc, ACCELERATION+TEMPERATURE, start="0:05", end="0:10")
+```
+
+
+
+
+<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: `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).
+
+
+```python
+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 `endaq.ide`:
+
+
+```python
+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 `endaq.ide.get_doc()`, as previously described.
diff --git a/endaq/ide/__init__.py b/endaq/ide/__init__.py
new file mode 100644
index 0000000..ea6ecd4
--- /dev/null
+++ b/endaq/ide/__init__.py
@@ -0,0 +1,3 @@
+from .files import *
+from .info import *
+from .measurement import *
diff --git a/endaq/ide/endaq.ide examples.ipynb b/endaq/ide/endaq.ide examples.ipynb
new file mode 100644
index 0000000..8778306
--- /dev/null
+++ b/endaq/ide/endaq.ide examples.ipynb
@@ -0,0 +1,836 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "5d34f981",
+   "metadata": {},
+   "source": [
+    "# `endaq.ide` usage examples\n",
+    "For brevity, the following examples assume everything has been imported from `endaq.ide`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "2e1c7e9a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from endaq.ide import *"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ad299aa5",
+   "metadata": {},
+   "source": [
+    "# Opening IDE files: `endaq.ide.get_doc()`\n",
+    "`endaq.ide` includes a convenient shortcut for importing IDE data: `get_doc()`. It can load data from local files, or read data directly from a URL."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "e56ed446",
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [],
+   "source": [
+    "doc = get_doc(\"tests/test.ide\")\n",
+    "doc1 = get_doc(\"https://info.endaq.com/hubfs/data/surgical-instrument.ide\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3bc35e49",
+   "metadata": {},
+   "source": [
+    "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.\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "dd93af23",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "doc2 = get_doc(\"https://drive.google.com/file/d/1t3JqbZGhuZbIK9agH24YZIdVE26-NOF5/view?usp=sharing\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "56d44599",
+   "metadata": {},
+   "source": [
+    "Whether opening a local file or a URL, `get_doc()` can be used to import only a specific interval by way of its `start` and `end` parameters:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "4f5180c3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "doc3 = get_doc(\"tests/test.ide\", start=\"5s\", end=\"10s\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "db4f550e",
+   "metadata": {},
+   "source": [
+    "# Summarizing IDE files: `endaq.ide.get_channel_table()`\n",
+    "Once an IDE file has been loaded, `endaq.ide.get_channel_table()` will retrieve basic summary information about its contents."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "9ff1c8f0",
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<style type=\"text/css\">\n",
+       "</style>\n",
+       "<table id=\"T_d2cbb_\">\n",
+       "  <thead>\n",
+       "    <tr>\n",
+       "      <th class=\"blank level0\" >&nbsp;</th>\n",
+       "      <th class=\"col_heading level0 col0\" >channel</th>\n",
+       "      <th class=\"col_heading level0 col1\" >name</th>\n",
+       "      <th class=\"col_heading level0 col2\" >type</th>\n",
+       "      <th class=\"col_heading level0 col3\" >units</th>\n",
+       "      <th class=\"col_heading level0 col4\" >start</th>\n",
+       "      <th class=\"col_heading level0 col5\" >end</th>\n",
+       "      <th class=\"col_heading level0 col6\" >duration</th>\n",
+       "      <th class=\"col_heading level0 col7\" >samples</th>\n",
+       "      <th class=\"col_heading level0 col8\" >rate</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row0\" class=\"row_heading level0 row0\" >0</th>\n",
+       "      <td id=\"T_d2cbb_row0_col0\" class=\"data row0 col0\" >32.0</td>\n",
+       "      <td id=\"T_d2cbb_row0_col1\" class=\"data row0 col1\" >X (16g)</td>\n",
+       "      <td id=\"T_d2cbb_row0_col2\" class=\"data row0 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_d2cbb_row0_col3\" class=\"data row0 col3\" >g</td>\n",
+       "      <td id=\"T_d2cbb_row0_col4\" class=\"data row0 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_d2cbb_row0_col5\" class=\"data row0 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_d2cbb_row0_col6\" class=\"data row0 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_d2cbb_row0_col7\" class=\"data row0 col7\" >7113</td>\n",
+       "      <td id=\"T_d2cbb_row0_col8\" class=\"data row0 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row1\" class=\"row_heading level0 row1\" >1</th>\n",
+       "      <td id=\"T_d2cbb_row1_col0\" class=\"data row1 col0\" >32.1</td>\n",
+       "      <td id=\"T_d2cbb_row1_col1\" class=\"data row1 col1\" >Y (16g)</td>\n",
+       "      <td id=\"T_d2cbb_row1_col2\" class=\"data row1 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_d2cbb_row1_col3\" class=\"data row1 col3\" >g</td>\n",
+       "      <td id=\"T_d2cbb_row1_col4\" class=\"data row1 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_d2cbb_row1_col5\" class=\"data row1 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_d2cbb_row1_col6\" class=\"data row1 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_d2cbb_row1_col7\" class=\"data row1 col7\" >7113</td>\n",
+       "      <td id=\"T_d2cbb_row1_col8\" class=\"data row1 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row2\" class=\"row_heading level0 row2\" >2</th>\n",
+       "      <td id=\"T_d2cbb_row2_col0\" class=\"data row2 col0\" >32.2</td>\n",
+       "      <td id=\"T_d2cbb_row2_col1\" class=\"data row2 col1\" >Z (16g)</td>\n",
+       "      <td id=\"T_d2cbb_row2_col2\" class=\"data row2 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_d2cbb_row2_col3\" class=\"data row2 col3\" >g</td>\n",
+       "      <td id=\"T_d2cbb_row2_col4\" class=\"data row2 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_d2cbb_row2_col5\" class=\"data row2 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_d2cbb_row2_col6\" class=\"data row2 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_d2cbb_row2_col7\" class=\"data row2 col7\" >7113</td>\n",
+       "      <td id=\"T_d2cbb_row2_col8\" class=\"data row2 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row3\" class=\"row_heading level0 row3\" >3</th>\n",
+       "      <td id=\"T_d2cbb_row3_col0\" class=\"data row3 col0\" >80.0</td>\n",
+       "      <td id=\"T_d2cbb_row3_col1\" class=\"data row3 col1\" >X (8g)</td>\n",
+       "      <td id=\"T_d2cbb_row3_col2\" class=\"data row3 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_d2cbb_row3_col3\" class=\"data row3 col3\" >g</td>\n",
+       "      <td id=\"T_d2cbb_row3_col4\" class=\"data row3 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_d2cbb_row3_col5\" class=\"data row3 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_d2cbb_row3_col6\" class=\"data row3 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_d2cbb_row3_col7\" class=\"data row3 col7\" >9070</td>\n",
+       "      <td id=\"T_d2cbb_row3_col8\" class=\"data row3 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row4\" class=\"row_heading level0 row4\" >4</th>\n",
+       "      <td id=\"T_d2cbb_row4_col0\" class=\"data row4 col0\" >80.1</td>\n",
+       "      <td id=\"T_d2cbb_row4_col1\" class=\"data row4 col1\" >Y (8g)</td>\n",
+       "      <td id=\"T_d2cbb_row4_col2\" class=\"data row4 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_d2cbb_row4_col3\" class=\"data row4 col3\" >g</td>\n",
+       "      <td id=\"T_d2cbb_row4_col4\" class=\"data row4 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_d2cbb_row4_col5\" class=\"data row4 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_d2cbb_row4_col6\" class=\"data row4 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_d2cbb_row4_col7\" class=\"data row4 col7\" >9070</td>\n",
+       "      <td id=\"T_d2cbb_row4_col8\" class=\"data row4 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row5\" class=\"row_heading level0 row5\" >5</th>\n",
+       "      <td id=\"T_d2cbb_row5_col0\" class=\"data row5 col0\" >80.2</td>\n",
+       "      <td id=\"T_d2cbb_row5_col1\" class=\"data row5 col1\" >Z (8g)</td>\n",
+       "      <td id=\"T_d2cbb_row5_col2\" class=\"data row5 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_d2cbb_row5_col3\" class=\"data row5 col3\" >g</td>\n",
+       "      <td id=\"T_d2cbb_row5_col4\" class=\"data row5 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_d2cbb_row5_col5\" class=\"data row5 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_d2cbb_row5_col6\" class=\"data row5 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_d2cbb_row5_col7\" class=\"data row5 col7\" >9070</td>\n",
+       "      <td id=\"T_d2cbb_row5_col8\" class=\"data row5 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row6\" class=\"row_heading level0 row6\" >6</th>\n",
+       "      <td id=\"T_d2cbb_row6_col0\" class=\"data row6 col0\" >36.0</td>\n",
+       "      <td id=\"T_d2cbb_row6_col1\" class=\"data row6 col1\" >Pressure/Temperature:00</td>\n",
+       "      <td id=\"T_d2cbb_row6_col2\" class=\"data row6 col2\" >Pressure</td>\n",
+       "      <td id=\"T_d2cbb_row6_col3\" class=\"data row6 col3\" >Pa</td>\n",
+       "      <td id=\"T_d2cbb_row6_col4\" class=\"data row6 col4\" >00:00.0945</td>\n",
+       "      <td id=\"T_d2cbb_row6_col5\" class=\"data row6 col5\" >00:19.0175</td>\n",
+       "      <td id=\"T_d2cbb_row6_col6\" class=\"data row6 col6\" >00:18.0230</td>\n",
+       "      <td id=\"T_d2cbb_row6_col7\" class=\"data row6 col7\" >20</td>\n",
+       "      <td id=\"T_d2cbb_row6_col8\" class=\"data row6 col8\" >1.10 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row7\" class=\"row_heading level0 row7\" >7</th>\n",
+       "      <td id=\"T_d2cbb_row7_col0\" class=\"data row7 col0\" >36.1</td>\n",
+       "      <td id=\"T_d2cbb_row7_col1\" class=\"data row7 col1\" >Pressure/Temperature:01</td>\n",
+       "      <td id=\"T_d2cbb_row7_col2\" class=\"data row7 col2\" >Temperature</td>\n",
+       "      <td id=\"T_d2cbb_row7_col3\" class=\"data row7 col3\" >°C</td>\n",
+       "      <td id=\"T_d2cbb_row7_col4\" class=\"data row7 col4\" >00:00.0945</td>\n",
+       "      <td id=\"T_d2cbb_row7_col5\" class=\"data row7 col5\" >00:19.0175</td>\n",
+       "      <td id=\"T_d2cbb_row7_col6\" class=\"data row7 col6\" >00:18.0230</td>\n",
+       "      <td id=\"T_d2cbb_row7_col7\" class=\"data row7 col7\" >20</td>\n",
+       "      <td id=\"T_d2cbb_row7_col8\" class=\"data row7 col8\" >1.10 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row8\" class=\"row_heading level0 row8\" >8</th>\n",
+       "      <td id=\"T_d2cbb_row8_col0\" class=\"data row8 col0\" >70.0</td>\n",
+       "      <td id=\"T_d2cbb_row8_col1\" class=\"data row8 col1\" >X</td>\n",
+       "      <td id=\"T_d2cbb_row8_col2\" class=\"data row8 col2\" >Quaternion</td>\n",
+       "      <td id=\"T_d2cbb_row8_col3\" class=\"data row8 col3\" >q</td>\n",
+       "      <td id=\"T_d2cbb_row8_col4\" class=\"data row8 col4\" >00:01.0132</td>\n",
+       "      <td id=\"T_d2cbb_row8_col5\" class=\"data row8 col5\" >00:18.0954</td>\n",
+       "      <td id=\"T_d2cbb_row8_col6\" class=\"data row8 col6\" >00:17.0821</td>\n",
+       "      <td id=\"T_d2cbb_row8_col7\" class=\"data row8 col7\" >1755</td>\n",
+       "      <td id=\"T_d2cbb_row8_col8\" class=\"data row8 col8\" >98.47 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row9\" class=\"row_heading level0 row9\" >9</th>\n",
+       "      <td id=\"T_d2cbb_row9_col0\" class=\"data row9 col0\" >70.1</td>\n",
+       "      <td id=\"T_d2cbb_row9_col1\" class=\"data row9 col1\" >Y</td>\n",
+       "      <td id=\"T_d2cbb_row9_col2\" class=\"data row9 col2\" >Quaternion</td>\n",
+       "      <td id=\"T_d2cbb_row9_col3\" class=\"data row9 col3\" >q</td>\n",
+       "      <td id=\"T_d2cbb_row9_col4\" class=\"data row9 col4\" >00:01.0132</td>\n",
+       "      <td id=\"T_d2cbb_row9_col5\" class=\"data row9 col5\" >00:18.0954</td>\n",
+       "      <td id=\"T_d2cbb_row9_col6\" class=\"data row9 col6\" >00:17.0821</td>\n",
+       "      <td id=\"T_d2cbb_row9_col7\" class=\"data row9 col7\" >1755</td>\n",
+       "      <td id=\"T_d2cbb_row9_col8\" class=\"data row9 col8\" >98.47 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row10\" class=\"row_heading level0 row10\" >10</th>\n",
+       "      <td id=\"T_d2cbb_row10_col0\" class=\"data row10 col0\" >70.2</td>\n",
+       "      <td id=\"T_d2cbb_row10_col1\" class=\"data row10 col1\" >Z</td>\n",
+       "      <td id=\"T_d2cbb_row10_col2\" class=\"data row10 col2\" >Quaternion</td>\n",
+       "      <td id=\"T_d2cbb_row10_col3\" class=\"data row10 col3\" >q</td>\n",
+       "      <td id=\"T_d2cbb_row10_col4\" class=\"data row10 col4\" >00:01.0132</td>\n",
+       "      <td id=\"T_d2cbb_row10_col5\" class=\"data row10 col5\" >00:18.0954</td>\n",
+       "      <td id=\"T_d2cbb_row10_col6\" class=\"data row10 col6\" >00:17.0821</td>\n",
+       "      <td id=\"T_d2cbb_row10_col7\" class=\"data row10 col7\" >1755</td>\n",
+       "      <td id=\"T_d2cbb_row10_col8\" class=\"data row10 col8\" >98.47 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row11\" class=\"row_heading level0 row11\" >11</th>\n",
+       "      <td id=\"T_d2cbb_row11_col0\" class=\"data row11 col0\" >70.3</td>\n",
+       "      <td id=\"T_d2cbb_row11_col1\" class=\"data row11 col1\" >W</td>\n",
+       "      <td id=\"T_d2cbb_row11_col2\" class=\"data row11 col2\" >Quaternion</td>\n",
+       "      <td id=\"T_d2cbb_row11_col3\" class=\"data row11 col3\" >q</td>\n",
+       "      <td id=\"T_d2cbb_row11_col4\" class=\"data row11 col4\" >00:01.0132</td>\n",
+       "      <td id=\"T_d2cbb_row11_col5\" class=\"data row11 col5\" >00:18.0954</td>\n",
+       "      <td id=\"T_d2cbb_row11_col6\" class=\"data row11 col6\" >00:17.0821</td>\n",
+       "      <td id=\"T_d2cbb_row11_col7\" class=\"data row11 col7\" >1755</td>\n",
+       "      <td id=\"T_d2cbb_row11_col8\" class=\"data row11 col8\" >98.47 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row12\" class=\"row_heading level0 row12\" >12</th>\n",
+       "      <td id=\"T_d2cbb_row12_col0\" class=\"data row12 col0\" >59.0</td>\n",
+       "      <td id=\"T_d2cbb_row12_col1\" class=\"data row12 col1\" >Control Pad Pressure</td>\n",
+       "      <td id=\"T_d2cbb_row12_col2\" class=\"data row12 col2\" >Pressure</td>\n",
+       "      <td id=\"T_d2cbb_row12_col3\" class=\"data row12 col3\" >Pa</td>\n",
+       "      <td id=\"T_d2cbb_row12_col4\" class=\"data row12 col4\" >00:00.0979</td>\n",
+       "      <td id=\"T_d2cbb_row12_col5\" class=\"data row12 col5\" >00:18.0910</td>\n",
+       "      <td id=\"T_d2cbb_row12_col6\" class=\"data row12 col6\" >00:17.0931</td>\n",
+       "      <td id=\"T_d2cbb_row12_col7\" class=\"data row12 col7\" >180</td>\n",
+       "      <td id=\"T_d2cbb_row12_col8\" class=\"data row12 col8\" >10.04 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row13\" class=\"row_heading level0 row13\" >13</th>\n",
+       "      <td id=\"T_d2cbb_row13_col0\" class=\"data row13 col0\" >59.1</td>\n",
+       "      <td id=\"T_d2cbb_row13_col1\" class=\"data row13 col1\" >Control Pad Temperature</td>\n",
+       "      <td id=\"T_d2cbb_row13_col2\" class=\"data row13 col2\" >Temperature</td>\n",
+       "      <td id=\"T_d2cbb_row13_col3\" class=\"data row13 col3\" >°C</td>\n",
+       "      <td id=\"T_d2cbb_row13_col4\" class=\"data row13 col4\" >00:00.0979</td>\n",
+       "      <td id=\"T_d2cbb_row13_col5\" class=\"data row13 col5\" >00:18.0910</td>\n",
+       "      <td id=\"T_d2cbb_row13_col6\" class=\"data row13 col6\" >00:17.0931</td>\n",
+       "      <td id=\"T_d2cbb_row13_col7\" class=\"data row13 col7\" >180</td>\n",
+       "      <td id=\"T_d2cbb_row13_col8\" class=\"data row13 col8\" >10.04 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row14\" class=\"row_heading level0 row14\" >14</th>\n",
+       "      <td id=\"T_d2cbb_row14_col0\" class=\"data row14 col0\" >76.0</td>\n",
+       "      <td id=\"T_d2cbb_row14_col1\" class=\"data row14 col1\" >Lux</td>\n",
+       "      <td id=\"T_d2cbb_row14_col2\" class=\"data row14 col2\" >Light</td>\n",
+       "      <td id=\"T_d2cbb_row14_col3\" class=\"data row14 col3\" >Ill</td>\n",
+       "      <td id=\"T_d2cbb_row14_col4\" class=\"data row14 col4\" >00:00.0000</td>\n",
+       "      <td id=\"T_d2cbb_row14_col5\" class=\"data row14 col5\" >00:18.0737</td>\n",
+       "      <td id=\"T_d2cbb_row14_col6\" class=\"data row14 col6\" >00:18.0737</td>\n",
+       "      <td id=\"T_d2cbb_row14_col7\" class=\"data row14 col7\" >71</td>\n",
+       "      <td id=\"T_d2cbb_row14_col8\" class=\"data row14 col8\" >3.79 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_d2cbb_level0_row15\" class=\"row_heading level0 row15\" >15</th>\n",
+       "      <td id=\"T_d2cbb_row15_col0\" class=\"data row15 col0\" >76.1</td>\n",
+       "      <td id=\"T_d2cbb_row15_col1\" class=\"data row15 col1\" >UV</td>\n",
+       "      <td id=\"T_d2cbb_row15_col2\" class=\"data row15 col2\" >Light</td>\n",
+       "      <td id=\"T_d2cbb_row15_col3\" class=\"data row15 col3\" >Index</td>\n",
+       "      <td id=\"T_d2cbb_row15_col4\" class=\"data row15 col4\" >00:00.0000</td>\n",
+       "      <td id=\"T_d2cbb_row15_col5\" class=\"data row15 col5\" >00:18.0737</td>\n",
+       "      <td id=\"T_d2cbb_row15_col6\" class=\"data row15 col6\" >00:18.0737</td>\n",
+       "      <td id=\"T_d2cbb_row15_col7\" class=\"data row15 col7\" >71</td>\n",
+       "      <td id=\"T_d2cbb_row15_col8\" class=\"data row15 col8\" >3.79 Hz</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n"
+      ],
+      "text/plain": [
+       "<pandas.io.formats.style.Styler at 0x264946edf70>"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "get_channel_table(doc)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "0de5f8fe",
+   "metadata": {},
+   "source": [
+    "The results can be filtered by measurement type:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "b82ac038",
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<style type=\"text/css\">\n",
+       "</style>\n",
+       "<table id=\"T_9f9cf_\">\n",
+       "  <thead>\n",
+       "    <tr>\n",
+       "      <th class=\"blank level0\" >&nbsp;</th>\n",
+       "      <th class=\"col_heading level0 col0\" >channel</th>\n",
+       "      <th class=\"col_heading level0 col1\" >name</th>\n",
+       "      <th class=\"col_heading level0 col2\" >type</th>\n",
+       "      <th class=\"col_heading level0 col3\" >units</th>\n",
+       "      <th class=\"col_heading level0 col4\" >start</th>\n",
+       "      <th class=\"col_heading level0 col5\" >end</th>\n",
+       "      <th class=\"col_heading level0 col6\" >duration</th>\n",
+       "      <th class=\"col_heading level0 col7\" >samples</th>\n",
+       "      <th class=\"col_heading level0 col8\" >rate</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th id=\"T_9f9cf_level0_row0\" class=\"row_heading level0 row0\" >0</th>\n",
+       "      <td id=\"T_9f9cf_row0_col0\" class=\"data row0 col0\" >32.0</td>\n",
+       "      <td id=\"T_9f9cf_row0_col1\" class=\"data row0 col1\" >X (16g)</td>\n",
+       "      <td id=\"T_9f9cf_row0_col2\" class=\"data row0 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_9f9cf_row0_col3\" class=\"data row0 col3\" >g</td>\n",
+       "      <td id=\"T_9f9cf_row0_col4\" class=\"data row0 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_9f9cf_row0_col5\" class=\"data row0 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_9f9cf_row0_col6\" class=\"data row0 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_9f9cf_row0_col7\" class=\"data row0 col7\" >7113</td>\n",
+       "      <td id=\"T_9f9cf_row0_col8\" class=\"data row0 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_9f9cf_level0_row1\" class=\"row_heading level0 row1\" >1</th>\n",
+       "      <td id=\"T_9f9cf_row1_col0\" class=\"data row1 col0\" >32.1</td>\n",
+       "      <td id=\"T_9f9cf_row1_col1\" class=\"data row1 col1\" >Y (16g)</td>\n",
+       "      <td id=\"T_9f9cf_row1_col2\" class=\"data row1 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_9f9cf_row1_col3\" class=\"data row1 col3\" >g</td>\n",
+       "      <td id=\"T_9f9cf_row1_col4\" class=\"data row1 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_9f9cf_row1_col5\" class=\"data row1 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_9f9cf_row1_col6\" class=\"data row1 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_9f9cf_row1_col7\" class=\"data row1 col7\" >7113</td>\n",
+       "      <td id=\"T_9f9cf_row1_col8\" class=\"data row1 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_9f9cf_level0_row2\" class=\"row_heading level0 row2\" >2</th>\n",
+       "      <td id=\"T_9f9cf_row2_col0\" class=\"data row2 col0\" >32.2</td>\n",
+       "      <td id=\"T_9f9cf_row2_col1\" class=\"data row2 col1\" >Z (16g)</td>\n",
+       "      <td id=\"T_9f9cf_row2_col2\" class=\"data row2 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_9f9cf_row2_col3\" class=\"data row2 col3\" >g</td>\n",
+       "      <td id=\"T_9f9cf_row2_col4\" class=\"data row2 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_9f9cf_row2_col5\" class=\"data row2 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_9f9cf_row2_col6\" class=\"data row2 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_9f9cf_row2_col7\" class=\"data row2 col7\" >7113</td>\n",
+       "      <td id=\"T_9f9cf_row2_col8\" class=\"data row2 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_9f9cf_level0_row3\" class=\"row_heading level0 row3\" >3</th>\n",
+       "      <td id=\"T_9f9cf_row3_col0\" class=\"data row3 col0\" >80.0</td>\n",
+       "      <td id=\"T_9f9cf_row3_col1\" class=\"data row3 col1\" >X (8g)</td>\n",
+       "      <td id=\"T_9f9cf_row3_col2\" class=\"data row3 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_9f9cf_row3_col3\" class=\"data row3 col3\" >g</td>\n",
+       "      <td id=\"T_9f9cf_row3_col4\" class=\"data row3 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_9f9cf_row3_col5\" class=\"data row3 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_9f9cf_row3_col6\" class=\"data row3 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_9f9cf_row3_col7\" class=\"data row3 col7\" >9070</td>\n",
+       "      <td id=\"T_9f9cf_row3_col8\" class=\"data row3 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_9f9cf_level0_row4\" class=\"row_heading level0 row4\" >4</th>\n",
+       "      <td id=\"T_9f9cf_row4_col0\" class=\"data row4 col0\" >80.1</td>\n",
+       "      <td id=\"T_9f9cf_row4_col1\" class=\"data row4 col1\" >Y (8g)</td>\n",
+       "      <td id=\"T_9f9cf_row4_col2\" class=\"data row4 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_9f9cf_row4_col3\" class=\"data row4 col3\" >g</td>\n",
+       "      <td id=\"T_9f9cf_row4_col4\" class=\"data row4 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_9f9cf_row4_col5\" class=\"data row4 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_9f9cf_row4_col6\" class=\"data row4 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_9f9cf_row4_col7\" class=\"data row4 col7\" >9070</td>\n",
+       "      <td id=\"T_9f9cf_row4_col8\" class=\"data row4 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_9f9cf_level0_row5\" class=\"row_heading level0 row5\" >5</th>\n",
+       "      <td id=\"T_9f9cf_row5_col0\" class=\"data row5 col0\" >80.2</td>\n",
+       "      <td id=\"T_9f9cf_row5_col1\" class=\"data row5 col1\" >Z (8g)</td>\n",
+       "      <td id=\"T_9f9cf_row5_col2\" class=\"data row5 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_9f9cf_row5_col3\" class=\"data row5 col3\" >g</td>\n",
+       "      <td id=\"T_9f9cf_row5_col4\" class=\"data row5 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_9f9cf_row5_col5\" class=\"data row5 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_9f9cf_row5_col6\" class=\"data row5 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_9f9cf_row5_col7\" class=\"data row5 col7\" >9070</td>\n",
+       "      <td id=\"T_9f9cf_row5_col8\" class=\"data row5 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n"
+      ],
+      "text/plain": [
+       "<pandas.io.formats.style.Styler at 0x264955a5d90>"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "get_channel_table(doc, ACCELERATION)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ccb7f94d",
+   "metadata": {},
+   "source": [
+    "Measurement types can be combined to retrieve more than one:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "65b0e5d1",
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<style type=\"text/css\">\n",
+       "</style>\n",
+       "<table id=\"T_68598_\">\n",
+       "  <thead>\n",
+       "    <tr>\n",
+       "      <th class=\"blank level0\" >&nbsp;</th>\n",
+       "      <th class=\"col_heading level0 col0\" >channel</th>\n",
+       "      <th class=\"col_heading level0 col1\" >name</th>\n",
+       "      <th class=\"col_heading level0 col2\" >type</th>\n",
+       "      <th class=\"col_heading level0 col3\" >units</th>\n",
+       "      <th class=\"col_heading level0 col4\" >start</th>\n",
+       "      <th class=\"col_heading level0 col5\" >end</th>\n",
+       "      <th class=\"col_heading level0 col6\" >duration</th>\n",
+       "      <th class=\"col_heading level0 col7\" >samples</th>\n",
+       "      <th class=\"col_heading level0 col8\" >rate</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row0\" class=\"row_heading level0 row0\" >0</th>\n",
+       "      <td id=\"T_68598_row0_col0\" class=\"data row0 col0\" >32.0</td>\n",
+       "      <td id=\"T_68598_row0_col1\" class=\"data row0 col1\" >X (16g)</td>\n",
+       "      <td id=\"T_68598_row0_col2\" class=\"data row0 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_68598_row0_col3\" class=\"data row0 col3\" >g</td>\n",
+       "      <td id=\"T_68598_row0_col4\" class=\"data row0 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_68598_row0_col5\" class=\"data row0 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_68598_row0_col6\" class=\"data row0 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_68598_row0_col7\" class=\"data row0 col7\" >7113</td>\n",
+       "      <td id=\"T_68598_row0_col8\" class=\"data row0 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row1\" class=\"row_heading level0 row1\" >1</th>\n",
+       "      <td id=\"T_68598_row1_col0\" class=\"data row1 col0\" >32.1</td>\n",
+       "      <td id=\"T_68598_row1_col1\" class=\"data row1 col1\" >Y (16g)</td>\n",
+       "      <td id=\"T_68598_row1_col2\" class=\"data row1 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_68598_row1_col3\" class=\"data row1 col3\" >g</td>\n",
+       "      <td id=\"T_68598_row1_col4\" class=\"data row1 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_68598_row1_col5\" class=\"data row1 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_68598_row1_col6\" class=\"data row1 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_68598_row1_col7\" class=\"data row1 col7\" >7113</td>\n",
+       "      <td id=\"T_68598_row1_col8\" class=\"data row1 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row2\" class=\"row_heading level0 row2\" >2</th>\n",
+       "      <td id=\"T_68598_row2_col0\" class=\"data row2 col0\" >32.2</td>\n",
+       "      <td id=\"T_68598_row2_col1\" class=\"data row2 col1\" >Z (16g)</td>\n",
+       "      <td id=\"T_68598_row2_col2\" class=\"data row2 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_68598_row2_col3\" class=\"data row2 col3\" >g</td>\n",
+       "      <td id=\"T_68598_row2_col4\" class=\"data row2 col4\" >00:00.0952</td>\n",
+       "      <td id=\"T_68598_row2_col5\" class=\"data row2 col5\" >00:19.0012</td>\n",
+       "      <td id=\"T_68598_row2_col6\" class=\"data row2 col6\" >00:18.0059</td>\n",
+       "      <td id=\"T_68598_row2_col7\" class=\"data row2 col7\" >7113</td>\n",
+       "      <td id=\"T_68598_row2_col8\" class=\"data row2 col8\" >393.86 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row3\" class=\"row_heading level0 row3\" >3</th>\n",
+       "      <td id=\"T_68598_row3_col0\" class=\"data row3 col0\" >80.0</td>\n",
+       "      <td id=\"T_68598_row3_col1\" class=\"data row3 col1\" >X (8g)</td>\n",
+       "      <td id=\"T_68598_row3_col2\" class=\"data row3 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_68598_row3_col3\" class=\"data row3 col3\" >g</td>\n",
+       "      <td id=\"T_68598_row3_col4\" class=\"data row3 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_68598_row3_col5\" class=\"data row3 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_68598_row3_col6\" class=\"data row3 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_68598_row3_col7\" class=\"data row3 col7\" >9070</td>\n",
+       "      <td id=\"T_68598_row3_col8\" class=\"data row3 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row4\" class=\"row_heading level0 row4\" >4</th>\n",
+       "      <td id=\"T_68598_row4_col0\" class=\"data row4 col0\" >80.1</td>\n",
+       "      <td id=\"T_68598_row4_col1\" class=\"data row4 col1\" >Y (8g)</td>\n",
+       "      <td id=\"T_68598_row4_col2\" class=\"data row4 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_68598_row4_col3\" class=\"data row4 col3\" >g</td>\n",
+       "      <td id=\"T_68598_row4_col4\" class=\"data row4 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_68598_row4_col5\" class=\"data row4 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_68598_row4_col6\" class=\"data row4 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_68598_row4_col7\" class=\"data row4 col7\" >9070</td>\n",
+       "      <td id=\"T_68598_row4_col8\" class=\"data row4 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row5\" class=\"row_heading level0 row5\" >5</th>\n",
+       "      <td id=\"T_68598_row5_col0\" class=\"data row5 col0\" >80.2</td>\n",
+       "      <td id=\"T_68598_row5_col1\" class=\"data row5 col1\" >Z (8g)</td>\n",
+       "      <td id=\"T_68598_row5_col2\" class=\"data row5 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_68598_row5_col3\" class=\"data row5 col3\" >g</td>\n",
+       "      <td id=\"T_68598_row5_col4\" class=\"data row5 col4\" >00:00.0948</td>\n",
+       "      <td id=\"T_68598_row5_col5\" class=\"data row5 col5\" >00:19.0013</td>\n",
+       "      <td id=\"T_68598_row5_col6\" class=\"data row5 col6\" >00:18.0064</td>\n",
+       "      <td id=\"T_68598_row5_col7\" class=\"data row5 col7\" >9070</td>\n",
+       "      <td id=\"T_68598_row5_col8\" class=\"data row5 col8\" >502.09 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row6\" class=\"row_heading level0 row6\" >6</th>\n",
+       "      <td id=\"T_68598_row6_col0\" class=\"data row6 col0\" >36.1</td>\n",
+       "      <td id=\"T_68598_row6_col1\" class=\"data row6 col1\" >Pressure/Temperature:01</td>\n",
+       "      <td id=\"T_68598_row6_col2\" class=\"data row6 col2\" >Temperature</td>\n",
+       "      <td id=\"T_68598_row6_col3\" class=\"data row6 col3\" >°C</td>\n",
+       "      <td id=\"T_68598_row6_col4\" class=\"data row6 col4\" >00:00.0945</td>\n",
+       "      <td id=\"T_68598_row6_col5\" class=\"data row6 col5\" >00:19.0175</td>\n",
+       "      <td id=\"T_68598_row6_col6\" class=\"data row6 col6\" >00:18.0230</td>\n",
+       "      <td id=\"T_68598_row6_col7\" class=\"data row6 col7\" >20</td>\n",
+       "      <td id=\"T_68598_row6_col8\" class=\"data row6 col8\" >1.10 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_68598_level0_row7\" class=\"row_heading level0 row7\" >7</th>\n",
+       "      <td id=\"T_68598_row7_col0\" class=\"data row7 col0\" >59.1</td>\n",
+       "      <td id=\"T_68598_row7_col1\" class=\"data row7 col1\" >Control Pad Temperature</td>\n",
+       "      <td id=\"T_68598_row7_col2\" class=\"data row7 col2\" >Temperature</td>\n",
+       "      <td id=\"T_68598_row7_col3\" class=\"data row7 col3\" >°C</td>\n",
+       "      <td id=\"T_68598_row7_col4\" class=\"data row7 col4\" >00:00.0979</td>\n",
+       "      <td id=\"T_68598_row7_col5\" class=\"data row7 col5\" >00:18.0910</td>\n",
+       "      <td id=\"T_68598_row7_col6\" class=\"data row7 col6\" >00:17.0931</td>\n",
+       "      <td id=\"T_68598_row7_col7\" class=\"data row7 col7\" >180</td>\n",
+       "      <td id=\"T_68598_row7_col8\" class=\"data row7 col8\" >10.04 Hz</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n"
+      ],
+      "text/plain": [
+       "<pandas.io.formats.style.Styler at 0x26494829490>"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "get_channel_table(doc, ACCELERATION+TEMPERATURE)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9bc36cab",
+   "metadata": {},
+   "source": [
+    "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`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "bc3f39be",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<style type=\"text/css\">\n",
+       "</style>\n",
+       "<table id=\"T_6ade9_\">\n",
+       "  <thead>\n",
+       "    <tr>\n",
+       "      <th class=\"blank level0\" >&nbsp;</th>\n",
+       "      <th class=\"col_heading level0 col0\" >channel</th>\n",
+       "      <th class=\"col_heading level0 col1\" >name</th>\n",
+       "      <th class=\"col_heading level0 col2\" >type</th>\n",
+       "      <th class=\"col_heading level0 col3\" >units</th>\n",
+       "      <th class=\"col_heading level0 col4\" >start</th>\n",
+       "      <th class=\"col_heading level0 col5\" >end</th>\n",
+       "      <th class=\"col_heading level0 col6\" >duration</th>\n",
+       "      <th class=\"col_heading level0 col7\" >samples</th>\n",
+       "      <th class=\"col_heading level0 col8\" >rate</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row0\" class=\"row_heading level0 row0\" >0</th>\n",
+       "      <td id=\"T_6ade9_row0_col0\" class=\"data row0 col0\" >32.0</td>\n",
+       "      <td id=\"T_6ade9_row0_col1\" class=\"data row0 col1\" >X (16g)</td>\n",
+       "      <td id=\"T_6ade9_row0_col2\" class=\"data row0 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_6ade9_row0_col3\" class=\"data row0 col3\" >g</td>\n",
+       "      <td id=\"T_6ade9_row0_col4\" class=\"data row0 col4\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row0_col5\" class=\"data row0 col5\" >00:10.0001</td>\n",
+       "      <td id=\"T_6ade9_row0_col6\" class=\"data row0 col6\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row0_col7\" class=\"data row0 col7\" >1969</td>\n",
+       "      <td id=\"T_6ade9_row0_col8\" class=\"data row0 col8\" >393.75 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row1\" class=\"row_heading level0 row1\" >1</th>\n",
+       "      <td id=\"T_6ade9_row1_col0\" class=\"data row1 col0\" >32.1</td>\n",
+       "      <td id=\"T_6ade9_row1_col1\" class=\"data row1 col1\" >Y (16g)</td>\n",
+       "      <td id=\"T_6ade9_row1_col2\" class=\"data row1 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_6ade9_row1_col3\" class=\"data row1 col3\" >g</td>\n",
+       "      <td id=\"T_6ade9_row1_col4\" class=\"data row1 col4\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row1_col5\" class=\"data row1 col5\" >00:10.0001</td>\n",
+       "      <td id=\"T_6ade9_row1_col6\" class=\"data row1 col6\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row1_col7\" class=\"data row1 col7\" >1969</td>\n",
+       "      <td id=\"T_6ade9_row1_col8\" class=\"data row1 col8\" >393.75 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row2\" class=\"row_heading level0 row2\" >2</th>\n",
+       "      <td id=\"T_6ade9_row2_col0\" class=\"data row2 col0\" >32.2</td>\n",
+       "      <td id=\"T_6ade9_row2_col1\" class=\"data row2 col1\" >Z (16g)</td>\n",
+       "      <td id=\"T_6ade9_row2_col2\" class=\"data row2 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_6ade9_row2_col3\" class=\"data row2 col3\" >g</td>\n",
+       "      <td id=\"T_6ade9_row2_col4\" class=\"data row2 col4\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row2_col5\" class=\"data row2 col5\" >00:10.0001</td>\n",
+       "      <td id=\"T_6ade9_row2_col6\" class=\"data row2 col6\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row2_col7\" class=\"data row2 col7\" >1969</td>\n",
+       "      <td id=\"T_6ade9_row2_col8\" class=\"data row2 col8\" >393.75 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row3\" class=\"row_heading level0 row3\" >3</th>\n",
+       "      <td id=\"T_6ade9_row3_col0\" class=\"data row3 col0\" >80.0</td>\n",
+       "      <td id=\"T_6ade9_row3_col1\" class=\"data row3 col1\" >X (8g)</td>\n",
+       "      <td id=\"T_6ade9_row3_col2\" class=\"data row3 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_6ade9_row3_col3\" class=\"data row3 col3\" >g</td>\n",
+       "      <td id=\"T_6ade9_row3_col4\" class=\"data row3 col4\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row3_col5\" class=\"data row3 col5\" >00:10.0001</td>\n",
+       "      <td id=\"T_6ade9_row3_col6\" class=\"data row3 col6\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row3_col7\" class=\"data row3 col7\" >2510</td>\n",
+       "      <td id=\"T_6ade9_row3_col8\" class=\"data row3 col8\" >501.98 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row4\" class=\"row_heading level0 row4\" >4</th>\n",
+       "      <td id=\"T_6ade9_row4_col0\" class=\"data row4 col0\" >80.1</td>\n",
+       "      <td id=\"T_6ade9_row4_col1\" class=\"data row4 col1\" >Y (8g)</td>\n",
+       "      <td id=\"T_6ade9_row4_col2\" class=\"data row4 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_6ade9_row4_col3\" class=\"data row4 col3\" >g</td>\n",
+       "      <td id=\"T_6ade9_row4_col4\" class=\"data row4 col4\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row4_col5\" class=\"data row4 col5\" >00:10.0001</td>\n",
+       "      <td id=\"T_6ade9_row4_col6\" class=\"data row4 col6\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row4_col7\" class=\"data row4 col7\" >2510</td>\n",
+       "      <td id=\"T_6ade9_row4_col8\" class=\"data row4 col8\" >501.98 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row5\" class=\"row_heading level0 row5\" >5</th>\n",
+       "      <td id=\"T_6ade9_row5_col0\" class=\"data row5 col0\" >80.2</td>\n",
+       "      <td id=\"T_6ade9_row5_col1\" class=\"data row5 col1\" >Z (8g)</td>\n",
+       "      <td id=\"T_6ade9_row5_col2\" class=\"data row5 col2\" >Acceleration</td>\n",
+       "      <td id=\"T_6ade9_row5_col3\" class=\"data row5 col3\" >g</td>\n",
+       "      <td id=\"T_6ade9_row5_col4\" class=\"data row5 col4\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row5_col5\" class=\"data row5 col5\" >00:10.0001</td>\n",
+       "      <td id=\"T_6ade9_row5_col6\" class=\"data row5 col6\" >00:05.0000</td>\n",
+       "      <td id=\"T_6ade9_row5_col7\" class=\"data row5 col7\" >2510</td>\n",
+       "      <td id=\"T_6ade9_row5_col8\" class=\"data row5 col8\" >501.98 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row6\" class=\"row_heading level0 row6\" >6</th>\n",
+       "      <td id=\"T_6ade9_row6_col0\" class=\"data row6 col0\" >36.1</td>\n",
+       "      <td id=\"T_6ade9_row6_col1\" class=\"data row6 col1\" >Pressure/Temperature:01</td>\n",
+       "      <td id=\"T_6ade9_row6_col2\" class=\"data row6 col2\" >Temperature</td>\n",
+       "      <td id=\"T_6ade9_row6_col3\" class=\"data row6 col3\" >°C</td>\n",
+       "      <td id=\"T_6ade9_row6_col4\" class=\"data row6 col4\" >00:04.0954</td>\n",
+       "      <td id=\"T_6ade9_row6_col5\" class=\"data row6 col5\" >00:10.0966</td>\n",
+       "      <td id=\"T_6ade9_row6_col6\" class=\"data row6 col6\" >00:06.0011</td>\n",
+       "      <td id=\"T_6ade9_row6_col7\" class=\"data row6 col7\" >6</td>\n",
+       "      <td id=\"T_6ade9_row6_col8\" class=\"data row6 col8\" >1.00 Hz</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th id=\"T_6ade9_level0_row7\" class=\"row_heading level0 row7\" >7</th>\n",
+       "      <td id=\"T_6ade9_row7_col0\" class=\"data row7 col0\" >59.1</td>\n",
+       "      <td id=\"T_6ade9_row7_col1\" class=\"data row7 col1\" >Control Pad Temperature</td>\n",
+       "      <td id=\"T_6ade9_row7_col2\" class=\"data row7 col2\" >Temperature</td>\n",
+       "      <td id=\"T_6ade9_row7_col3\" class=\"data row7 col3\" >°C</td>\n",
+       "      <td id=\"T_6ade9_row7_col4\" class=\"data row7 col4\" >00:05.0086</td>\n",
+       "      <td id=\"T_6ade9_row7_col5\" class=\"data row7 col5\" >00:10.0095</td>\n",
+       "      <td id=\"T_6ade9_row7_col6\" class=\"data row7 col6\" >00:05.0008</td>\n",
+       "      <td id=\"T_6ade9_row7_col7\" class=\"data row7 col7\" >50</td>\n",
+       "      <td id=\"T_6ade9_row7_col8\" class=\"data row7 col8\" >9.98 Hz</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n"
+      ],
+      "text/plain": [
+       "<pandas.io.formats.style.Styler at 0x264948296a0>"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "get_channel_table(doc, ACCELERATION+TEMPERATURE, start=\"0:05\", end=\"0:10\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8bfff52d",
+   "metadata": {},
+   "source": [
+    "# Extracting intervals: `endaq.ide.extract_time()`\n",
+    "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)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2423edf3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "extract_time(\"tests/test.ide\", \"doc_extracted.ide\", start=\"0:05\", end=\"0:10\")\n",
+    "extract_time(doc1, \"doc1_extracted.ide\", start=\"0:05\", end=\"0:10\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "58cf1c20",
+   "metadata": {},
+   "source": [
+    "# Additional sample IDE recording files\n",
+    "Here are a number of example IDE files, which may be used with `endaq.ide`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8a151765",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "file_urls = ['https://info.endaq.com/hubfs/data/surgical-instrument.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/97c3990f-Drive-Home_70-1616632444.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/High-Drop.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/HiTest-Shock.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/Drive-Home_01.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/Tower-of-Terror.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Punching-Bag.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/Gun-Stock.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Seat-Base_21.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Seat-Top_09.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/Bolted.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Motorcycle-Car-Crash.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/train-passing.ide', \n",
+    "             'https://info.endaq.com/hubfs/data/baseball.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Clean-Room-VC.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/enDAQ_Cropped.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Drive-Home_07.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/ford_f150.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Drive-Home.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Mining-Data.ide',\n",
+    "             'https://info.endaq.com/hubfs/data/Mide-Airport-Drive-Lexus-Hybrid-Dash-W8.ide'] "
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d9ade002",
+   "metadata": {},
+   "source": [
+    "These can be directly read from endaq.com using `endaq.ide.get_doc()`, as previously described."
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/endaq/ide/files.py b/endaq/ide/files.py
new file mode 100644
index 0000000..5288bce
--- /dev/null
+++ b/endaq/ide/files.py
@@ -0,0 +1,259 @@
+"""
+files.py: IDE file access functions.
+"""
+# TODO: Progress callback for `get_doc()` (separate from the `callback` argument
+#  of `idelib.importer.openFile()` and `idelib.importer.readData()`?)
+# TODO: Exception subclasses for `get_doc()` failures, to separate the function's
+#  own errors from `ValueError` exceptions raised by things the function calls?
+
+from datetime import datetime
+import os
+from pathlib import Path
+import tempfile
+from urllib.parse import urlparse
+
+from idelib.importer import openFile, readData
+from idelib.util import extractTime
+import requests
+
+from .gdrive import gdrive_download
+from .info import parse_time
+from .util import validate
+
+__all__ = ['get_doc', 'extract_time']
+
+# ============================================================================
+#
+# ============================================================================
+
+
+def _get_url(url, localfile=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 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
+        bytes downloaded.
+    """
+    parsed_url = urlparse(url)
+    session = requests.Session()
+
+    netloc = parsed_url.netloc.lower()
+    if netloc.endswith('.google.com') or netloc == "google.com":
+        response, filename = gdrive_download(url, localfile, params=params, cookies=cookies)
+    else:
+        response = session.get(parsed_url.geturl(), params=params, cookies=cookies)
+        filename = None
+
+    if not response.ok:
+        raise ValueError(f"Could not retrieve data from URL {url} "
+                         f"({response.status_code}: {response.reason})")
+
+    if localfile is None:
+        stream = tempfile.SpooledTemporaryFile(suffix=".ide")
+    else:
+        localfile = os.path.abspath(os.path.expanduser(localfile))
+        if os.path.isdir(localfile):
+            if not filename:
+                filename = os.path.basename(parsed_url.path)
+            localfile = os.path.join(localfile, filename)
+        if not localfile.lower().endswith('.ide'):
+            localfile += ".ide"
+        stream = open(localfile, 'w+b')
+
+    total = 0
+    for chunk in response.iter_content(2**15):
+        # future: Confirm that this is an IDE from 1st chunk, avoiding download if not
+        if chunk:
+            stream.write(chunk)
+            total += len(chunk)
+
+    response.close()
+    stream.seek(0)
+
+    return stream, total
+
+
+# ============================================================================
+#
+# ============================================================================
+
+def get_doc(name=None, filename=None, url=None, parsed=True, start=0, end=None,
+            localfile=None, params=None, cookies=None, **kwargs):
+    """
+    Retrieve an IDE file from either a file or URL.
+
+    Note: `name`, `filename`, and `url` are mutually exclusive arguments.
+    One and only one must be specified. Attempting to supply more than one
+    will generate an error.
+
+    Example usage::
+
+        get_doc("my_recording.ide")
+        get_doc("https://example.com/remote_recording.ide")
+        get_doc(filename="my_recording.ide")
+        get_doc(url="https://example.com/remote_recording.ide")
+        get_doc(filename="my_recording.ide", start="1:23")
+
+    The `start` and `end` times, if used, may be specified in several
+    ways:
+
+    * `int`/`float` (Microseconds from the recording start)
+    * `str` (formatted as a time from the recording start, e.g., `MM:SS`,
+      `HH:MM:SS`, `DDd HH:MM:SS`). More examples:
+
+      * ``":01"`` or ``":1"`` or ``"1s"`` (1 second)
+      * ``"22:11"`` (22 minutes, 11 seconds)
+      * ``"3:22:11"`` (3 hours, 22 minutes, 11 seconds)
+      * ``"1d 3:22:11"`` (1 day, 3 hours, 22 minutes, 11 seconds)
+    * `datetime.timedelta` or `pandas.Timedelta` (time from the
+      recording start)
+    * `datetime.datetime` (an explicit UTC time)
+
+    :param name: The name or URL of the IDE. The method of fetching it will
+        be automatically chosen based on how it is formatted.
+    :param filename: The name of an IDE file. Supplying a name this way will
+        force it to be read from a file, avoiding the possibility of
+        accidentally trying to retrieve it via URL.
+    :param url: The URL of an IDE file. Supplying a name this way will force
+        it to be read from a URL, avoiding the possibility of accidentally
+        trying to retrieve it from a local file.
+    :param parsed: If `True` (default), the IDE will be fully parsed after it
+        is fetched. If `False`, only the file metadata will be initially
+        loaded, and a call to `idelib.importer.readData()`. This can save
+        time.
+    :param start: The starting time. Defaults to the start of the
+        recording. Only applicable if `parsed` is `True`.
+    :param end: The ending time. Defaults to the end of the recording.  Only
+        applicable if `parsed` is `True`.
+    :param localfile: The name of the file to which to write data recieved
+        from a URL. If none is supplied, a temporary file will be used. Only
+        applicable when opening a URL.
+    :param params: Additional URL request parameters. 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.
+
+    Additionally, `get_doc()` will accept the keyword arguments for
+    `idelib.importer.importFile()` or `idelib.importer.openFile()`
+    """
+    if len([x for x in (name, filename, url) if x]) != 1:
+        raise TypeError("Only one source can be specified: name, filename, or url")
+
+    original = name or filename or url  # For error reporting
+    stream = None
+    parsed_url = None
+
+    if name:
+        if os.path.isfile(name):
+            filename = name
+        else:
+            parsed_url = urlparse(name.replace('\\', '/'))
+            if not parsed_url.scheme or parsed_url.scheme == "file":
+                filename = parsed_url.path
+            else:
+                url = name
+
+    if filename:
+        filename = os.path.abspath(os.path.expanduser(filename))
+        stream = open(filename, 'rb')
+
+    elif 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)
+        else:
+            # future: more fetching schemes before this `else` (ftp, etc.)?
+            raise ValueError(f"Unsupported transfer scheme: {parsed_url.scheme}")
+
+    if stream:
+        if not validate(stream):
+            stream.close()
+            raise ValueError(f"Could not read a Dataset from '{original}'"
+                             f"(not an IDE file?)")
+
+        # Separate `openFile()` and `readData` kwargs, remove ones that aren't shared
+        open_kwargs = kwargs.copy()
+        read_kwargs = kwargs.copy()
+
+        for k in ('startTime', 'endTime', 'channels', 'source', 'total',
+                  'bytesRead', 'samplesRead'):
+            open_kwargs.pop(k, None)
+
+        doc = openFile(stream, **open_kwargs)
+
+        if parsed:
+            for k in ('defaults', 'name', 'quiet'):
+                read_kwargs.pop(k, None)
+
+            session_start = doc.lastSession.utcStartTime
+            if session_start:
+                session_start = datetime.utcfromtimestamp(session_start)
+
+            if start:
+                read_kwargs['startTime'] = parse_time(start, session_start)
+            if end:
+                read_kwargs['endTime'] = parse_time(end, session_start)
+
+            readData(doc, **read_kwargs)
+
+        return doc
+
+    raise ValueError(f"Could not read data from '{original}'")
+
+
+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.
+
+    The `start` and `end` times, if used, may be specified in several
+    ways:
+
+    * `int`/`float` (Microseconds from the recording start)
+    * `str` (formatted as a time from the recording start, e.g., `MM:SS`,
+      `HH:MM:SS`, `DDd HH:MM:SS`). More examples:
+
+      * ``":01"`` or ``":1"`` or ``"1s"`` (1 second)
+      * ``"22:11"`` (22 minutes, 11 seconds)
+      * ``"3:22:11"`` (3 hours, 22 minutes, 11 seconds)
+      * ``"1d 3:22:11"`` (1 day, 3 hours, 22 minutes, 11 seconds)
+    * `datetime.timedelta` or `pandas.Timedelta` (time from the
+      recording start)
+    * `datetime.datetime` (an explicit UTC time)
+
+    :param doc: A `Dataset` or the name of a local IDE file. `Dataset`
+        objects do not have to be fully imported.
+    :param out: A filename or stream to which to save the extracted data.
+    :param start: The starting time. Defaults to the start of the recording.
+    :param end: The ending time. Defaults to the end of the recording.
+    :param channels: A list of channel IDs to specifically export. If `None`,
+        all channels will be exported. Note excluded channels will still
+        appear in the new IDE's `channels` dictionary, but the file will
+        contain no data for them.
+    :return: The total number of bytes written, and total number of
+        ChannelDataBlock elements copied.
+    """
+    if isinstance(doc, (str, Path)):
+        doc = openFile(doc)
+
+    session_start = doc.lastSession.utcStartTime
+    if session_start:
+        session_start = datetime.utcfromtimestamp(session_start)
+
+    if start:
+        kwargs['startTime'] = parse_time(start, session_start)
+    if end:
+        kwargs['endTime'] = parse_time(end, session_start)
+    kwargs['channels'] = channels
+
+    return extractTime(doc, out, **kwargs)
+
diff --git a/endaq/ide/gdrive.py b/endaq/ide/gdrive.py
new file mode 100644
index 0000000..54588f1
--- /dev/null
+++ b/endaq/ide/gdrive.py
@@ -0,0 +1,81 @@
+"""
+gdrive.py: Accessing data via Google Drive links.
+"""
+import re
+from urllib.parse import parse_qs, urlparse
+
+import requests
+
+# DRIVE_URL = "https://docs.google.com/uc?export=download"
+DRIVE_URL = "https://docs.google.com/uc"
+
+
+def get_file_id(url):
+    """
+    Extract the Google Drive file ID from a URL. The ID can be in the
+    URL path itself, or a query argument.
+
+    :param url: The Google Drive 'shared link'.
+    :return: The file ID portion of the URL.
+    """
+    # NOTE: This is a somewhat brittle hack. Revised later?
+    parsed = urlparse(url)
+    query = parse_qs(parsed.query)
+
+    qid = query.get('id', '')
+    if qid:
+        return qid[0]
+
+    match = re.match(r"^/file/d/(.*?)/view$", parsed.path)
+    if match:
+        return match.groups()[0]
+
+    return None
+
+
+def gdrive_download(url, localfile, params=None, cookies=None, drive_url=DRIVE_URL):
+    """
+    Retrieve an IDE from Google Drive. The file must be set to be shared
+    with anyone with the URL.
+
+    :param url: The 'shared link' to the file on Google Drive.
+    :param localfile: The local filename (if saving the file).
+    :param params: Additional (optional) request parameters.
+    :param cookies: Optional browser cookies for the session.
+    :param drive_url: The Google Docs download URL.
+    :return: The 'get' response and the filename.
+    """
+    file_id = get_file_id(url)
+    if not file_id:
+        raise ValueError(f"Could not identify ID in URL {url}")
+
+    p = {'id': file_id}
+    if params:
+        p.update(params)
+
+    session = requests.Session()
+    response = session.get(drive_url, params=p, cookies=cookies, stream=True)
+
+    if not response.ok:
+        raise ValueError(f"Could not retrieve data from URL {url} "
+                         f"({response.status_code}: {response.reason})")
+
+    # A response with the file will have its name in the headers.
+    name = None
+
+    if 'Content-Disposition' not in response.headers:
+        # TODO: This is probably an intermediate page, possibly a warning.
+        #  Get the download URL from the response body text and/or
+        #  authorization token from the cookies, then get another response.
+        raise ValueError(f"Could not retrieve data from URL {url} "
+                         f"(not shared 'anyone with link'?)")
+
+    if 'Content-Disposition' in response.headers:
+        m = re.search('filename="(.*)"', ''.join(response.headers['Content-Disposition']))
+        if m:
+            name = m.groups()[0]
+
+    if not name:
+        raise ValueError(f"Could not retrieve data from URL {url}")
+
+    return response, name
diff --git a/endaq/ide/info.py b/endaq/ide/info.py
new file mode 100644
index 0000000..10b22fc
--- /dev/null
+++ b/endaq/ide/info.py
@@ -0,0 +1,328 @@
+"""
+Functions for retrieving summary data from a dataset.
+"""
+from __future__ import annotations
+import typing
+
+from collections import defaultdict
+import datetime
+import string
+import warnings
+
+import numpy as np
+import pandas as pd
+import idelib
+
+from .measurement import ANY, get_channels
+
+
+__all__ = [
+    "get_channel_table",
+    "to_pandas",
+]
+
+
+# ============================================================================
+# Display formatting functions
+# ============================================================================
+
+def parse_time(t, datetime_start=None):
+    """ Convert a time in one of several user-friendly forms to microseconds
+        (the native time units used in `idelib`). Valid types are:
+
+        * `None`, `int`, or `float` (returns the same value)
+        * `str` (formatted as a time, e.g., `MM:SS`, `HH:MM:SS`,
+          `DDd HH:MM:SS`). More examples:
+
+          * ``":01"`` or ``":1"`` or ``"1s"`` (1 second)
+          * ``"22:11"`` (22 minutes, 11 seconds)
+          * ``"3:22:11"`` (3 hours, 22 minutes, 11 seconds)
+          * ``"1d 3:22:11"`` (3 hours, 22 minutes, 11 seconds)
+        * `datetime.timedelta` or `pandas.Timedelta`
+        * `datetime.datetime`
+
+        :param t: The time value to convert.
+        :param datetime_start: If `t` is a `datetime` object, the result will
+            be relative to `datetime_start`. It will default to the start of
+            the day portion of `t`. This has no effect on non-`datetime`
+            values of `t` .
+        :returns: The time in microseconds.
+    """
+    # TODO: Put this somewhere else? It will be useful elsewhere, and shouldn't
+    #   be bound to the `pandas` requirement in this module.
+
+    if t is None or isinstance(t, (int, float)):
+        return t
+
+    elif isinstance(t, str):
+        if not t:
+            return None
+        orig = t
+        t = t.strip().lower()
+        for c in ":dhms":
+            t = t.replace(c, ' ')
+        if not all(c in string.digits + ' ' for c in t):
+            raise ValueError(f"Bad time string for parse_time(): {orig!r}")
+
+        micros = 0
+        for part, mult in zip(reversed(t.split()), (1, 60, 3600, 86400)):
+            if not part:
+                continue
+            part = part.strip(string.ascii_letters + string.punctuation + string.whitespace)
+            micros += float(part) * mult
+        return micros * 10**6
+
+    elif isinstance(t, datetime.timedelta):
+        return t.total_seconds() * 10**6
+
+    elif isinstance(t, (datetime.time, datetime.datetime)):
+        if datetime_start is None:
+            # No starting time, assume midnight of same day.
+            datetime_start = datetime.datetime(t.year, t.month, t.day)
+
+        if isinstance(t, datetime.time):
+            # just time: make datetime
+            t = datetime.datetime.combine(datetime_start, t)
+
+        if isinstance(t, datetime.datetime):
+            # datetime: make timedelta
+            return (t - datetime_start).total_seconds() * 10**6
+
+    raise TypeError(f"Unsupported type for parse_time(): {type(t).__name__} ({t!r})")
+
+
+# ============================================================================
+# Display formatting functions
+# ============================================================================
+
+def format_channel_id(ch):
+    """ Function for formatting an `idelib.dataset.Channel` or `SubChannel`
+        for display. Renders as only the channel and subchannel IDs (the other
+        information is shown in the rest of the table).
+
+        :param ch: The `idelib.dataset.Channel` or `idelib.dataset.SubChannel`
+            to format.
+        :return: A formatted "channel.subchannel" string.
+    """
+    try:
+        if ch.parent:
+            return f"{ch.parent.id}.{ch.id}"
+        else:
+            return f"{ch.id}.*"
+
+    except (AttributeError, TypeError, ValueError) as err:
+        warnings.warn(f"format_channel_id({ch!r}) raised {type(err).__name__}: {err}")
+        return str(ch)
+
+
+def format_timedelta(val):
+    """ Function for formatting microsecond timestamps (e.g., start, end,
+        or duration) as times. Somewhat more condensed than the standard
+        `DataFrame` formatting of `datetime.timedelta`.
+
+        :param val: The `pandas.Timedelta` or `datetime.timedelta` to format.
+            Will also work with microseconds as `float` or `int`.
+        :return: A formatted time 'duration' string.
+    """
+    try:
+        if isinstance(val, datetime.timedelta):
+            td = pd.Timedelta(val)
+        else:
+            td = pd.Timedelta(microseconds=val)
+
+        # NOTE: `components` attr only exists in pandas `Timedelta`
+        c = td.components
+        s = f"{c.minutes:02d}:{c.seconds:02d}.{c.milliseconds:04d}"
+        if c.hours or c.days:
+            s = f"{c.hours:02d}:{s}"
+            if c.days:
+                s = f"{c.days}d {s}"
+        return s
+
+    except (AttributeError, TypeError, ValueError) as err:
+        warnings.warn(f"format_timedelta({val!r}) raised {type(err).__name__}: {err}")
+        return str(val)
+
+
+def format_timestamp(ts):
+    """ Function for formatting start/end timestamps. Somewhat more condensed
+        than the standard Pandas formatting.
+
+        :param ts: The timestamps in microseconds. Rendered as integers, since
+            `idelib` timestamps have whole microsecond resolution.
+        :return: A formatted timestamp string, with units.
+    """
+    try:
+        return f"{int(ts)} µs"
+    except (TypeError, ValueError) as err:
+        warnings.warn(f"format_timestamp({ts!r}) raised {type(err).__name__}: {err}")
+        return str(ts)
+
+# ============================================================================
+#
+# ============================================================================
+
+
+""" The default table formatting. """
+TABLE_FORMAT = {
+    'channel': format_channel_id,
+    'start': format_timedelta,
+    'end': format_timedelta,
+    'duration': format_timedelta,
+    'rate': "{:.2f} Hz",
+}
+
+
+def get_channel_table(dataset, measurement_type=ANY, start=0, end=None,
+                      formatting=None, index=True, precision=4,
+                      timestamps=False, **kwargs):
+    """ Get summary data for all `SubChannel` objects in a `Dataset` that
+        contain one or more type of sensor data. By using the optional
+        `start` and `end` parameters, information can be retrieved for a
+        specific interval of time.
+
+        The `start` and `end` times, if used, may be specified in several
+        ways:
+
+        * `int`/`float` (Microseconds from the recording start)
+        * `str` (formatted as a time from the recording start, e.g., `MM:SS`,
+          `HH:MM:SS`, `DDd HH:MM:SS`). More examples:
+
+          * ``":01"`` or ``":1"`` or ``"1s"`` (1 second)
+          * ``"22:11"`` (22 minutes, 11 seconds)
+          * ``"3:22:11"`` (3 hours, 22 minutes, 11 seconds)
+          * ``"1d 3:22:11"`` (1 day, 3 hours, 22 minutes, 11 seconds)
+        * `datetime.timedelta` or `pandas.Timedelta` (time from the
+          recording start)
+        * `datetime.datetime` (an explicit UTC time)
+
+        :param dataset: A `idelib.dataset.Dataset` or a list of
+            channels/subchannels from which to build the table.
+        :param measurement_type: A `MeasurementType`, a measurement type
+            'key' string, or a string of multiple keys generated by adding
+            and/or subtracting `MeasurementType` objects to filter the
+            results. Any 'subtracted' types will be excluded.
+        :param start: The starting time. Defaults to the start of the
+            recording.
+        :param end: The ending time. Defaults to the end of the recording.
+        :param formatting: A dictionary of additional style/formatting items
+            (see `pandas.DataFrame.style.format()`). If `False`, no additional
+            formatting is applied.
+        :param index: If `True`, show the index column on the left.
+        :param precision: The default decimal precision to display. Can be
+            changed later.
+        :param timestamps: If `True`, show the start and end as raw
+            microsecond timestamps.
+        :returns: A table (`pandas.io.formats.style.Styler`) of summary data.
+        :rtype: pandas.DataFrame
+    """
+    # We don't support multiple sessions on current Slam Stick/enDAQ recorders,
+    # but in the event we ever do, this allows one to be specified like so:
+    #       :param session: A `Session` or session ID to retrieve from a
+    #           multi-session recording.
+    # Leave out of docstring until we ever support it.
+    session = kwargs.get('session', None)
+    if session:
+        session = getattr(session, 'sessionId', session)
+
+    if hasattr(dataset, 'getPlots'):
+        sources = get_channels(dataset, measurement_type)
+    else:
+        sources = dataset
+
+    result = defaultdict(list)
+    for source in sources:
+        range_start = range_end = duration = rate = session_start = None
+        samples = 0
+
+        data = source.getSession(session)
+        if data.session.utcStartTime:
+            session_start = datetime.datetime.utcfromtimestamp(data.session.utcStartTime)
+        start = parse_time(start, session_start)
+        end = parse_time(end, session_start)
+
+        if len(data):
+            if not start and not end:
+                start_idx, end_idx = 0, -1
+                samples = len(data)
+            else:
+                start_idx, end_idx = data.getRangeIndices(start, end)
+                end_idx = min(len(data) - 1, end_idx)
+                if end_idx < 0:
+                    samples = len(data) - start_idx - 1
+                else:
+                    samples = end_idx - start_idx
+
+            range_start = data[int(start_idx)][0]
+            range_end = data[int(end_idx)][0]
+            duration = range_end - range_start
+            rate = samples / (duration / 10 ** 6)
+
+        result['channel'].append(source)
+        result['name'].append(source.name)
+        result['type'].append(source.units[0])
+        result['units'].append(source.units[1])
+        result['start'].append(range_start)
+        result['end'].append(range_end)
+        result['duration'].append(duration)
+        result['samples'].append(samples)
+        result['rate'].append(rate)
+
+        # # TODO: RESTORE AFTER FIX IN idelib
+        # dmin, dmean, dmax = data.getRangeMinMeanMax(start, end)
+        # result['min'].append(dmin)
+        # result['mean'].append(dmean)
+        # result['max'].append(dmax)
+
+    if formatting is False:
+        return pd.DataFrame(result).style
+
+    style = TABLE_FORMAT.copy()
+    if timestamps:
+        style.update({
+            'start': format_timestamp,
+            'end': format_timestamp
+        })
+    if isinstance(formatting, dict):
+        style.update(formatting)
+
+    styled = pd.DataFrame(result).style.format(style, precision=precision)
+    if not index:
+        return styled.hide_index()
+    else:
+        return styled
+
+
+def to_pandas(
+    channel: typing.Union[idelib.dataset.Channel, idelib.dataset.SubChannel],
+    time_mode: typing.Literal["seconds", "timedelta", "datetime"] = "datetime",
+) -> pd.DataFrame:
+    """ Read IDE data into a pandas DataFrame.
+
+        :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
+            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
+        :return: a `pandas.DataFrame` containing the channel's data
+    """
+    data = channel.getSession().arraySlice()
+    t, data = data[0], data[1:].T
+
+    t = (1e3*t).astype("timedelta64[ns]")
+    if time_mode == "seconds":
+        t = t / np.timedelta64(1, "s")
+    elif time_mode == "datetime":
+        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:
+        columns = [channel.name]
+
+    return pd.DataFrame(data, index=pd.Series(t, name="timestamp"), columns=columns)
diff --git a/endaq/ide/measurement.py b/endaq/ide/measurement.py
new file mode 100644
index 0000000..09369a2
--- /dev/null
+++ b/endaq/ide/measurement.py
@@ -0,0 +1,368 @@
+"""
+Functions for filtering data by measurement types, and singleton objects
+representing different measurement types.
+"""
+
+__all__ = ['ANY', 'ACCELERATION', 'ALTITUDE', 'ANG_RATE', 'AUDIO', 'DIRECTION',
+           'FREQUENCY', 'GENERIC', 'GYRO', 'HUMIDITY', 'LIGHT', 'LOCATION',
+           'MAGNETIC', 'ORIENTATION', 'PRESSURE', 'ROTATION', 'SPEED',
+           'TEMPERATURE', 'TIME', 'VOLTAGE',
+           'get_measurement_type', 'filter_channels', 'get_channels']
+
+from fnmatch import fnmatch
+from shlex import shlex
+
+from idelib.dataset import Dataset, Channel, SubChannel
+
+# ============================================================================
+#
+# ============================================================================
+
+
+class MeasurementType:
+    """ Singleton/sentinel marker object for filtering channels by measurement
+        type.
+    """
+    # TODO: Fix nomenclature. "Singleton" may not be the correct term; there
+    #  are multiple instances of MeasurementType, but not multiple *duplicate*
+    #  ones. This may be closer to the "Sentinel" pattern, but the class has
+    #  more functionality than just marking.
+
+    types = {}  # Maps all measurement type 'key' strings to objects.
+    names = {}  # Maps display names to objects.
+    verbose = False
+
+    def __new__(cls, *keys, labels=None, doc=None):
+        """ Create and return a new object, if one with a matching `key` does
+            not already exist. If it does, the previous instance is returned.
+            Called prior to `__init__()`.
+
+            The first argument is taken as the display name of the
+            measurement type. Its first 3 characters are used as the default
+            key (the string actually used in filtering). Functions that use
+            `MeasurementType` objects can also use those key strings.
+            Additional non-keyword arguments are taken as alternate keys.
+
+            :param labels: A list/tuple of substrings to match against
+                `Channel` and `SubChannel` unit labels, used to identify
+                the appropriate `NeasurementType` instance. The `name` is
+                automatically included.
+            :param doc: A docstring for the `MeasurementType` instance.
+            :returns: `MeasurementType`
+        """
+        obj = None
+        name = keys[0].strip()
+        for key in keys:
+            key = str(key).strip().lower()[:3]
+            obj = cls.types.get(key, None)
+            if not obj:
+                if name not in cls.names:
+                    obj = super().__new__(cls)
+                    obj._name = name
+                    obj._key = key
+                    obj._keys = set()  # Alternative keys for this object
+                    if labels is not None:
+                        labels = (labels.strip().lower(),) if isinstance(labels, str) else labels
+                        labels = tuple(labels) if labels else ()
+                        if name not in labels:
+                            labels += (name.lower(),)
+                    obj._labels = labels
+                    obj.__doc__ = doc or name
+                    cls.types[key] = obj
+                    cls.names[name] = obj
+                else:
+                    obj = cls.names[name]
+                    cls.types[key] = obj
+            obj._keys.add(key)
+        return obj
+
+    def __str__(self):
+        return self._key
+
+    def __repr__(self):
+        if not self.verbose:
+            return f"<MeasurementType: {self._name}>"
+        keys = ', '.join([repr(k) for k in self._keys if k != self.key])
+        if keys:
+            return f"<MeasurementType: {self._name} {self.key!r} (alt: {keys})>"
+        return f"<MeasurementType: {self._name} {self.key!r})>"
+
+    def __eq__(self, other):
+        return str(self) == str(other)
+
+    def __hash__(self):
+        # For interoperability with 'key' strings (as dict keys, etc.)
+        # e.g., `mtype == "acc"`
+        return hash(self._key)
+
+    def __add__(self, other):
+        # Concatenates as strings, so query-like sequences can be built.
+        return f"{self} {other}"
+
+    def __radd__(self, other):
+        # Concatenates as strings, so query-like sequences can be built.
+        return f"{other} {self}"
+
+    def __sub__(self, other):
+        # Concatenates as strings (with this one's negated)
+        return f"{self} -{other}"
+
+    def __rsub__(self, other):
+        # Concatenates as strings (with this one's negated)
+        return f"{other} -{self}"
+
+    def __or__(self, other):
+        # Same as __add__(), but convenient for those used to using bitwise OR to combine flags
+        return self + other
+
+    def __neg__(self):
+        # Negates (appends a `-`) so query-like strings can be built.
+        return f"-{self.key}"
+
+    def __getitem__(self, *args, **kwargs):
+        # For basic required string interoperability
+        return self._key.__getitem__(*args, **kwargs)
+
+    def upper(self):
+        # For basic required string interoperability
+        return self._key.upper()
+
+    def lower(self):
+        # For basic required string interoperability
+        return self._key.lower()
+
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def key(self):
+        return self._key
+
+    def match(self, channel):
+        """ Does the given object match this measurement type?
+
+            :param channel: The object to test. Can be an `idelib.dataset.Channel`,
+                `idelib.dataset.SubChannel`, or a or a string of a measurement
+                type name.
+            :return: `True` if the given object uses this measurement type.
+                In the case of `idelib.dataset.Channel`, `True` is returned
+                if any of its subchannels match.
+        """
+        if getattr(channel, '_measurementType', channel) == self:
+            return True
+
+        if isinstance(channel, Channel):
+            if not isinstance(channel, SubChannel):
+                return any(self.match(c) for c in channel.children)
+            else:
+                mt = channel.units[0]
+        elif isinstance(channel, str):
+            mt = channel
+        else:
+            raise TypeError(f"Cannot compare measurement types with {channel} ({type(channel)})")
+
+        mt = mt.lower()
+        return any(label in mt or fnmatch(mt, label) for label in self._labels)
+
+# ============================================================================
+#
+# ============================================================================
+
+
+ANY = MeasurementType("Any/all", "*", "any", "all",
+    doc="Marker object for matching any/all measurement types",
+    labels=("*",))
+
+ACCELERATION = MeasurementType("Acceleration", "acc", "g",
+    doc="Marker object for filtering channels with acceleration data",
+    labels=())
+ORIENTATION = MeasurementType("Orientation", "imu", "qua", "gyr",
+    doc="Marker object for filtering channels with rotation/orientation data",
+    labels=("quaternion", "euler", "orientation"))
+ROTATION = MeasurementType("Rotation", "rot", "ang",
+    doc="Marker object for filtering channels with angular change rate data",
+    labels=("rotation", "gyro"))
+AUDIO = MeasurementType("Audio", "mic",
+    doc="Marker object for filtering channels with sound level data",
+    labels=("mic",))
+LIGHT = MeasurementType("Light", "lux",
+    doc="Marker object for filtering channels with light intensity data",
+    labels=("lux", "uv"))
+PRESSURE = MeasurementType("Pressure",
+    doc="Marker object for filtering channels with air pressure data",
+    labels=())  # pressures
+TEMPERATURE = MeasurementType("Temperature",
+    doc="Marker object for filtering channels with temperature data",
+    labels=())  # temperature
+HUMIDITY = MeasurementType("Relative Humidity", "hum",
+    doc="Marker object for filtering channels with (relative) humidity data",
+    labels=())  # Humidity
+LOCATION = MeasurementType("Location", "pos", "gps",
+    doc="Marker object for filtering channels with location data",
+    labels=("pos",))  # GPS
+SPEED = MeasurementType("Speed",
+    doc="Marker object for filtering channels with rate-of-speed data",
+    labels=("velocity",))  # GPS Ground Speed
+TIME = MeasurementType("Time", "epo",
+    doc="Marker object for filtering channels with time data",
+    labels=("epoch",))  # GPS Epoch Time
+
+# For potential future use
+GENERIC = MeasurementType("Generic/Unspecified", "adc", "raw",
+    labels=("adc", "raw"))
+ALTITUDE = MeasurementType("Altitude",
+    doc="Marker object for filtering channels with altitude data",
+    labels=())
+VOLTAGE = MeasurementType("Voltage",
+    doc="Marker object for filtering channels with voltmeter data",
+    labels=("volt",))
+DIRECTION = MeasurementType("Direction",
+    doc="Marker object for filtering channels with 2D directional data",
+    labels=("compass", "heading"))
+MAGNETIC = MeasurementType("Magnetic Field", "emf",
+    doc="Marker object for filtering channels with magnetic field strength data",
+    labels=("emf", "magnetic"))
+FREQUENCY = MeasurementType("Frequency",
+    doc="Marker object for filtering channels with frequency data",
+    labels=("rate",))
+
+# Synonyms, for convenience.
+# TODO: Include abbreviations (e.g., TEMP = TEMPERATURE) for convenience? Current names are long.
+ADC = GENERIC
+ANG_RATE = ROTATION
+GYRO = ROTATION
+RAW = GENERIC
+QUATERNION = ORIENTATION
+
+
+# ============================================================================
+#
+# ============================================================================
+
+def get_measurement_type(channel):
+    """ Get the appropriate `MeasurementType` object for a given `SubChannel`.
+        Calling with a `Channel` returns a list of `MeasurementType` objects,
+        with one for each child `SubChannel`.
+
+        :param channel: A `Channel` or `SubChannel` instance (e.g., from a
+            `Dataset`).
+        :returns: A `MeasurementType` object (for a `SubChannel`), or a list
+            of `MeasurentType` objects (one for each child) if a `Channel`
+            was supplied.
+    """
+    mt = getattr(channel, "_measurementType", None)
+    if mt:
+        return mt
+
+    if channel.children:
+        # Note: this caches the MeasurementType in the Channel/SubChannel; the
+        # attribute `_measurementType` must be settable
+        channel._measurementType = [get_measurement_type(c) for c in channel.children]
+        return channel._measurementType
+    else:
+        for m in MeasurementType.types.values():
+            if m == ANY:
+                continue
+            if m.match(channel):
+                channel._measurementType = m
+                return m
+
+    return None
+
+
+def split_types(query):
+    """ Split a string of multiple `MeasurementType` keys (e.g., one generated
+        by using addition or subtraction of `MeasurementType` objects and/or
+        strings).
+
+        :param query: A `MeasurementType` or a string containing multiple
+            `MeasurementType` keys. A key can be excluded by prefixing it with
+            a ``-``.
+        :returns: A pair of sets of `MeasurementType` instances: ones to
+            include, and ones to exclude.
+    """
+    query = str(query).lower().strip()
+    if query == "*":
+        return set(MeasurementType.types.values()), set()
+
+    inc = set()
+    exc = set()
+    prev = None
+
+    # NOTE: Casting to string and parsing isn't always required, but this
+    #   isn't used often enough to require high performance optimization.
+    for token in shlex(query):
+        token = token.lower()[:3]
+        if token in MeasurementType.types:
+            if prev == "-":
+                exc.add(MeasurementType.types[token])
+            else:
+                inc.add(MeasurementType.types[token])
+        elif token not in "+-":
+            raise TypeError(f"Unknown measurement type: {token!r}")
+        prev = token
+    return inc, exc
+
+
+def filter_channels(channels, measurement_type=ANY):
+    """ Filter a list of `Channel` and/or `SubChannel` instances by their
+        measurement type(s).
+
+        :param channels: A list or dictionary of channels/subchannels to filter.
+        :param measurement_type: A `MeasurementType`, a measurement type 'key'
+            string, or a string of multiple keys generated by adding and/or
+            subtracting `MeasurementType` objects. Any 'subtracted' types
+            will be excluded.
+    """
+    # Note: This is separated from `get_channels()` so it can be used
+    # elsewhere (i.e., to retrieve channels by type from a `Recorder`)
+    if isinstance(channels, dict):
+        channels = channels.values()
+
+    if measurement_type == ANY:
+        return list(channels)
+
+    # Note: if no `inc`, only `exc` is used
+    inc, exc = split_types(measurement_type)
+
+    result = []
+    for ch in channels:
+        thisType = get_measurement_type(ch)
+
+        if isinstance(thisType, (list, set, tuple)):
+            # `ch` is a Channel with SubChannels
+            if exc.intersection(thisType):
+                # One or more subchannels excluded; exclude channel.
+                continue
+            elif not inc or inc.intersection(thisType):
+                result.append(ch)
+                continue
+
+        elif thisType not in exc:
+            if not inc or thisType in inc:
+                result.append(ch)
+
+    return result
+
+
+def get_channels(dataset, measurement_type=ANY, subchannels=True):
+    """ Get a list of `Channel` or `SubChannel` instances from a `Dataset` by
+        their measurement type(s).
+
+        :param dataset: The `Dataset` from which to retrieve the list.
+        :param measurement_type: A `MeasurementType`, a measurement type 'key'
+            string, or a string of multiple keys generated by adding and/or
+            subtracting `MeasurementType` objects. Any 'subtracted' types
+            will be excluded.
+        :param subchannels: If `False`, get only `Channel` objects. If `True`,
+            get only `SubChannel` objects.
+        :returns: A list of matching `SubChannel` instances from the `Dataset`.
+    """
+    # This is really simple; it is a convenience for users, particularly
+    # novices for whom the use of `getPlots()` may not be obvious.
+    # TODO: Should there only be `filter_channels()`, and have it do `if isinstance(dataset, Dataset):`?
+    #   The `subchannels` argument isn't applicable to non-Datasets, however, which would be weird.
+    if subchannels:
+        return filter_channels(dataset.getPlots(sort=False), measurement_type)
+    return filter_channels(dataset.channels, measurement_type)
diff --git a/endaq/ide/util.py b/endaq/ide/util.py
new file mode 100644
index 0000000..c8df759
--- /dev/null
+++ b/endaq/ide/util.py
@@ -0,0 +1,54 @@
+"""
+Some general-purpose IDE file manipulation funcions.
+"""
+
+from ebmlite import loadSchema
+
+__all__ = ['validate']
+
+
+# ============================================================================
+#
+# ============================================================================
+
+def validate(stream, from_pos=False, lookahead=25, percent=.5):
+    """
+    Determine if a stream contains IDE data.
+
+    :param stream: A file-like stream (something that supports the methods
+        `tell()` and `seek()`).
+    :param from_pos: If `True`, validation of the stream will start at its
+        current position. If `False` (default), the validation will start
+        from its beginning.
+    :param lookahead: The number of EBML elements to check.
+    :param percent: The minumum percentage of EBML elements identified as
+        being part of the IDE schema for validation. A small number of
+        unknown elements may not indicate an invalid file; it may simply
+        have been created using a different version of the schema.
+    :return: `True` if validation passed, `False` if it failed.
+    """
+    # TODO: Make validation more thorough, test for corrupt files?
+
+    orig_pos = stream.tell()
+    if not from_pos:
+        stream.seek(0)
+
+    try:
+        schema = loadSchema('mide_ide.xml')
+        doc = schema.load(stream, headers=True)
+
+        # Basic test: is it EBML data in the expected schema?
+        known = 0
+        for idx, el in enumerate(doc):
+            if idx >= lookahead:
+                break
+            if el.id in schema.elements:
+                known += 1
+        if known < lookahead * percent:
+            return False
+
+        return True
+
+    finally:
+        stream.seek(orig_pos)
+
diff --git a/endaq/plot/README.md b/endaq/plot/README.md
new file mode 100644
index 0000000..2eb93e4
--- /dev/null
+++ b/endaq/plot/README.md
@@ -0,0 +1,51 @@
+# endaq-plot - Painless Plotting Of Sensor Data
+
+endaq-plot is a module comprising a collection of plotting utilities for sensor data analysis. It leverages Plotly in order to produce interactive plots, and makes creating powerful visualizations simple and easy for those new to Python.
+
+endaq-plot is a sub-module of the larger enDAQ ecosystem. See [the endaq package](https://github.com/MideTechnology/endaq-python) for more details.
+
+## 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).
+
+```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
+
+```python
+set_theme(theme='endaq')
+```
+
+### Creating Spectrograms With Octave Spaced Frequencies
+
+```python
+data_df, fig = octave_spectrogram(df, window=.15)
+fig.show()
+```
+
+![Spectrogram With Octave Spaced Frequencies](https://i.imgur.com/929aszu.png)
+
+### Creating PSD Bar Plots With Octave Spaced Frequencies
+
+```python
+fig = octave_psd_bar_plot(df, yaxis_title="Magnitude")
+fig.show()
+```
+
+![PSD Bar Plot With Octave Spaced Frequencies](https://i.imgur.com/ueqcVTQ.png)
+
+### Plot Attributes In Figure With Subplots
+
+```Python
+fig = multi_file_plot_attributes(attribute_df)
+fig.show()
+```
+
+![Attributes Plotted As Subplots](https://i.imgur.com/5Yy4DN7.png)
+
+## Other Links
+- the endaq package - https://github.com/MideTechnology/endaq-python
+- the enDAQ homepage - https://endaq.com/
diff --git a/endaq/plot/__init__.py b/endaq/plot/__init__.py
new file mode 100644
index 0000000..ce4b930
--- /dev/null
+++ b/endaq/plot/__init__.py
@@ -0,0 +1,2 @@
+from .plots import *
+from .t_sne import get_tsne_plot
diff --git a/endaq/plot/dashboards.py b/endaq/plot/dashboards.py
new file mode 100644
index 0000000..bdf7261
--- /dev/null
+++ b/endaq/plot/dashboards.py
@@ -0,0 +1,504 @@
+from __future__ import annotations
+
+from collections import Container
+import numpy as np
+import pandas as pd
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+import plotly.io as pio
+from typing import Optional
+
+
+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
+) -> go.Figure:
+    """
+    A function to create a Plotly Figure with sub-plots for each of the available data sub-channels, designed to reduce
+    the number of points/data being plotted without minimizing the insight available from the plots.  It will plot
+    either an envelope for rolling windows of the data (plotting the max and the min as line plots), or a bar based
+    plot where the top of the bar (rectangle) is the highest value in the time window that bar spans, and the bottom of
+    the bar is the lowest point in that time window (choosing between them is done with the `plot_as_bars` parameter).
+
+
+    :param channel_df_dict: A dictionary mapping channel names to Pandas DataFrames of that channels data
+    :param desired_num_points: The desired number of points to be plotted in each subplot.  The number of points
+     will be reduced from its original sampling rate by applying metrics (e.g. min, max) over sliding windows
+     and then using that information to represent/visualize the data contained within the original data.  If less than
+     the desired number of points are present, then a sliding window will NOT be used, and instead the points will be
+     plotted as they were originally recorded  (also the subplot will NOT be plotted as a bar based plot even if
+     `plot_as_bars` was set to true).
+    :param num_rows: The number of columns of subplots to be created inside the Plotly figure. If `None` is given, (then
+     `num_cols` must not be `None`), then this number will automatically be determined by what's needed.  If more rows
+     are specified than are needed, the number of rows will be reduced to the minimum needed to contain all the subplots
+    :param num_cols: The number of columns of subplots to be created inside the Plotly figure.  See the description of
+     the `num_rows` parameter for more details on this parameter, and how the two interact.  This also follows the same
+     approach to handling `None` when given
+    :param width_for_subplot_row: The width of the area used for a single subplot (in pixels).
+    :param height_for_subplot_row: The height of the area used for a single subplot (in pixels).
+    :param subplot_colors: An "array-like" object of strings containing colors to be cycled through for the subplots.
+     If `None` is given (which is the default), then the `colorway` variable in Plotly's current theme/template will
+     be used to color the data on each of the subplots uniquely, repeating from the start of the `colorway` if
+     all colors have been used.
+    :param min_points_to_plot: The minimum number of data points required to be present to create a subplot for a
+     channel/subchannel (NOT including `NaN` values).
+    :param plot_as_bars: A boolean value indicating if the plot should be visualized as a set of rectangles, where a
+     shaded rectangle is used to represent the maximum and minimum values of the data during the time window
+     covered by the rectangle.  These maximum and minimum values are visualized by the locations of the top and bottom
+     edges of the rectangle respectively, unless the height of the rectangle would be 0, in which case a line segment
+     will be displayed in its place.  If this parameter is `False`, two lines will be plotted for each
+     of the subplots in the figure being created, creating an "envelope" around the data.  An "envelope" around the
+     data consists of a line plotted for the maximum values contained in each of the time windows, and another line
+     plotted for the minimum values.  Together these lines create a boundary which contains all the data points
+     recorded in the originally recorded data.
+    :param plot_full_single_channel: If instead of a dashboard of subplots a single plot with multiple sub-channels
+     should be created.  If this is True, only one (key, value) pair can be given for the `channel_df_dict` parameter
+    :param opacity: The opacity to use for plotting bars/lines
+    :param y_axis_bar_plot_padding: Due to some unknown reason the bar subplots aren't having their y axis ranges
+     automatically scaled so this is the ratio of the total y-axis data range to pad both the top and bottom of the
+     y axis with.  The default value is the one it appears Plotly uses as well.
+    :return: The Plotly Figure containing the subplots of sensor data (the 'dashboard')
+    """
+    if not (num_rows is None or isinstance(num_rows, (int, np.integer))):
+        raise TypeError(f"`num_rows` is of type `{type(num_rows)}`, which is not allowed.  " 
+                        "`num_rows` can either be `None` or some type of integer.")
+    elif not (num_cols is None or isinstance(num_cols, (int, np.integer))):
+        raise TypeError(f"`num_cols` is of type `{type(num_cols)}`, which is not allowed.  "
+                        "`num_cols` can either be `None` or some type of integer.")
+
+    # I'm pretty sure the below is correct and it appears to work correctly, but it'd be nice if someone could
+    # double check this logic
+
+    # This removes any channels with less than `min_points_to_plot` data points per sub-channel, and removes any
+    # sub-channels which have less than `min_points_to_plot` non-NaN data points
+    channel_df_dict = {
+        k: v.drop(columns=v.columns[v.notna().sum(axis=0) < min_points_to_plot])
+            for (k, v) in channel_df_dict.items() if v.shape[0] >= min_points_to_plot}
+
+    subplot_titles = [' '.join((k, col)) for (k, v) in channel_df_dict.items() for col in v.columns]
+
+    if num_rows is None and num_cols is None:
+        raise TypeError("Both `num_rows` and `num_columns` were given as `None`!  "
+                        "A maximum of one of these two parameters may be given as None.")
+    elif num_rows is None:
+        num_rows = 1 + (len(subplot_titles) - 1) // num_cols
+    elif num_cols is None:
+        num_cols = 1 + (len(subplot_titles) - 1) // num_rows
+    elif len(subplot_titles) > num_rows * num_cols:
+        raise ValueError("The values given for `num_rows` and `num_columns` result in a maximum "
+                        f"of {num_rows * num_cols} avaialable sub-plots, but {len(subplot_titles)} subplots need "
+                        "to be plotted!   Try setting one of these variables to `None`, it will then "
+                        "automatically be set to the optimal number of rows/columns.")
+    else:
+        num_rows = 1 + (len(subplot_titles) - 1) // num_cols
+        num_cols = int(np.ceil(len(subplot_titles)/num_rows))
+
+    if subplot_colors is None:
+        colorway = pio.templates[pio.templates.default]['layout']['colorway']
+    else:
+        colorway = subplot_colors
+
+    if plot_full_single_channel:
+        if len(channel_df_dict) != 1:
+            raise ValueError("The 'channel_df_dict' parameter must be length 1 when "
+                             "'plot_full_single_channel' is set to true!")
+
+        num_rows = 1
+        num_cols = 1
+        fig = go.Figure(layout_title_text=list(channel_df_dict.keys())[0])
+    else:
+        fig = make_subplots(
+            rows=num_rows,
+            cols=num_cols,
+            subplot_titles=subplot_titles,
+            figure=go.Figure(
+                layout_height=height_for_subplot_row * num_rows,
+                layout_width=width_for_subplot_row * num_cols,
+            ),
+        )
+
+    # A counter to keep track of which subplot is currently being worked on
+    subplot_num = 0
+
+    # A dictionary to be used to modify the Plotly Figure layout all at once after all the elements have been added
+    layout_changes_to_make = {}
+
+    for channel_data in channel_df_dict.values():
+        window = int(np.around((channel_data.shape[0]-1) / desired_num_points, decimals=0))
+
+        # If a window size of 1 is determined, it sets the stride to 1 so we don't get an error as a result of the
+        # 0 length stride
+        stride = 1 if window == 1 else window - 1
+
+        rolling_n = channel_data.rolling(window)
+        min_max_tuple = (rolling_n.min()[::stride], rolling_n.max()[::stride])
+        min_max_equal = min_max_tuple[0] == min_max_tuple[1]
+
+        is_nan_min_max_mask = np.logical_not(
+            np.logical_and(
+                pd.isnull(min_max_tuple[0]),
+                pd.isnull(min_max_tuple[1])))
+
+        # If it's going to be plotted as bars, force its time stamps to be uniformly spaced
+        # so that the bars don't have any discontinuities in the X-axis
+        if len(channel_data) >= desired_num_points and plot_as_bars:
+            if isinstance(channel_data.index, pd.core.indexes.datetimes.DatetimeIndex):
+                new_index = pd.date_range(
+                    channel_data.index.values[0],
+                    channel_data.index.values[-1],
+                    periods=len(channel_data),
+                )
+            else:
+                new_index = np.linspace(
+                    channel_data.index.values[0],
+                    channel_data.index.values[-1],
+                    num=len(channel_data),
+                )
+
+            channel_data.set_index(new_index)
+
+        # Loop through each of the sub-channels, and their respective '0-height rectangle mask'
+        for subchannel_name, cur_min_max_equal in min_max_equal[channel_data.columns].iteritems():
+
+            traces = []
+            cur_color = colorway[subplot_num % len(colorway)]
+            cur_subchannel_non_nan_mask = is_nan_min_max_mask[subchannel_name]
+
+            # If there are less data points than the desired number of points
+            # to be plotted, just plot the data as a line plot
+            if len(channel_data) < desired_num_points:
+                not_nan_mask = np.logical_not(pd.isnull(channel_data[subchannel_name]))
+                traces.append(
+                    go.Scatter(
+                        x=channel_data.index[not_nan_mask],
+                        y=channel_data.loc[not_nan_mask, subchannel_name],
+                        name=subchannel_name,
+                        opacity=opacity,
+                        line_color=cur_color,
+                        showlegend=plot_full_single_channel,
+                    )
+                )
+            # If it's going to plot the data with bars
+            elif plot_as_bars:
+                # If there are any 0-height rectangles
+                if np.any(cur_min_max_equal):
+                    equal_data_df = min_max_tuple[0].loc[cur_min_max_equal.values, subchannel_name]
+
+                    # Half of the sampling period
+                    half_dt = np.diff(cur_min_max_equal.index[[0, -1]])[0] / (2 * (len(cur_min_max_equal) - 1))
+
+                    # Initialize the arrays we'll use for creating line segments where
+                    # rectangles would have 0 width so it will end up formatted as follows
+                    # (duplicate values for y since line segements are horizontal):
+                    # x = [x1, x2, None, x3, x4, None, ...]
+                    # y = [y12, y12, None, y34, y34, None, ...]
+                    x_patch_line_segs = np.repeat(equal_data_df.index.values, 3)
+                    y_patch_line_segs = np.repeat(equal_data_df.values, 3)
+
+                    # All X axis values are the same, but these values are supposed to represent pairs of start and end
+                    # times for line segments, so the time stamp is shifted half its duration backwards for the start
+                    # time, and half its duration forward for the end time
+                    x_patch_line_segs[::3] -= half_dt
+                    x_patch_line_segs[1::3] += half_dt
+
+                    # This is done every third value so that every two pairs of points is unconnected from eachother,
+                    # since the (None, None) point will not connect to either the point before it nor behind it
+                    x_patch_line_segs[2::3] = None
+                    y_patch_line_segs[2::3] = None
+
+                    traces.append(
+                        go.Scatter(
+                            x=x_patch_line_segs,
+                            y=y_patch_line_segs,
+                            name=subchannel_name,
+                            opacity=opacity,
+                            mode='lines',
+                            line_color=cur_color,
+                            showlegend=plot_full_single_channel,
+                        )
+                    )
+
+                min_data_point = np.min(min_max_tuple[0][subchannel_name])
+                max_data_point = np.max(min_max_tuple[1][subchannel_name])
+
+                y_padding = (max_data_point - min_data_point) * y_axis_bar_plot_padding
+
+                traces.append(
+                    go.Bar(
+                        x=min_max_tuple[0].index[cur_subchannel_non_nan_mask],
+                        y=(min_max_tuple[1].loc[cur_subchannel_non_nan_mask, subchannel_name] -
+                           min_max_tuple[0].loc[cur_subchannel_non_nan_mask, subchannel_name]),
+                        marker_color=cur_color,
+                        opacity=opacity,
+                        marker_line_width=0,
+                        base=min_max_tuple[0].loc[cur_subchannel_non_nan_mask, subchannel_name],
+                        showlegend=plot_full_single_channel,
+                        name=subchannel_name,
+                    )
+                )
+
+                # Adds a (key, value) pair to the dict for setting this subplot's Y-axis display range (applied later)
+                min_y_range = min_data_point - y_padding
+                max_y_range = max_data_point + y_padding
+                y_axis_id = f'yaxis{1 + subplot_num}_range'
+                if plot_full_single_channel:
+                    y_axis_id = 'yaxis_range'
+                    if layout_changes_to_make:
+                        min_y_range = min(min_y_range, layout_changes_to_make[y_axis_id][0])
+                        max_y_range = max(max_y_range, layout_changes_to_make[y_axis_id][1])
+
+                layout_changes_to_make[y_axis_id] = [min_y_range, max_y_range]
+            else:
+                for cur_df in min_max_tuple:
+                    traces.append(
+                        go.Scatter(
+                            x=cur_df.index[cur_subchannel_non_nan_mask],
+                            y=cur_df.loc[cur_subchannel_non_nan_mask, subchannel_name],
+                            name=subchannel_name,
+                            opacity=opacity,
+                            line_color=cur_color,
+                            showlegend=plot_full_single_channel,
+                        )
+                    )
+
+            # Add the traces created for the current subchannel of data to the plotly figure
+            if plot_full_single_channel:
+                fig.add_traces(traces)
+            else:
+                fig.add_traces(
+                    traces,
+                    rows=1 + subplot_num // num_cols,
+                    cols=1 + subplot_num % num_cols,
+                )
+
+            subplot_num += 1
+
+    fig.update_layout(
+        **layout_changes_to_make,
+        bargap=0,
+        barmode='overlay'
+    )
+    return fig
+
+
+def rolling_metric_dashboard(channel_df_dict: dict, desired_num_points: int = 250, num_rows: Optional[int] = None,
+             num_cols: Optional[int] = 3, rolling_metrics_to_plot: tuple = ('mean', 'std'),
+             metric_colors: Optional[Container] = None, width_for_subplot_row: int = 400,
+             height_for_subplot_row: int = 400) -> go.Figure:
+    """
+    A function to create a dashboard of subplots of the given data, plotting a set of rolling metrics.
+
+    :param channel_df_dict: A dictionary mapping channel names to Pandas DataFrames of that channels data
+    :param desired_num_points:  The desired number of points to be plotted in each subplot.  The number of points
+     will be reduced from its original sampling rate by applying metrics (e.g. min, max) over sliding windows
+     and then using that information to represent/visualize the data contained within the original data.  If less than
+     the desired number of points are present, then a sliding window will NOT be used, and instead the points will be
+     plotted as they were originally recorded  (also the subplot will NOT be plotted as a bar based plot even if
+     `plot_as_bars` was set to true).
+    :param num_rows: The number of columns of subplots to be created inside the Plotly figure. If `None` is given, (then
+     `num_cols` must not be `None`), then this number will automatically be determined by what's needed.  If more rows
+     are specified than are needed, the number of rows will be reduced to the minimum needed to contain all the subplots
+    :param num_cols: The number of columns of subplots to be created inside the Plotly figure.  See the description of
+     the `num_rows` parameter for more details on this parameter, and how the two interact.  This also follows the same
+     approach to handling `None` when given
+    :param rolling_metrics_to_plot: A tuple of strings which indicate what rolling metrics to plot for each subchannel.
+     The options are ['mean', 'std', 'absolute max', 'rms'] which correspond to the mean, standard deviation, maximum of
+     the absolute value, and root-mean-square.
+    :param metric_colors: An "array-like" object of strings containing colors to be cycled through for the metrics.
+     If `None` is given (which is the default), then the `colorway` variable in Plotly's current theme/template will
+     be used to color the metric data, repeating from the start of the `colorway` if all colors have been used.
+     The first value corresponds to the color if not enough points of data exist for a rolling metric,
+     and the others correspond to the metric in `rolling_metrics_to_plot` in the same order they are given
+    :param width_for_subplot_row: The width of the area used for a single subplot (in pixels).
+    :param height_for_subplot_row: The height of the area used for a single subplot (in pixels).
+    :return: The Plotly Figure containing the subplots of sensor data (the 'dashboard')
+    """
+    if len(rolling_metrics_to_plot) == 0:
+        raise ValueError("At least one rolling metric must be specified in `rolling_metrics_to_plot`!")
+
+    subplot_titles = [' '.join((k, col)) for (k, v) in channel_df_dict.items() for col in v.columns]
+
+    if num_rows is None and num_cols is None:
+        raise TypeError("Both `num_rows` and `num_columns` were given as `None`!  "
+                        "A maximum of one of these two parameters may be given as None.")
+    elif num_rows is None:
+        num_rows = 1 + (len(subplot_titles) - 1) // num_cols
+    elif num_cols is None:
+        num_cols = 1 + (len(subplot_titles) - 1) // num_rows
+    elif len(subplot_titles) > num_rows * num_cols:
+        raise ValueError("The values given for `num_rows` and `num_columns` result in a maximum "
+                         f"of {num_rows * num_cols} avaialable sub-plots, but {len(subplot_titles)} subplots need "
+                         "to be plotted!   Try setting one of these variables to `None`, it will then "
+                         "automatically be set to the optimal number of rows/columns.")
+    else:
+        num_rows = 1 + (len(subplot_titles) - 1) // num_cols
+        num_cols = int(np.ceil(len(subplot_titles)/num_rows))
+
+    if metric_colors is None:
+        colorway = pio.templates[pio.templates.default]['layout']['colorway']
+    else:
+        colorway = metric_colors
+    fig = make_subplots(rows=num_rows, cols=num_cols, subplot_titles=subplot_titles)
+    subplot_num = 0
+    for channel_name, channel_data in channel_df_dict.items():
+        n = int(channel_data.shape[0] / desired_num_points)
+
+        if n > 0:
+            time = channel_data.rolling(n).mean().iloc[::n].index
+        else:
+            time = channel_data.index
+
+        for c_i, (subchannel_name, subchannel_data) in enumerate(channel_data.iteritems()):
+            for metric in rolling_metrics_to_plot:
+                if n == 0:
+                    data = subchannel_data.values
+                    name = subchannel_name
+                    color = colorway[0]
+                elif metric == 'mean':
+                    data = subchannel_data.rolling(n).mean().iloc[::n]
+                    name = 'Mean'
+                    color = colorway[1]
+                elif metric == 'std':
+                    data = subchannel_data.rolling(n).std().iloc[::n]
+                    name = 'STD Dev'
+                    color = colorway[2]
+                elif metric == 'absolute max':
+                    data = subchannel_data.abs().rolling(n).max().iloc[::n]
+                    name = 'Max'
+                    color = colorway[3]
+                elif metric == 'rms':
+                    data = subchannel_data.pow(2).rolling(n).mean().apply(np.sqrt, raw=True).iloc[::n]
+                    name = 'RMS'
+                    color = colorway[4]
+                else:
+                    raise ValueError(f"metric given to `rolling_metrics_to_plot` is not valid!  Was given {metric}"
+                                     " which is not in the allowed options of ['mean', 'std', 'absolute max', 'rms']")
+                fig.add_trace(
+                    go.Scatter(
+                        x=time,
+                        y=data,
+                        name=name,
+                        line=dict(color=color)),
+                    row=1 + subplot_num // num_cols,
+                    col=1 + subplot_num % num_cols,
+                )
+
+                if n == 0:
+                    break
+
+            subplot_num += 1
+
+    return fig.update_layout(
+        width=num_cols * width_for_subplot_row,
+        height=num_rows * height_for_subplot_row,
+        showlegend=False,
+    )
+
+
+
+
+
+
+if __name__ == '__main__':
+    import endaq.ide
+    from utilities import set_theme
+    set_theme()
+
+    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']
+
+    for j in [4]:
+        doc = endaq.ide.get_doc(file_urls[j])
+        table = endaq.ide.get_channel_table(doc)
+
+        # (IMPORTANT NOTE) The use of this as a dictionary is dependent on it maintaining being 'insertion ordered',
+        # which is a thing in Python 3.7 (may have existed in a different way in python 3.6, but I'm not sure)
+        CHANNEL_DFS = {
+            doc.channels[ch].name: endaq.ide.to_pandas(doc.channels[ch], time_mode='seconds') for ch in doc.channels}
+
+        SINGLE_CHANNEL = r'40g DC Acceleration'
+        JUST_ACCEL_DFS = {SINGLE_CHANNEL: CHANNEL_DFS[SINGLE_CHANNEL]}
+
+        # Examples
+        rolling_enveloped_dashboard(
+            JUST_ACCEL_DFS,
+            plot_full_single_channel=True,
+        ).show()
+
+        rolling_enveloped_dashboard(
+            JUST_ACCEL_DFS,
+            plot_as_bars=True,
+            plot_full_single_channel=True,
+        ).show()
+
+        rolling_enveloped_dashboard(
+            CHANNEL_DFS,
+            desired_num_points=100,
+            min_points_to_plot=10,
+            plot_as_bars=True,
+            height_for_subplot_row=600,
+            width_for_subplot_row=600,
+            num_cols=2,
+            num_rows=None,
+        ).show()
+
+        rolling_enveloped_dashboard(
+            CHANNEL_DFS,
+            desired_num_points=1000,
+            num_rows=2,
+            num_cols=9999999,
+            width_for_subplot_row=250,
+            height_for_subplot_row=250,
+        ).show()
+
+        rolling_enveloped_dashboard(
+            CHANNEL_DFS,
+            desired_num_points=1000,
+            num_rows=9999999,
+            num_cols=2,
+            width_for_subplot_row=250,
+            height_for_subplot_row=250,
+        ).show()
+
+        rolling_enveloped_dashboard(
+            CHANNEL_DFS,
+            desired_num_points=1000,
+            min_points_to_plot=10,
+            plot_as_bars=True,
+        ).show()
+
+        rolling_enveloped_dashboard(
+            CHANNEL_DFS,
+            desired_num_points=1000,
+            min_points_to_plot=10,
+            num_rows=999999,
+            num_cols=4,
+        ).show()
+
+        rolling_metric_dashboard(
+            CHANNEL_DFS,
+            rolling_metrics_to_plot=('mean', 'absolute max', 'std'),
+        ).show()
+
+        print(str(j) + " done!")
diff --git a/endaq/plot/plots.py b/endaq/plot/plots.py
new file mode 100644
index 0000000..2de047f
--- /dev/null
+++ b/endaq/plot/plots.py
@@ -0,0 +1,458 @@
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+import plotly.graph_objects as go
+import plotly.express as px
+from plotly.subplots import make_subplots
+from scipy import signal
+from typing import Optional
+from collections import Container
+
+from endaq.calc import sample_spacing
+from endaq.calc.psd import to_octave, welch
+
+from .utilities import determine_plotly_map_zoom, get_center_of_coordinates
+from .dashboards import rolling_enveloped_dashboard
+
+__all__ = [
+    'multi_file_plot_attributes',
+    'general_get_correlation_figure',
+    'get_pure_numpy_2d_pca',
+    'gen_map',
+    'octave_spectrogram',
+    'octave_psd_bar_plot',
+    'rolling_min_max_envelope',
+    'around_peak',
+]
+
+DEFAULT_ATTRIBUTES_TO_PLOT_INDIVIDUALLY = np.array([
+    'accelerationPeakFull', 'accelerationRMSFull', 'velocityRMSFull', 'psuedoVelocityPeakFull',
+    'displacementRMSFull', 'gpsSpeedFull', 'gyroscopeRMSFull', 'microphonoeRMSFull',
+    'temperatureMeanFull', 'pressureMeanFull'])
+
+
+def multi_file_plot_attributes(multi_file_db: pd.DataFrame,
+                               attribs_to_plot: np.ndarray = DEFAULT_ATTRIBUTES_TO_PLOT_INDIVIDUALLY,
+                               recording_colors: Optional[Container] = None,
+                               width_per_subplot: int = 400) -> go.Figure:
+    """
+    Creates a Plotly figure plotting all the desired attributes from the given DataFrame.
+
+    :param multi_file_db: The Pandas DataFrame of data to plot attributes from
+    :param attribs_to_plot: A numpy ndarray of strings with the names of the attributes to plot
+    :param recording_colors: The colors to make each of the points (All will be the same color if None is given)
+    :param width_per_subplot: The width to make every subplot
+    :return: A Plotly figure of all the subplots desired to be plotted
+    """
+    if not isinstance(attribs_to_plot, np.ndarray):
+        raise TypeError(
+            "Instead of an ndarray given for 'attribs_to_plot', a variable of type %s was given" % str(type(attribs_to_plot)))
+
+    if len(attribs_to_plot) == 0:
+        raise ValueError("At least one value must be given for 'attribs_to_plot'")
+
+    if not all(isinstance(row, str) for row in attribs_to_plot):
+        raise TypeError("All rows to plot must be given as a string!")
+
+    should_plot_row = np.array([not multi_file_db[r].isnull().all() for r in attribs_to_plot])
+
+    attribs_to_plot = attribs_to_plot[should_plot_row]
+
+    fig = make_subplots(
+        rows=1,
+        cols=len(attribs_to_plot),
+        subplot_titles=attribs_to_plot,
+    )
+
+    for j, row_name in enumerate(attribs_to_plot):
+        fig.add_trace(
+            go.Scatter(
+                x=multi_file_db['recording_ts'],
+                y=multi_file_db[row_name],
+                name=row_name,
+                mode='markers',
+                text=multi_file_db['serial_number_id'].values,
+                marker_color=recording_colors,
+            ),
+            row=1,
+            col=j + 1,
+        )
+
+    return fig.update_layout(width=len(attribs_to_plot)*width_per_subplot, showlegend=False)
+
+
+def general_get_correlation_figure(merged_df: pd.DataFrame,
+                                   color_col: str = 'serial_number_id',
+                                   color_discrete_map: Optional[dict] = None,
+                                   hover_names: Optional[Container] = None,
+                                   characteristics_to_show_on_hover: list = [],
+                                   starting_cols: Container = None) -> go.Figure:
+    """
+    A function to create a plot with two drop-down menus, each populated with a set of options corresponding to the
+    scalar quantities contained in the given dataframe.   The data points will then be plotted with the X and Y axis
+    corresponding to the selected attributes from the drop-down menu.
+
+    :param merged_df: A Pandas DataFrame of data to use for producing the plot
+    :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 hover_names: The names of the points to display when they are hovered on
+    :param characteristics_to_show_on_hover: The set of characteristics of the data to display when hovered over
+    :param starting_cols: The two starting columns for the dropdown menus (will be the first two available
+     if None is given)
+    :return: The interactive Plotly figure
+    """
+    cols = [col for col, t in zip(merged_df.columns, merged_df.dtypes) if t != np.object]
+
+    point_naming_characteristic = merged_df.index if hover_names is None else hover_names
+
+    # This is not necessary, but usually produces easily discernible correlations or groupings of files/devices.
+    # The hope is that when the initial plot has these characteristics, it will encourage
+    # the exploration of this interactive plot.
+    start_dropdown_indices = [0, 1]
+    first_x_var = cols[0]
+    first_y_var = cols[1]
+    if starting_cols is not None and starting_cols[0] in cols and starting_cols[1] in cols:
+        for j, col_name in enumerate(cols):
+            if col_name == starting_cols[0]:
+                first_x_var, start_dropdown_indices[0] = col_name, j
+            if col_name == starting_cols[1]:
+                first_y_var, start_dropdown_indices[1] = col_name, j
+
+    # Create the scatter plot of the initially selected variables
+    fig = px.scatter(
+        merged_df,
+        x=first_x_var,
+        y=first_y_var,
+        color=color_col,
+        color_discrete_map=color_discrete_map,
+        hover_name=point_naming_characteristic,
+        hover_data=characteristics_to_show_on_hover,
+        # width=800,
+        # height=500,
+    )
+
+    # Create the drop-down menus which will be used to choose the desired file characteristics for comparison
+    drop_downs = []
+    for axis in ['x', 'y']:
+        drop_downs.append([
+            dict(
+                method="update",
+                args=[{axis: [merged_df[cols[k]]]},
+                      {'%saxis.title.text' % axis: cols[k]},
+                      # {'color': recording_colors},
+                      ],
+                label=cols[k]) for k in range(len(cols))
+        ])
+
+    # Sets up various apsects of the Plotly figure that is currently being produced.  This ranges from
+    # aethetic things, to setting the dropdown menues as part of the figure
+    fig.update_layout(
+        title_x=0.4,
+        # width=800,
+        # height=500,
+        showlegend=False,
+        updatemenus=[{
+            'active': start_j,
+            'buttons': drop_down,
+            'x': 1.125,
+            'y': y_height,
+            'xanchor': 'left',
+            'yanchor': 'top',
+        } for drop_down, start_j, y_height in zip(drop_downs, start_dropdown_indices, [1, .85])])
+
+    # Adds text labels for the two drop-down menus
+    for axis, height in zip(['X', 'Y'], [1.05, .9]):
+        fig.add_annotation(
+            x=1.1,  # 1.185,
+            y=height,
+            xref='paper',
+            yref='paper',
+            showarrow=False,
+            xanchor='left',
+            yanchor='top',
+            text="%s-Axis Measurement" % axis,
+        )
+    return fig
+
+
+def get_pure_numpy_2d_pca(df: pd.DataFrame,
+                          color_col: str = 'serial_number_id',
+                          color_discrete_map: Optional[dict] = None,
+                          ) -> go.Figure:
+    """
+    Get a Plotly figure of the 2D PCA for the given DataFrame.   This will have dropdown menus to select
+    which components are being used for the X and Y axis.
+
+    :param df: The dataframe of points to compute the PCA with
+    :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
+    :return: A plotly figure as described in the main function description
+
+    .. todo::
+     - Add type checking statements to ensure the given dataframe contains enough values of the desired type
+     - Add type checking statements to ensure the recording_colors given (if not None) are the proper length
+    """
+
+    # Drop all non-float64 type columns, and drop all columns with standard deviation of 0 because this will result
+    # in division by 0
+    X = df.loc[:, (df.std() != 0) & (np.float64 == df.dtypes)].dropna(axis='columns')
+
+    # Standardize the values (so that mean of each variable is 0 and standard deviation is 1)
+    X = (X - X.mean()) / X.std()
+
+    # Get the shape of the data to compute PCA for
+    n, m = X.shape
+
+    # Compute covariance matrix
+    covariance = np.dot(X.T, X) / (n - 1)
+
+    # Eigen decomposition
+    eigen_vals, eigen_vecs = np.linalg.eig(covariance)
+
+    # Project X onto PC space
+    X_pca = np.dot(X, eigen_vecs)
+
+    # Create a new DataFrame with column names describing the fact that the values are the principal components
+    pca_df = pd.DataFrame(X_pca)
+    pca_df.columns = (pca_df.columns + 1).map(lambda x: "Component %d" % x)
+
+    # Produce the Plotly figure for the points after being transfored with the PCA computed,
+    # with dropdown menus to allow selection of what PCA components are being analyzed
+    fig = general_get_correlation_figure(
+        pca_df,
+        color_col=df[color_col],
+        color_discrete_map=color_discrete_map,
+        characteristics_to_show_on_hover=[df.index],
+        hover_names=df.index
+    )
+
+    return fig
+
+
+def gen_map(df_map: pd.DataFrame, mapbox_access_token: str, filter_points_by_positive_groud_speed: bool = True,
+            color_by_column: str = "GNSS Speed: Ground Speed") -> go.Figure:
+    """
+    Plots GPS data on a map from a single recording, shading the points based some characteristic
+    (defaults to ground speed).
+    
+    :param df_map: The pandas dataframe containing the recording data.
+    :param mapbox_access_token: The access token (or API key) needed to be able to plot against
+     a map.
+    :param filter_points_by_positive_groud_speed: A boolean variable, which will filter
+     which points are plotted by if they have corresponding positive ground speeds.  This helps
+     remove points which didn't actually have a GPS location found (was created by a bug in the hardware I believe).
+    :param color_by_column: The dataframe column title to color the plotted points by.
+    """
+    if filter_points_by_positive_groud_speed:
+        df_map = df_map[df_map["GNSS Speed: Ground Speed"] > 0]
+    
+    zoom = determine_plotly_map_zoom(lats=df_map["Location: Latitude"], lons=df_map["Location: Longitude"])
+    center = get_center_of_coordinates(lats=df_map["Location: Latitude"], lons=df_map["Location: Longitude"])
+    
+    px.set_mapbox_access_token(mapbox_access_token)
+    
+    fig = px.scatter_mapbox(
+        df_map,
+        lat="Location: Latitude",
+        lon="Location: Longitude",
+        color=color_by_column,
+        size_max=15,
+        zoom=zoom - 1,
+        center=center,
+    )
+
+    return fig
+    
+
+def octave_spectrogram(df: pd.DataFrame, window: float, bins_per_octave: int = 3, freq_start: float = 20.0,
+                       max_freq: float = float('inf'), db_scale: bool = True, log_scale_y_axis: bool = True
+                       ) -> go.Figure:
+    """
+    Produces an octave spectrogram of the given data.
+
+    :param df: The dataframe of sensor data.  This must only have 1 column.
+    :param window: The time window for each of the columns in the spectrogram
+    :param bins_per_octave: The number of frequency bins per octave
+    :param freq_start: The center of the first frequency bin
+    :param max_freq: The maximum frequency to plot
+    :param db_scale: If the spectrogram should be log-scaled for visibility (with 10*log10(x))
+    :param log_scale_y_axis: If the y-axis of the plot should be log scaled
+    :return: a tuple containing:
+
+        - the frequency bins
+        - the time bins
+        - the spectrogram data
+        - the corresponding plotly figure
+    """
+    if len(df.columns) != 1:
+        raise ValueError("The parameter 'df' must have only one column of data!")
+
+    ary = df.values.squeeze()
+
+    fs = 1/sample_spacing(df)#(len(df) - 1) / (df.index[-1] - df.index[0])
+    N = int(fs * window) #Number of points in the fft
+    w = signal.blackman(N, False)
+    
+    freqs, bins, Pxx = signal.spectrogram(ary, fs, window=w, nperseg=N, noverlap=0)
+
+    time_dfs = [pd.DataFrame({bins[j]: Pxx[:, j]}, index=freqs) for j in range(len(bins))]
+
+    octave_dfs = list(map(lambda x: to_octave(x, freq_start, octave_bins=bins_per_octave, agg='sum'), time_dfs))
+    
+    combined_df = pd.concat(octave_dfs, axis=1)
+    
+    freqs = combined_df.index.values
+    Pxx = combined_df.values
+    
+    include_freqs_mask = freqs <= max_freq
+    Pxx = Pxx[include_freqs_mask]
+    freqs = freqs[include_freqs_mask]
+
+    if db_scale:
+        Pxx = 10 * np.log10(Pxx)
+
+    trace = [go.Heatmap(x=bins, y=freqs, z=Pxx, colorscale='Jet')]
+    layout = go.Layout(
+        yaxis={'title': 'Frequency (Hz)'},
+        xaxis={'title': 'Time (s)'},
+    )
+
+    fig = go.Figure(data=trace, layout=layout)
+    
+    if log_scale_y_axis:
+        fig.update_yaxes(type="log")
+
+    fig.update_traces(showscale=False)
+
+    data_df = pd.DataFrame(Pxx, index=freqs, columns=bins)
+
+    return data_df, fig
+    
+
+def octave_psd_bar_plot(df: pd.DataFrame, bins_per_octave: int = 3, f_start: float = 20.0, yaxis_title: str = '',
+                        log_scale_y_axis: bool = True) -> go.Figure:
+    """
+    Produces a bar plot of an octave psd.
+
+    :param df: The dataframe of sensor data
+    :param bins_per_octave: The number of frequency bins per octave
+    :param f_start: The center of the first frequency bin
+    :param yaxis_title: The text to label the y-axis
+    :param log_scale_y_axis: If the y-axis should be log scaled
+    """
+    psd_df = welch(df, 1, scaling='spectrum')
+
+    octave_psd_df = to_octave(
+        psd_df,
+        f_start, 
+        bins_per_octave,
+        agg='sum',
+    )
+
+    bar_widths = octave_psd_df.index * (2 ** (.5/bins_per_octave) - 2 ** (-.5/bins_per_octave))
+
+    bar = go.Bar(
+        x=octave_psd_df.index.values,
+        y=octave_psd_df.values.squeeze(),
+        width=bar_widths,
+    )
+    layout = go.Layout(
+        yaxis={'title': yaxis_title},
+        xaxis={'title': 'Frequency (Hz)'},
+    )
+
+    fig = go.Figure(data=bar, layout=layout)
+
+    if log_scale_y_axis:
+        fig.update_xaxes(type="log")
+
+    return fig
+
+
+def rolling_min_max_envelope(df: pd.DataFrame, desired_num_points: int = 250, plot_as_bars: bool = False,
+                             plot_title: str = "", opacity: float = 1,
+                             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
+    reduce the number of points/data being plotted without minimizing the insight available from the plots.  It will
+    plot either an envelope for rolling windows of the data (plotting the max and the min as line plots), or a bar based
+    plot where the top of the bar (rectangle) is the highest value in the time window that bar spans, and the bottom of
+    the bar is the lowest point in that time window (choosing between them is done with the `plot_as_bars` parameter).
+
+    :param df: The dataframe of sub-channel data indexed by time stamps
+    :param desired_num_points: The desired number of points to be plotted for each subchannel.  The number of points
+     will be reduced from it's original sampling rate by applying metrics (e.g. min, max) over sliding windows
+     and then using that information to represent/visualize the data contained within the original data.  If less than
+     the desired number of points are present, then a sliding window will NOT be used, and instead the points will be
+     plotted as they were originally recorded  (also the subchannel will NOT be plotted as a bar based plot even if
+     `plot_as_bars` was set to true).
+    :param plot_as_bars: A boolean value indicating if the data should be visualized as a set of rectangles, where a
+     shaded rectangle is used to represent the maximum and minimum values of the data during the time window
+     covered by the rectangle.  These maximum and minimum values are visualized by the locations of the top and bottom
+     edges of the rectangle respectively, unless the height of the rectangle would be 0, in which case a line segment
+     will be displayed in it's place.  If this parameter is `False`, two lines will be plotted for each
+     of the sub-channels in the figure being created, creating an 'envelope' around the data.  An 'envelope' around the
+     data consists of a line plotted for the maximum values contained in each of the time windows, and another line
+     plotted for the minimum values.  Together these lines create a boundary which contains all the data points
+     recorded in the originally recorded data.
+    :param plot_title: The title for the plot
+    :param opacity: The opacity to use for plotting bars/lines
+    :param colors_to_use: An "array-like" object of strings containing colors to be cycled through for the sub-channels.
+     If `None` is given (which is the default), then the `colorway` variable in Plotly's current theme/template will
+     be used to color the data on each of the sub-channels uniquely, repeating from the start of the `colorway` if
+     all colors have been used.
+    :return: The Plotly Figure with the data plotted
+    """
+
+    return rolling_enveloped_dashboard(
+        {plot_title: df},
+        desired_num_points=desired_num_points,
+        subplot_colors=colors_to_use,
+        plot_as_bars=plot_as_bars,
+        plot_full_single_channel=True,
+        opacity=opacity
+    )
+
+
+def around_peak(df: pd.DataFrame, num: int = 1000, leading_ratio: float = 0.5):
+    """
+    A function to plot the data surrounding the largest peak (or valley) in the given data.
+    The "peak" is defined by the point in the absolute value of the given data with the largest value.
+
+    :param df: A dataframe indexed by time stamps
+    :param num: The number of points to plot
+    :param leading_ratio: The ratio of the data to be viewed that will come before the peak
+    :return: A Plotly figure containing the plot
+    """
+    if not isinstance(df, pd.DataFrame):
+        raise TypeError(f"The `df` parmeter must be of type `pd.DataFrame` but was given type {type(df)} instead.")
+
+    if not isinstance(num, int):
+        raise TypeError(f"The `num` parameter must be an `int` type, but was given {type(num)}.")
+
+    if not isinstance(leading_ratio, float):
+        raise TypeError(f"The `leading_ratio` parameter must be a `float` type, but was given {type(leading_ratio)}.")
+
+    if len(df) == 0:
+        raise ValueError(f"The parameter `df` must have nonzero length, but has shape {df.shape} instead")
+
+    if num < 3:
+        raise ValueError(f"The `num` parameter must be at least 3, but {num} was given.")
+
+    if leading_ratio < 0 or leading_ratio > 1:
+        raise ValueError("The `leading_ratio` parameter must be a float value in the "
+                         f"range [0,1], but was given {leading_ratio} instead.")
+
+    max_i = df.abs().max(axis=1).reset_index(drop=True).idxmax()
+
+    # These can go below and above the number of valid indices, but that can be ignored since
+    # they'll only be used to slice the data in a way that is okay to go over/under
+    window_start = max(0, max_i - int(num * leading_ratio))
+    window_end = min(len(df), max_i + int(num * (1-leading_ratio)))
+
+    return px.line(df.iloc[window_start: window_end])
diff --git a/endaq/plot/t_sne.py b/endaq/plot/t_sne.py
new file mode 100644
index 0000000..7b2c361
--- /dev/null
+++ b/endaq/plot/t_sne.py
@@ -0,0 +1,166 @@
+import numpy as np
+import pandas as pd
+from typing import Optional
+import plotly.express as px
+import plotly.graph_objects as go
+
+
+def neg_squared_euc_dists(ary: np.ndarray):
+    """
+    Compute matrix containing negative squared euclidean distance for all pairs of points in input matrix X
+    (Implementation based around the following: https://nlml.github.io/in-raw-numpy/in-raw-numpy-t-sne/)
+    (Math? See https://stackoverflow.com/questions/37009647)
+
+    :param ary: A matrix of size NxD
+    :return: An NxN matrix D, with entry D_ij = negative squared euclidean distance between rows X_i and
+    """
+    #
+    ary_sum = np.sum(np.square(ary), 1)
+    return - np.add(np.add(-2 * np.dot(ary, ary.T), ary_sum).T, ary_sum)
+
+
+def softmax(ary: np.ndarray, diag_zero: bool = True, zero_index: Optional[int] = None):
+    """
+    Compute softmax values for each row of matrix X.
+    (Implementation based around the following: https://nlml.github.io/in-raw-numpy/in-raw-numpy-t-sne/)
+    """
+
+    # Subtract max for numerical stability
+    e_x = np.exp(ary - np.max(ary, axis=1).reshape([-1, 1]))
+
+    # We usually want diagonal probabilities to be 0.
+    if zero_index is None:
+        if diag_zero:
+            np.fill_diagonal(e_x, 0.)
+    else:
+        e_x[:, zero_index] = 0.
+
+    # Add a tiny constant for stability of log we take later
+    # e_x = e_x + 1e-8  # numerical stability
+    e_x += 1e-8  # for numerical stability
+
+    return e_x / e_x.sum(axis=1).reshape([-1, 1])
+
+
+def calc_prob_matrix(distances: np.ndarray, sigmas: Optional[np.ndarray] = None, zero_index: Optional[int] = None):
+    """
+    Convert a distances matrix to a matrix of probabilities.
+    (Implementation based around the following: https://nlml.github.io/in-raw-numpy/in-raw-numpy-t-sne/)
+    """
+    two_sig_sq = 2. * np.square(sigmas.reshape((-1, 1)))
+    return softmax(distances / two_sig_sq, zero_index=zero_index)
+
+
+def find_optimal_sigmas(distances: np.ndarray, target_perplexity: int, max_iterations: int = 10000) -> np.ndarray:
+    """
+    For each row of distances matrix, find sigma that results
+    in target perplexity for that role.
+    (Implementation based around the following: https://nlml.github.io/in-raw-numpy/in-raw-numpy-t-sne/)
+    """
+    sigmas = []
+    # For each row of the matrix (each point in our dataset)
+    for i in range(distances.shape[0]):
+        # Binary search over sigmas to achieve target perplexity
+        lower_binary_search = 1e-20
+        upper_binary_search = 1000.
+        for _ in range(max_iterations):
+            guess = (lower_binary_search + upper_binary_search) / 2.
+            prob_mat = calc_prob_matrix(distances[i:i+1, :], np.array(guess), i)
+            val = 2 ** -np.sum(prob_mat * np.log2(prob_mat), 1)
+
+            if val > target_perplexity:
+                upper_binary_search = guess
+            else:
+                lower_binary_search = guess
+            if np.abs(val - target_perplexity) <= 1e-10:
+                break
+
+        correct_sigma = guess
+
+        # Append the resulting sigma to our output array
+        sigmas.append(correct_sigma)
+    return np.array(sigmas)
+
+
+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,
+                  momentum: int = 0) -> go.Figure:
+    """
+    Estimates a SNE model, and plots the visualization of the given data.
+
+    More information about t-SNE visualizations and how to use them effectively can be found here:
+    https://distill.pub/2016/misread-tsne/
+
+    (Implementation based around the following: https://nlml.github.io/in-raw-numpy/in-raw-numpy-t-sne/)
+
+    :param df: A Pandas Dataframe of the the data to be visualized (e.g. a recording attribute DataFrame)
+    :param perplexity: A value which can be thought of as determining how many neighbors of a point
+     will be used to update its position in the visualization.
+    :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 momentum:  The momentum to be used when applying updates to the model
+    :return: Plotly figure of the, low-dimensional representation of the given data
+    """
+    rng = np.random.RandomState(random_seed)
+
+    X = df.loc[:, np.float64 == df.dtypes].dropna(axis='columns').to_numpy()
+
+    # Standardize the data
+    X = (X - X.mean()) / X.std()
+
+    # Obtain matrix of joint probabilities p_ij
+    euc_distances = neg_squared_euc_dists(X)
+    calc_prob_mat = calc_prob_matrix(
+        euc_distances,
+        find_optimal_sigmas(euc_distances, perplexity))
+
+    P = (calc_prob_mat + calc_prob_mat.T) / (2. * calc_prob_mat.shape[0])
+
+    # Initialise our 2D representation
+    Y = rng.normal(0., 0.0001, [X.shape[0], 2])
+
+    # Initialise past values (used for momentum)
+    if momentum:
+        Y_m2 = Y.copy()
+        Y_m1 = Y.copy()
+
+    # Start gradient descent loop
+    for i in range(num_iterations):
+
+        # Get Q and distances (distances only used for t-SNE)
+        distances = neg_squared_euc_dists(Y)
+        inv_distances = np.power(1. - distances, -1)
+        np.fill_diagonal(inv_distances, 0.)
+        q_distances = inv_distances / np.sum(inv_distances)
+
+        # Estimate gradients with respect to Y
+        y_diffs = np.expand_dims(Y, 1) - np.expand_dims(Y, 0)
+        grads = 4. * (np.expand_dims(P - q_distances, 2) * y_diffs * np.expand_dims(inv_distances, 2)).sum(1)
+
+        # Update Y
+        Y -= learning_rate * grads
+        if momentum:  # Add momentum
+            Y += momentum * (Y_m1 - Y_m2)
+            # Update previous Y's for momentum
+            Y_m2 = Y_m1.copy()
+            Y_m1 = Y.copy()
+
+    fig = px.scatter(
+        x=Y[:, 0],
+        y=Y[:, 1],
+        # color=colors,
+        color=df['serial_number_id'],
+        color_discrete_map=serials_to_indices,
+        hover_name=df.index,
+    )
+
+    fig.update_layout(
+        xaxis_visible=False,
+        yaxis_visible=False,
+        showlegend=False,
+        margin=dict(t=0, b=0, l=0, r=0),
+    )
+
+    return fig
diff --git a/endaq/plot/utilities.py b/endaq/plot/utilities.py
new file mode 100644
index 0000000..1419428
--- /dev/null
+++ b/endaq/plot/utilities.py
@@ -0,0 +1,241 @@
+from __future__ import annotations
+
+import plotly.io as pio
+import plotly.graph_objects as go
+import numpy as np
+import typing
+from typing import Union
+
+
+def define_theme(template_name: str = "endaq_cloud", default_plotly_template: str = 'plotly_dark',
+                 text_color: str = '#DAD9D8', font_family: str = "Open Sans",
+                 title_font_family: str = "Open Sans SemiBold", graph_line_color: str = '#DAD9D8',
+                 grid_line_color: str = "#404041", background_color: str = '#262626',
+                 plot_background_color: str = '#0F0F0F') -> go.layout._template.Template:
+    """
+    Define a Plotly theme (template), allowing completely custom aesthetics
+
+    :param template_name: The name for the Plotly template being created
+    :param default_plotly_template: The default Plotly Template (aspects of this will be used if
+     a characteristic isn't set elsewhere)
+    :param text_color: The color of the text
+    :param font_family: The font family to use for text (not including the title)
+    :param title_font_family: The font family to use for the title
+    :param graph_line_color: The line color used when plotting line plots
+    :param grid_line_color: The color of the grid lines on the plot
+    :param background_color: The background color of the figure
+    :param plot_background_color: The background color of the plot
+    :return: The plotly template which was just created
+    """
+    pio.templates[template_name] = pio.templates[default_plotly_template]
+
+    # Line Colors
+    colorway = ['#EE7F27', '#6914F0', '#2DB473', '#D72D2D', '#3764FF', '#FAC85F', '#27eec0', '#b42d4d', '#82d72d',
+                '#e35ffa']
+    colorbar = [[0.0, '#6914F0'],
+                [0.2, '#3764FF'],
+                [0.4, '#2DB473'],
+                [0.6, '#FAC85F'],
+                [0.8, '#EE7F27'],
+                [1.0, '#D72D2D']]
+    pio.templates[template_name]['layout']['colorway'] = colorway
+    pio.templates[template_name]['layout']['colorscale']['sequential'] = colorbar
+    pio.templates[template_name]['layout']['colorscale']['sequentialminus'] = colorbar
+    pio.templates[template_name]['layout']['colorscale']['diverging'] = [[0.0, '#6914F0'],
+                                                                         [0.5, '#f7f7f7'],
+                                                                         [1.0, '#EE7F27']]
+    plot_types = ['contour', 'heatmap', 'heatmapgl', 'histogram2d', 'histogram2dcontour', 'surface']
+    for p in plot_types:
+        pio.templates[template_name]['data'][p][0].colorscale = colorbar
+
+    # Text
+    # dictionary = dict(font=dict(family="Open Sans", size=24, color=text_color))
+    # pio.templates[template_name]['layout']['annotations'] = [(k, v) for k, v in dictionary.items()]
+    pio.templates[template_name]['layout']['font'] = dict(family=font_family, size=16, color=text_color)
+    pio.templates[template_name]['layout']['title_font_family'] = title_font_family
+    pio.templates[template_name]['layout']['title_font_size'] = 24
+    pio.templates[template_name]['layout']['title_x'] = 0.5
+    pio.templates[template_name]['layout']['yaxis_title_font_size'] = 20
+    pio.templates[template_name]['layout']['xaxis_title_font_size'] = 20
+
+    # Legend
+    pio.templates[template_name]['layout']['legend'] = dict(orientation='h', y=-0.2)
+
+    # Background Color
+    pio.templates[template_name]['layout']['paper_bgcolor'] = background_color
+    pio.templates[template_name]['layout']['plot_bgcolor'] = plot_background_color
+    pio.templates[template_name]['layout']['geo']['bgcolor'] = plot_background_color
+    pio.templates[template_name]['layout']['polar']['bgcolor'] = plot_background_color
+    pio.templates[template_name]['layout']['ternary']['bgcolor'] = plot_background_color
+    pio.templates[template_name]['layout']['scene']['xaxis']['backgroundcolor'] = plot_background_color
+    pio.templates[template_name]['layout']['scene']['yaxis']['backgroundcolor'] = plot_background_color
+    pio.templates[template_name]['layout']['scene']['zaxis']['backgroundcolor'] = plot_background_color
+
+    # Graph Lines
+    pio.templates[template_name]['data']['scatter'][0].marker.line.color = graph_line_color
+    pio.templates[template_name]['layout']['scene']['xaxis']['gridcolor'] = grid_line_color
+    pio.templates[template_name]['layout']['scene']['xaxis']['linecolor'] = graph_line_color
+    pio.templates[template_name]['layout']['scene']['yaxis']['gridcolor'] = grid_line_color
+    pio.templates[template_name]['layout']['scene']['yaxis']['linecolor'] = graph_line_color
+    pio.templates[template_name]['layout']['scene']['zaxis']['gridcolor'] = grid_line_color
+    pio.templates[template_name]['layout']['scene']['zaxis']['linecolor'] = graph_line_color
+    pio.templates[template_name]['layout']['xaxis']['gridcolor'] = grid_line_color
+    pio.templates[template_name]['layout']['xaxis']['linecolor'] = graph_line_color
+    pio.templates[template_name]['layout']['yaxis']['gridcolor'] = grid_line_color
+    pio.templates[template_name]['layout']['yaxis']['linecolor'] = graph_line_color
+    pio.templates[template_name]['layout']['yaxis']['zerolinecolor'] = graph_line_color
+    pio.templates[template_name]['layout']['xaxis']['zerolinecolor'] = graph_line_color
+    pio.templates[template_name]['layout']['yaxis']['zerolinewidth'] = 1
+    pio.templates[template_name]['layout']['xaxis']['zerolinewidth'] = 1
+    pio.templates[template_name]['layout']['yaxis']['showline'] = True
+    pio.templates[template_name]['layout']['xaxis']['showline'] = True
+    pio.templates[template_name]['layout']['yaxis']['showgrid'] = True
+    pio.templates[template_name]['layout']['xaxis']['showgrid'] = True
+    pio.templates[template_name]['layout']['yaxis']['mirror'] = True
+    pio.templates[template_name]['layout']['xaxis']['mirror'] = True
+    pio.templates[template_name]['layout']['yaxis']['zeroline'] = True
+    pio.templates[template_name]['layout']['xaxis']['zeroline'] = True
+
+    return pio.templates[template_name]
+
+
+def set_theme(theme: typing.Literal['endaq', 'endaq_light', 'endaq_arial', 'endaq_light_arial'] = 'endaq',
+              ) -> go.layout._template.Template:
+    """
+    Set the plot appearances based on a known 'theme'.
+
+    :param theme: A string denoting which plot appearance color scheme to use.
+     Current options are `'endaq'`, `'endaq_light'`, `'endaq_arial'` and `'endaq_light_arial'`.
+    :return: The plotly template which was set
+    """
+    if not isinstance(theme, str):
+        raise TypeError("'theme' must be given as a string")
+        
+    theme = theme.lower()
+    
+    if theme not in ['endaq_cloud', 'endaq_cloud_light', 'endaq', 'endaq_light']:
+        raise ValueError("'" + theme + "' not an option")
+
+    define_theme()
+
+    define_theme(template_name='endaq', font_family="Arial", title_font_family="Arial")
+
+    define_theme(template_name='endaq_cloud_light', grid_line_color='#DAD9D8', graph_line_color='#404041',
+                 plot_background_color='#f3f3f3', background_color='#FFFFFF', text_color='#404041',
+                 default_plotly_template='plotly_white')
+
+    define_theme(template_name='endaq_light', grid_line_color='#DAD9D8', graph_line_color='#404041',
+                 plot_background_color='#f3f3f3', background_color='#FFFFFF', text_color='#404041',
+                 font_family="Arial", title_font_family="Arial",
+                 default_plotly_template='plotly_white')
+
+    # Set Default
+    pio.templates.default = theme
+
+    return pio.templates[theme]
+
+
+def get_center_of_coordinates(lats: np.ndarray, lons: np.ndarray, as_list: bool = False, as_degrees: bool = True
+                              ) -> Union[list, dict]:
+    """
+    Inputs and outputs are measured in degrees.
+    
+    :param lats: An ndarray of latitude points
+    :param lons: An ndarray of longitude points
+    :param as_list: If True, return a length 2 list of the latitude and longitude coordinates.   If not return a
+     dictionary of format {"lon": lon_center, "lat": lat_center}
+    :param as_degrees: A boolean value representing if the 'lats' and 'lons' parameters are given in degrees (as opposed
+     to radians).  These units will be used for the returned values as well.  
+    :return: The latitude and longitude values as either a dictionary or a list, which is
+     determined by the value of the `as_list` parameter (see the `as_list` docstring for details
+     on the formatting of this return value
+    """
+    # Convert coordinates to radians if given in degrees
+    if as_degrees:
+        lats *= np.pi / 180
+        lons *= np.pi / 180
+
+    # Convert coordinates to 3D coordinates
+    x_coords = np.cos(lats) * np.cos(lons)
+    y_coords = np.sin(lats) * np.cos(lons)
+    z_coords = np.sin(lons)
+
+    # Caluculate the means of the coordinates in 3D
+    x_mean = np.mean(x_coords)
+    y_mean = np.mean(y_coords)
+    z_mean = np.mean(z_coords)
+
+    # Convert back to lat/lon from 3D coordinates
+    lat_center = np.arctan2(y_mean, x_mean)
+    lon_center = np.arctan2(z_mean, np.sqrt(x_mean ** 2 + y_mean ** 2))
+
+    # Convert back to degrees from radians
+    if as_degrees:
+        lat_center *= 180 / np.pi
+        lon_center *= 180 / np.pi
+
+    if as_list:
+        return [lat_center, lon_center]
+
+    return {
+        "lat": lat_center,
+        "lon": lon_center,
+    }
+
+
+
+def determine_plotly_map_zoom(
+        lons: tuple = None,
+        lats: tuple = None,
+        lonlats: tuple = None,
+        projection: str = "mercator",
+        width_to_height: float = 2.0,
+        margin: float = 1.2,
+) -> float:
+    """
+    Finds optimal zoom for a plotly mapbox. Must be passed (lons & lats) or lonlats.
+
+    Originally based on the following post:
+    https://stackoverflow.com/questions/63787612/plotly-automatic-zooming-for-mapbox-maps
+    
+    This is a temporary solution awaiting an official implementation:
+    https://github.com/plotly/plotly.js/issues/3434
+    
+    :param lons: tuple, optional, longitude component of each location
+    :param lats: tuple, optional, latitude component of each location
+    :param lonlats: tuple, optional, gps locations
+    :param projection: str, only accepting 'mercator' at the moment,
+        raises `NotImplementedError` if other is passed
+    :param width_to_height: float, expected ratio of final graph's with to height,
+        used to select the constrained axis.
+    :param margin: The desired margin around the plotted points (where 1 would be no-margin)
+    :return: The zoom scaling for the Plotly map
+    
+    .. note::
+      This implementation could be potentially problematic.  By simply averaging min/max coorindates
+      you end up with situations such as the longitude lines -179.99 and 179.99 being
+      almost right next to each other, but their center is calculated at 0, the other side of the earth.
+    """
+    if lons is None and lats is None:
+        if isinstance(lonlats, tuple):
+            lons, lats = zip(*lonlats)
+        else:
+            raise ValueError("Must pass lons & lats or lonlats")
+            
+    # longitudinal range by zoom level (20 to 1) in degrees, log scaled, with 360 as min zoom
+    lon_zoom_range = np.array([360 / 2 ** j for j in range(20)[::-1]], dtype=np.float32)
+
+    if projection == "mercator":
+        maxlon, minlon = max(lons), min(lons)
+        maxlat, minlat = max(lats), min(lats)
+        
+        height = (maxlat - minlat) * margin * width_to_height
+        width = (maxlon - minlon) * margin
+        lon_zoom = np.interp(width, lon_zoom_range, range(20, 0, -1))
+        lat_zoom = np.interp(height, lon_zoom_range, range(20, 0, -1))
+        zoom = round(min(lon_zoom, lat_zoom), 2)
+    else:
+        raise NotImplementedError(f"{projection} projection is not implemented")
+
+    return zoom
+    
diff --git a/requirements.txt b/requirements.txt
index 0d5a5af..0b66063 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,17 @@
-endaq-calc>=1.1.0.post2
-endaq-cloud>=1.1.0
-endaq-ide>=1.1.0
-endaq-plot>=1.1.0
+backports.cached-property; python_version<'3.8'
+ebmlite>=3.2.0
+idelib>=3.2.3
+jinja2
+numpy>=1.19.5
+pandas>=1.1.5
+pandas>=1.3
+plotly>=5.3.1
+python-dotenv>=0.18.0
+requests>=2.25.1
+scipy>=1.7.1
+
+# Testing
+hypothesis
+pytest
+pytest-cov
+sympy
diff --git a/setup.py b/setup.py
index 5f1a3bb..ba1f393 100644
--- a/setup.py
+++ b/setup.py
@@ -4,13 +4,24 @@
     long_description = fh.read()
 
 INSTALL_REQUIRES = [
-    'endaq-calc>=1.1.0.post2',
-    'endaq-cloud>=1.1.0',
-    'endaq-ide>=1.1.0',
-    'endaq-plot>=1.1.0',
+    "backports.cached-property; python_version<'3.8'",
+    "ebmlite>=3.2.0",
+    "idelib>=3.2.3",
+    "jinja2",
+    "numpy>=1.19.5",
+    "pandas>=1.3",
+    "plotly>=5.3.1",
+    "python-dotenv>=0.18.0",
+    "requests>=2.25.1",
+    "scipy>=1.7.1",
     ]
 
 TEST_REQUIRES = [
+    "hypothesis",
+    "pytest",
+    "pytest-cov",
+    "pytest-xdist[psutil]",
+    "sympy",
     ]
 
 DOCS_REQUIRES = [
@@ -23,7 +34,7 @@
 
 setuptools.setup(
         name='endaq',
-        version='1.1.1.post2',
+        version='1.2.0',
         author='Mide Technology',
         author_email='help@mide.com',
         description='A comprehensive, user-centric Python API for working with enDAQ data and devices',


Test patch:
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
new file mode 100644
index 0000000..9ebfcfb
--- /dev/null
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,63 @@
+name: Unit test on push and PR
+
+
+on: [push, pull_request, workflow_dispatch]
+
+
+jobs:
+  unit-tests:
+    name: Run Unit Tests
+
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [windows-latest, ubuntu-latest, macos-latest]
+        python-version: [3.7, 3.8, 3.9]
+
+    env:
+      OS: ${{ matrix.os }}
+      PYTHON-VERSION: ${{ matrix.python-version }}
+
+    runs-on: ${{ matrix.os }}
+
+    steps:
+
+      - name: Setup Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - 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: ${{ runner.os }}-Python${{ matrix.python-version }}-${{ 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 .[test]
+
+      - name: Run Tests
+        run: python -m pytest tests --cov=endaq --hypothesis-profile=ci --numprocesses auto
+
+      - name: Generate Codecov Report
+        uses: codecov/codecov-action@v2
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          env_vars: OS,PYTHON-VERSION
+          files: .coverage
+          name: ${{ matrix.os }} ${{ matrix.python-version }}
+          verbose: true
diff --git a/tests/batch/ActTest_009.IDE b/tests/batch/ActTest_009.IDE
new file mode 100644
index 0000000..eb7faa0
Binary files /dev/null and b/tests/batch/ActTest_009.IDE differ
diff --git a/tests/batch/Cloud_Test_000000.IDE b/tests/batch/Cloud_Test_000000.IDE
new file mode 100755
index 0000000..0ac39d2
Binary files /dev/null and b/tests/batch/Cloud_Test_000000.IDE differ
diff --git a/tests/batch/GPS-Chick-Fil-A_003.IDE b/tests/batch/GPS-Chick-Fil-A_003.IDE
new file mode 100644
index 0000000..6c9c918
Binary files /dev/null and b/tests/batch/GPS-Chick-Fil-A_003.IDE differ
diff --git a/tests/batch/High-Drop.IDE b/tests/batch/High-Drop.IDE
new file mode 100644
index 0000000..ae2dafa
Binary files /dev/null and b/tests/batch/High-Drop.IDE differ
diff --git a/tests/batch/SSX70065.IDE b/tests/batch/SSX70065.IDE
new file mode 100644
index 0000000..4cdbfb4
Binary files /dev/null and b/tests/batch/SSX70065.IDE differ
diff --git a/tests/batch/cnc_room_inside_the_rubber_floor.ide b/tests/batch/cnc_room_inside_the_rubber_floor.ide
new file mode 100755
index 0000000..434d32f
Binary files /dev/null and b/tests/batch/cnc_room_inside_the_rubber_floor.ide differ
diff --git a/tests/batch/cnc_room_outside_the_rubber_floor.ide b/tests/batch/cnc_room_outside_the_rubber_floor.ide
new file mode 100755
index 0000000..e7225c1
Binary files /dev/null and b/tests/batch/cnc_room_outside_the_rubber_floor.ide differ
diff --git a/tests/batch/test1.IDE b/tests/batch/test1.IDE
new file mode 100644
index 0000000..e656d9f
Binary files /dev/null and b/tests/batch/test1.IDE differ
diff --git a/tests/batch/test2.IDE b/tests/batch/test2.IDE
new file mode 100644
index 0000000..627c858
Binary files /dev/null and b/tests/batch/test2.IDE differ
diff --git a/tests/batch/test3.IDE b/tests/batch/test3.IDE
new file mode 100644
index 0000000..bf879e3
Binary files /dev/null and b/tests/batch/test3.IDE differ
diff --git a/tests/batch/test4.IDE b/tests/batch/test4.IDE
new file mode 100755
index 0000000..d6202f9
Binary files /dev/null and b/tests/batch/test4.IDE differ
diff --git a/tests/batch/test5.IDE b/tests/batch/test5.IDE
new file mode 100755
index 0000000..fb1fe49
Binary files /dev/null and b/tests/batch/test5.IDE differ
diff --git a/tests/batch/test_GPS_2.IDE b/tests/batch/test_GPS_2.IDE
new file mode 100644
index 0000000..a4c15d3
Binary files /dev/null and b/tests/batch/test_GPS_2.IDE differ
diff --git a/tests/batch/test_GPS_3.IDE b/tests/batch/test_GPS_3.IDE
new file mode 100644
index 0000000..4f42f85
Binary files /dev/null and b/tests/batch/test_GPS_3.IDE differ
diff --git a/tests/batch/test_analyzer.py b/tests/batch/test_analyzer.py
new file mode 100644
index 0000000..82b9dcc
--- /dev/null
+++ b/tests/batch/test_analyzer.py
@@ -0,0 +1,331 @@
+import os
+from unittest import mock
+
+import idelib
+import numpy as np
+import pandas as pd
+import pytest
+
+import endaq.batch.analyzer
+from endaq.calc.stats import rms, L2_norm
+
+
+np.random.seed(0)
+
+
+@pytest.fixture()
+def ide_SSX70065():
+    with idelib.importFile(os.path.join("tests", "batch", "SSX70065.IDE")) as doc:
+        yield doc
+
+
+@pytest.fixture()
+def analyzer_raw():
+    analyzer_mock = mock.create_autospec(
+        endaq.batch.analyzer.Analyzer, 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
+
+    return analyzer_mock
+
+
+@pytest.fixture()
+def analyzer_bulk(analyzer_raw):
+    analyzer_mock = analyzer_raw
+
+    analyzer_mock._channels = {
+        "acc": mock.Mock(axis_names=list("XYZ")),
+        "gps": mock.Mock(axis_names=["Latitude", "Longitude"]),
+        "spd": mock.Mock(axis_names=["Ground"]),
+        "gyr": mock.Mock(axis_names=list("XYZ")),
+        "mic": mock.Mock(axis_names=["Mic"]),
+        "tmp": mock.Mock(axis_names=["Control"]),
+        "pre": mock.Mock(axis_names=["Control"]),
+    }
+
+    analyzer_mock._accelerationFs = 3000
+    analyzer_mock._accelerationData = pd.DataFrame(
+        np.random.random((21, 3)),
+        index=pd.Series(np.arange(21) / 3000, name="time"),
+        columns=pd.Series(["X", "Y", "Z"], name="axis"),
+    )
+    analyzer_mock._accelerationResultantData = analyzer_mock._accelerationData.apply(
+        L2_norm, axis="columns"
+    ).to_frame()
+    analyzer_mock._microphoneData = pd.DataFrame(
+        np.random.random(21),
+        index=pd.Series(np.arange(21) / 3000, name="time"),
+        columns=pd.Series(["Mic"], name="axis"),
+    )
+    analyzer_mock._velocityData = pd.DataFrame(
+        np.random.random((21, 3)),
+        index=pd.Series(np.arange(21) / 3000, name="time"),
+        columns=pd.Series(["X", "Y", "Z"], name="axis"),
+    )
+    analyzer_mock._displacementData = pd.DataFrame(
+        np.random.random((21, 3)),
+        index=pd.Series(np.arange(21) / 3000, name="time"),
+        columns=pd.Series(["X", "Y", "Z"], name="axis"),
+    )
+    analyzer_mock._pressureData = pd.DataFrame(
+        np.random.random(5),
+        index=pd.Series(np.arange(5) / 5, name="time"),
+        columns=pd.Series(["Control"], name="axis"),
+    )
+    analyzer_mock._temperatureData = pd.DataFrame(
+        np.random.random(5),
+        index=pd.Series(np.arange(5) / 5, name="time"),
+        columns=pd.Series(["Control"], name="axis"),
+    )
+    analyzer_mock._gyroscopeData = pd.DataFrame(
+        np.random.random(11),
+        index=pd.Series(np.arange(11) / 5, name="time"),
+        columns=pd.Series(["Gyro"], name="axis"),
+    )
+
+    return analyzer_mock
+
+
+# ==============================================================================
+# Analyzer class tests
+# ==============================================================================
+
+
+class TestAnalyzer:
+    def test_accRMSFull(self, analyzer_bulk):
+        calc_result = endaq.batch.analyzer.Analyzer.accRMSFull.func(analyzer_bulk)[
+            "Resultant"
+        ]
+        expt_result = endaq.batch.analyzer.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)[
+            "Resultant"
+        ]
+        expt_result = endaq.batch.analyzer.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)[
+            "Resultant"
+        ]
+        expt_result = endaq.batch.analyzer.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)[
+            "Resultant"
+        ]
+        expt_result = endaq.batch.analyzer.Analyzer.MPS2_TO_G * rms(
+            analyzer_bulk._accelerationData.apply(L2_norm, axis="columns").max()
+        )
+
+        assert calc_result == pytest.approx(expt_result)
+
+    def test_pseudoVelPeakFull(self, analyzer_bulk):
+        pass
+
+    def test_gpsLocFull(self, analyzer_bulk):
+        pass
+
+    def test_gpsSpeedFull(self, analyzer_bulk):
+        pass
+
+    def test_gyroRMSFull(self, analyzer_bulk):
+        pass
+
+    def test_micRMSFull(self, analyzer_bulk):
+        calc_result = endaq.batch.analyzer.Analyzer.micRMSFull.func(analyzer_bulk)[
+            "Mic"
+        ]
+        expt_result = rms(analyzer_bulk._microphoneData)
+
+        assert calc_result == pytest.approx(expt_result)
+
+    def test_pressFull(self, analyzer_bulk):
+        calc_result = endaq.batch.analyzer.Analyzer.pressFull.func(analyzer_bulk)[
+            "Control"
+        ]
+        expt_result = analyzer_bulk._pressureData.mean()
+
+        assert calc_result == pytest.approx(expt_result)
+
+    def test_tempFull(self, analyzer_bulk):
+        calc_result = endaq.batch.analyzer.Analyzer.tempFull.func(analyzer_bulk)[
+            "Control"
+        ]
+        expt_result = analyzer_bulk._temperatureData.mean()
+
+        assert calc_result == pytest.approx(expt_result)
+
+    ###########################################################################
+    # Live File Tests
+
+    def testLiveFile(self, ide_SSX70065):
+        analyzer = endaq.batch.analyzer.Analyzer(
+            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,
+        )
+
+        raw_accel = ide_SSX70065.channels[32].getSession().arrayValues()
+        np.testing.assert_allclose(
+            analyzer.accRMSFull["Resultant"],
+            rms(L2_norm(raw_accel - raw_accel.mean(axis=-1, keepdims=True), axis=0)),
+            rtol=1e-3,
+        )
+        np.testing.assert_allclose(
+            analyzer.pressFull,
+            1e-3
+            * ide_SSX70065.channels[36]
+            .subchannels[0]
+            .getSession()
+            .arrayValues()
+            .mean(),
+            rtol=1e-5,
+        )
+        np.testing.assert_allclose(
+            analyzer.tempFull,
+            ide_SSX70065.channels[36].subchannels[1].getSession().arrayValues().mean(),
+            rtol=1e-4,
+        )
+
+    @pytest.mark.parametrize(
+        "filename",
+        [
+            os.path.join("tests", "batch", "test1.IDE"),
+            os.path.join("tests", "batch", "test2.IDE"),
+        ],
+    )
+    def testLiveFiles12(self, filename):
+        ds = idelib.importFile(filename)
+        analyzer = endaq.batch.analyzer.Analyzer(
+            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,
+        )
+
+        raw_accel = ds.channels[8].getSession().arrayValues(subchannels=[0, 1, 2])
+        np.testing.assert_allclose(
+            analyzer.accRMSFull["Resultant"],
+            rms(L2_norm(raw_accel - raw_accel.mean(axis=-1, keepdims=True), axis=0)),
+            rtol=0.55,  # this is... probably a little too high...
+        )
+        np.testing.assert_allclose(
+            analyzer.micRMSFull,
+            rms(ds.channels[8].subchannels[3].getSession().arrayValues()),
+            rtol=1e-3,
+        )
+        np.testing.assert_allclose(
+            analyzer.gyroRMSFull["Resultant"],
+            rms(L2_norm(ds.channels[84].getSession().arrayValues(), axis=0)),
+            rtol=1e-2,
+        )
+        np.testing.assert_allclose(
+            analyzer.pressFull,
+            1e-3 * ds.channels[36].subchannels[0].getSession().arrayValues().mean(),
+            rtol=1e-3,
+        )
+        np.testing.assert_allclose(
+            analyzer.tempFull,
+            ds.channels[36].subchannels[1].getSession().arrayValues().mean(),
+            rtol=0.05,  # ...GEFGW?
+        )
+
+    @pytest.mark.parametrize(
+        "filename",
+        [
+            os.path.join("tests", "batch", "test3.IDE"),
+        ],
+    )
+    def testLiveFile3(self, filename):
+        ds = idelib.importFile(filename)
+        analyzer = endaq.batch.analyzer.Analyzer(
+            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,
+        )
+
+        ch_rot = ds.channels[84]
+        np.testing.assert_allclose(
+            analyzer.gyroRMSFull["Resultant"],
+            rms(L2_norm(ch_rot.getSession().arrayValues(), axis=0)),
+            rtol=0.005,
+        )
+
+    @pytest.mark.parametrize(
+        "filename, sample_index",
+        [
+            (os.path.join("tests", "batch", "test_GPS_2.IDE"), -2),
+            (os.path.join("tests", "batch", "test_GPS_3.IDE"), -4),
+        ],
+    )
+    def testLiveFileGPS(self, filename, sample_index):
+        ds = idelib.importFile(filename)
+        analyzer = endaq.batch.analyzer.Analyzer(
+            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,
+        )
+
+        ch_gps = ds.channels[88]
+        # confirming channel format for gps file
+        assert ch_gps.subchannels[0].name == "Latitude"
+        assert ch_gps.subchannels[1].name == "Longitude"
+        assert ch_gps.subchannels[3].name == "Ground Speed"
+
+        assert tuple(analyzer.gpsLocFull) == tuple(
+            ch_gps.getSession().arrayValues()[[0, 1], sample_index],
+        )
+        # Resampling throws off mean calculation
+        # -> use resampled data for comparsion
+        # gps_speed = analyzer.MPS_TO_KMPH * ch_gps.getSession().arrayValues(subchannels=[3])
+        gps_speed = analyzer._gpsSpeedData
+        np.testing.assert_allclose(
+            analyzer.gpsSpeedFull,
+            np.mean(gps_speed[gps_speed != 0]),
+        )
diff --git a/tests/batch/test_calc.py b/tests/batch/test_calc.py
new file mode 100644
index 0000000..7fa840c
--- /dev/null
+++ b/tests/batch/test_calc.py
@@ -0,0 +1,616 @@
+from collections import namedtuple
+import os
+import tempfile
+
+
+import idelib
+import numpy as np
+import pandas as pd
+import pytest
+
+import endaq.batch.calc
+import endaq.batch.analyzer
+from endaq.batch.utils import ide_utils
+
+
+@pytest.mark.parametrize(
+    "filename, expt_result",
+    [
+        (
+            os.path.join("tests", "batch", "test1.IDE"),
+            [10118, np.datetime64("2020-09-16 19:05:49.771728")],
+        ),
+        (
+            os.path.join("tests", "batch", "test2.IDE"),
+            [10118, np.datetime64("2020-09-16 19:04:22.475738")],
+        ),
+        (
+            os.path.join("tests", "batch", "test4.IDE"),
+            [10118, np.datetime64("2020-11-18 17:31:27.000000")],
+        ),
+    ],
+)
+def test_make_meta(filename, expt_result):
+    with idelib.importFile(filename) as ds:
+        meta = endaq.batch.calc._make_meta(ds)
+
+    assert np.all(meta.index == ["serial number", "start time"])
+    assert np.all(meta == expt_result)
+
+
+@pytest.mark.parametrize(
+    "filename",
+    [
+        os.path.join("tests", "batch", "SSX70065.IDE"),
+        os.path.join("tests", "batch", "test1.IDE"),
+        os.path.join("tests", "batch", "test2.IDE"),
+    ],
+)
+def test_make_peak_windows(filename):
+    with idelib.importFile(filename) as ds:
+        accel_ch = ide_utils.get_ch_type_best(ds, "acc")
+
+        data = accel_ch.to_pandas(time_mode="timedelta")
+        utc_start_time = ds.lastUtcTime
+        axis_names = accel_ch.axis_names
+
+        analyzer = endaq.batch.analyzer.Analyzer(
+            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,
+        )
+        calc_meta = endaq.batch.calc._make_meta(ds)
+        calc_peaks = endaq.batch.calc._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"]
+    assert np.all(
+        calc_peaks.index.unique(level="axis").sort_values()
+        == ["Resultant"] + axis_names
+    )
+
+    calc_peak_times = (
+        calc_peaks.index.droplevel("peak offset")
+        .unique()
+        .to_frame()
+        .droplevel("peak time")
+        .loc[axis_names, "peak time"]
+    )
+    expt_peak_times = data.index[i_max].astype("timedelta64[ns]")
+    assert np.all(calc_peak_times == expt_peak_times)
+
+
+@pytest.fixture
+def data_builder():
+    return (
+        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1)
+        .add_psd(freq_bin_width=1)
+        .add_pvss(init_freq=1, bins_per_octave=12)
+        .add_metrics()
+        .add_peaks(margin_len=1000)
+        .add_vc_curves(init_freq=1, bins_per_octave=3)
+    )
+
+
+@pytest.mark.parametrize(
+    "filename",
+    [
+        os.path.join("tests", "batch", "SSX70065.IDE"),
+        os.path.join("tests", "batch", "test1.IDE"),
+        os.path.join("tests", "batch", "test2.IDE"),
+        os.path.join("tests", "batch", "test3.IDE"),
+        os.path.join("tests", "batch", "test4.IDE"),
+        os.path.join("tests", "batch", "test5.IDE"),
+        os.path.join("tests", "batch", "GPS-Chick-Fil-A_003.IDE"),
+        os.path.join("tests", "batch", "High-Drop.IDE"),
+    ],
+)
+def test_get_data(filename):
+    """Test `_get_data` over several varieties of recording files."""
+    (
+        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1)
+        .add_psd(freq_bin_width=1)
+        .add_pvss(init_freq=1, bins_per_octave=12)
+        .add_metrics()
+        .add_peaks(margin_len=1000)
+        .add_vc_curves(init_freq=1, bins_per_octave=3)
+        ._get_data(filename)
+    )
+
+
+def assert_output_is_valid(output: endaq.batch.calc.OutputStruct):
+    """Validate the contents & structure of an `OutputStruct` object."""
+    assert isinstance(output, endaq.batch.calc.OutputStruct)
+    assert isinstance(output.dataframes, dict)
+    assert {
+        "meta",
+        "psd",
+        "pvss",
+        "metrics",
+        "peaks",
+        "vc_curves",
+    }.issuperset(output.dataframes)
+
+    assert output.dataframes["meta"].index.name == "filename"
+    assert output.dataframes["meta"].columns.to_list() == [
+        "serial number",
+        "start time",
+    ]
+
+    if "psd" in output.dataframes:
+        assert np.all(
+            output.dataframes["psd"].columns
+            == [
+                "filename",
+                "axis",
+                "frequency (Hz)",
+                "value",
+                "serial number",
+                "start time",
+            ]
+        )
+
+    if "pvss" in output.dataframes:
+        assert np.all(
+            output.dataframes["pvss"].columns
+            == [
+                "filename",
+                "axis",
+                "frequency (Hz)",
+                "value",
+                "serial number",
+                "start time",
+            ]
+        )
+
+    if "metrics" in output.dataframes:
+        assert np.all(
+            output.dataframes["metrics"].columns
+            == [
+                "filename",
+                "calculation",
+                "axis",
+                "value",
+                "serial number",
+                "start time",
+            ]
+        )
+
+    if "peaks" in output.dataframes:
+        assert np.all(
+            output.dataframes["peaks"].columns
+            == [
+                "filename",
+                "axis",
+                "peak time",
+                "peak offset",
+                "value",
+                "serial number",
+                "start time",
+            ]
+        )
+
+    if "vc_curves" in output.dataframes:
+        assert np.all(
+            output.dataframes["vc_curves"].columns
+            == [
+                "filename",
+                "axis",
+                "frequency (Hz)",
+                "value",
+                "serial number",
+                "start time",
+            ]
+        )
+
+
+@pytest.mark.parametrize(
+    "getdata_builder",
+    [
+        # Each builder piece individually
+        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1),
+        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
+            freq_bin_width=1
+        ),
+        endaq.batch.calc.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(
+            margin_len=1000
+        ),
+        endaq.batch.calc.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)
+            .add_psd(freq_bin_width=1)
+            .add_pvss(init_freq=1, bins_per_octave=12)
+            .add_metrics()
+            .add_peaks(margin_len=1000)
+            .add_vc_curves(init_freq=1, bins_per_octave=3)
+        ),
+        # Disable highpass filter
+        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=None).add_psd(
+            freq_bin_width=1
+        ),
+        # Test time restrictions on acceleration data
+        endaq.batch.calc.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(
+            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(
+            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(
+            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(
+            bins_per_octave=1
+        ),
+        endaq.batch.calc.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(
+            freq_bin_width=0.2, bins_per_octave=3
+        ),
+    ],
+)
+def test_aggregate_data(getdata_builder):
+    """Test `aggregate_data` over several configurations of `GetDataBuilder`."""
+    filenames = [
+        os.path.join("tests", "batch", "test1.IDE"),
+        os.path.join("tests", "batch", "test2.IDE"),
+        os.path.join("tests", "batch", "test4.IDE"),
+    ]
+
+    calc_result = getdata_builder.aggregate_data(filenames)
+
+    assert list(calc_result.dataframes)[1:] == list(getdata_builder._metrics_queue)
+    assert len(calc_result.dataframes["meta"]) == 3
+    assert_output_is_valid(calc_result)
+
+
+@pytest.fixture
+def output_struct():
+    data = {}
+
+    fieldname_mods = dict(
+        frequency="frequency (Hz)",
+        serial_number="serial number",
+        start_time="start time",
+        peak_time="peak time",
+        peak_offset="peak offset",
+    )
+
+    RowStruct = namedtuple(
+        "RowStruct",
+        [
+            "filename",
+            "serial_number",
+            "start_time",
+        ],
+    )
+    data["meta"] = pd.DataFrame.from_records(
+        [
+            RowStruct(
+                filename="stub1.ide",
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+        ],
+        index="filename",
+        columns=[fieldname_mods.get(i, i) for i in RowStruct._fields],
+    )
+
+    RowStruct = namedtuple(
+        "RowStruct",
+        [
+            "filename",
+            "axis",
+            "frequency",
+            "value",
+            "serial_number",
+            "start_time",
+        ],
+    )
+    data["psd"] = pd.DataFrame.from_records(
+        [
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                frequency=1.0,
+                value=10,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                frequency=2.0,
+                value=5,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                frequency=1.0,
+                value=8,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                frequency=2.0,
+                value=16,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+        ],
+        columns=[fieldname_mods.get(i, i) for i in RowStruct._fields],
+    )
+
+    RowStruct = namedtuple(
+        "RowStruct",
+        [
+            "filename",
+            "axis",
+            "frequency",
+            "value",
+            "serial_number",
+            "start_time",
+        ],
+    )
+    data["pvss"] = pd.DataFrame.from_records(
+        [
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                frequency=1.0,
+                value=100,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                frequency=2.0,
+                value=50,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                frequency=1.0,
+                value=80,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                frequency=2.0,
+                value=160,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+        ],
+        columns=[fieldname_mods.get(i, i) for i in RowStruct._fields],
+    )
+
+    RowStruct = namedtuple(
+        "RowStruct",
+        [
+            "filename",
+            "calculation",
+            "axis",
+            "value",
+            "serial_number",
+            "start_time",
+        ],
+    )
+    data["metrics"] = pd.DataFrame.from_records(
+        [
+            RowStruct(
+                filename="stub1.ide",
+                calculation="RMS Acceleration",
+                axis="X",
+                value=0.2,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                calculation="RMS Acceleration",
+                axis="Y",
+                value=0.1,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+        ],
+        columns=[fieldname_mods.get(i, i) for i in RowStruct._fields],
+    )
+
+    RowStruct = namedtuple(
+        "RowStruct",
+        [
+            "filename",
+            "axis",
+            "peak_time",
+            "peak_offset",
+            "value",
+            "serial_number",
+            "start_time",
+        ],
+    )
+    data["peaks"] = pd.DataFrame.from_records(
+        [
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                peak_time=np.timedelta64(3, "s"),
+                peak_offset=np.timedelta64(-100, "us"),
+                value=0.7,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                peak_time=np.timedelta64(3, "s"),
+                peak_offset=np.timedelta64(0, "us"),
+                value=1.1,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                peak_time=np.timedelta64(3, "s"),
+                peak_offset=np.timedelta64(100, "us"),
+                value=0.4,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                peak_time=np.timedelta64(5, "s"),
+                peak_offset=np.timedelta64(-100, "us"),
+                value=-1.2,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                peak_time=np.timedelta64(5, "s"),
+                peak_offset=np.timedelta64(0, "us"),
+                value=-1.5,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                peak_time=np.timedelta64(5, "s"),
+                peak_offset=np.timedelta64(100, "us"),
+                value=-0.9,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+        ],
+        columns=[fieldname_mods.get(i, i) for i in RowStruct._fields],
+    )
+
+    RowStruct = namedtuple(
+        "RowStruct",
+        [
+            "filename",
+            "axis",
+            "frequency",
+            "value",
+            "serial_number",
+            "start_time",
+        ],
+    )
+    data["vc_curves"] = pd.DataFrame.from_records(
+        [
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                frequency=1.0,
+                value=1000,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub1.ide",
+                axis="X",
+                frequency=2.0,
+                value=500,
+                serial_number=12345,
+                start_time=np.datetime64("2020-01-01 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                frequency=1.0,
+                value=800,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+            RowStruct(
+                filename="stub2.ide",
+                axis="Y",
+                frequency=2.0,
+                value=1600,
+                serial_number=67890,
+                start_time=np.datetime64("2020-02-02 00:00:00"),
+            ),
+        ],
+        columns=[fieldname_mods.get(i, i) for i in RowStruct._fields],
+    )
+
+    result = endaq.batch.calc.OutputStruct(data)
+    assert_output_is_valid(result)
+
+    return result
+
+
+def test_output_to_csv_folder(output_struct):
+    with tempfile.TemporaryDirectory() as dirpath:
+        output_struct.to_csv_folder(dirpath)
+
+        for k, v in output_struct.dataframes.items():
+            filepath = os.path.join(dirpath, k + ".csv")
+            assert os.path.isfile(filepath)
+
+            read_result = pd.read_csv(
+                filepath,
+                **(dict(index_col="filename") if k == "meta" else {}),
+            )
+            assert v.astype(str).compare(read_result.astype(str)).size == 0
+
+
+def test_output_to_html_plots(output_struct):
+    with tempfile.TemporaryDirectory() as dirpath:
+        output_struct.to_html_plots(folder_path=dirpath, show=False)
+
+        for k in output_struct.dataframes:
+            # Not all dataframes get plotted
+            if k in ("meta", "metrics"):
+                continue
+
+            filepath = os.path.join(dirpath, k + ".html")
+            assert os.path.isfile(filepath)
+
+            # can't do much else for validation...
diff --git a/tests/batch/test_quat.py b/tests/batch/test_quat.py
new file mode 100644
index 0000000..7e8b81b
--- /dev/null
+++ b/tests/batch/test_quat.py
@@ -0,0 +1,65 @@
+import pytest
+import numpy as np
+import sympy as sp
+
+from endaq.batch import quat
+
+
+@pytest.fixture()
+def p():
+    return np.array(
+        [
+            sp.symbols("w_p:6", real=True),
+            sp.symbols("x_p:6", real=True),
+            sp.symbols("y_p:6", real=True),
+            sp.symbols("z_p:6", real=True),
+        ]
+    ).T.reshape(2, 3, 4)
+
+
+@pytest.fixture()
+def q():
+    return np.array(
+        [
+            sp.symbols("w_q:6", real=True),
+            sp.symbols("x_q:6", real=True),
+            sp.symbols("y_q:6", real=True),
+            sp.symbols("z_q:6", real=True),
+        ]
+    ).T.reshape(2, 3, 4)
+
+
+def test_quat_conj(q):
+    calc_result = quat.quat_conj(q)
+
+    for i_nd in np.ndindex(q.shape[:-1]):
+        assert sp.Quaternion(*calc_result[i_nd]) == sp.conjugate(
+            sp.Quaternion(*q[i_nd])
+        )
+
+
+def test_quat_inv(q):
+    calc_result = quat.quat_inv(q)
+
+    for i_nd in np.ndindex(q.shape[:-1]):
+        assert sp.Quaternion(*calc_result[i_nd]) == sp.Quaternion(*q[i_nd]) ** -1
+
+
+def test_quat_mul(p, q):
+    calc_result = quat.quat_mul(p, q)
+
+    for i_nd in np.ndindex(q.shape[:-1]):
+        assert sp.Quaternion(*calc_result[i_nd]) == sp.Quaternion(
+            *p[i_nd]
+        ) * sp.Quaternion(*q[i_nd])
+
+
+def test_quat_div(p, q):
+    calc_result = quat.quat_div(p, q)
+
+    for i_nd in np.ndindex(q.shape[:-1]):
+        assert (
+            sp.Quaternion(*calc_result[i_nd])
+            # faster to check p*(q**-1) than p/q due to symbolic representations
+            == sp.Quaternion(*p[i_nd]) * sp.Quaternion(*q[i_nd]) ** -1
+        )
diff --git a/tests/calc/test_filters.py b/tests/calc/test_filters.py
new file mode 100644
index 0000000..2f9582d
--- /dev/null
+++ b/tests/calc/test_filters.py
@@ -0,0 +1,61 @@
+import numpy as np
+import pandas as pd
+
+from endaq.calc import filters
+
+
+def test_highpass():
+    # Generate input data
+    x = np.zeros(1000)
+    x[300] = 1
+    fs = 100
+    fs_cutoff = 5
+
+    # Validate input data
+    f = np.fft.rfftfreq(len(x), d=1 / fs)
+    Fx = np.fft.rfft(x)
+    assert np.allclose(np.abs(Fx), 1)
+
+    # Generate output data
+    x_filt = (
+        filters.butterworth(
+            pd.DataFrame(
+                x, index=(np.arange(len(x)) / fs) * np.timedelta64(10 ** 9, "ns")
+            ),
+            low_cutoff=fs_cutoff,
+            high_cutoff=None,
+        )
+        .to_numpy()
+        .flatten()
+    )
+    assert x_filt.shape == x.shape
+
+    # Validate output data
+    Fx_filt = np.fft.rfft(x_filt)
+    assert (
+        np.abs(Fx_filt[f == 0]) < 1e-9
+    )  # diverges from other trends at f=0 -> test explicitly here
+
+    f_cut_lower = fs_cutoff / 2
+    f_cut_upper = fs_cutoff * 2
+    i_f_cut_lower = np.flatnonzero(np.diff(f > f_cut_lower, prepend=0))[0]
+    i_f_cut_upper = np.flatnonzero(np.diff(f > f_cut_upper, prepend=0))[0]
+
+    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)
+    # 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
+    assert np.allclose(Fx_filt_dB_diff[: i_f_cut_lower - 1], 10 * 6, atol=1)
+    # post-cutoff constant at 0dB
+    assert np.allclose(Fx_filt_dB_diff[i_f_cut_upper:], 0, atol=1)
+    # in-between slows down from ramp-up to constant
+    assert np.all(np.diff(Fx_filt_dB_diff) < 1e-2)
+
+    mask_thresh = np.abs(Fx_filt) > 1e-8
+    angle_change = np.angle(Fx_filt[mask_thresh]) - np.angle(Fx[mask_thresh])
+    angle_change_0centered = (angle_change + np.pi) % (2 * np.pi) - np.pi
+    # 0-phase offset with bidirectional filter
+    assert np.allclose(angle_change_0centered, 0)
diff --git a/tests/calc/test_integrate.py b/tests/calc/test_integrate.py
new file mode 100644
index 0000000..3d964c8
--- /dev/null
+++ b/tests/calc/test_integrate.py
@@ -0,0 +1,40 @@
+import pytest
+import numpy as np
+import pandas as pd
+import sympy as sp
+
+from endaq.calc import integrate
+
+
+def test_integrate():
+    """Test `_integrate` via differentiation."""
+    n = 20
+    array = np.array([sp.symbols(f"x:{n}"), sp.symbols(f"y:{n}")]).T
+    dt = sp.symbols("dt")
+
+    # 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)
+
+    # 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)
+
+
+def test_integrals():
+    n = 20
+    array = np.array([sp.symbols(f"x:{n}"), sp.symbols(f"y:{n}")]).T
+    dt = sp.symbols("dt")
+    df = pd.DataFrame(array, index=dt * np.arange(len(array)))
+
+    calc_result = integrate.integrals(df, n=2)
+
+    assert len(calc_result) == 3
+    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))
diff --git a/tests/calc/test_psd.py b/tests/calc/test_psd.py
new file mode 100644
index 0000000..7a1ff98
--- /dev/null
+++ b/tests/calc/test_psd.py
@@ -0,0 +1,175 @@
+from collections import namedtuple
+import timeit
+
+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
+
+from endaq.calc import psd, stats
+
+
+@hyp.given(
+    df=hyp_np.arrays(
+        dtype=np.float64,
+        shape=(200,),
+        elements=hyp_st.floats(
+            # leave at least half the bits of precision (52 / 2) in the
+            # mean-subtracted result
+            1,
+            1e26,
+        ),
+    )
+    .map(
+        lambda array: (array - array.mean(keepdims=True))
+        # V this pushes the zero-mean'd values away from zero
+        * 2 ** (np.finfo(np.float64).minexp // 2)
+    )
+    .map(lambda array: pd.DataFrame(array, index=np.arange(len(array)) * 1e-1)),
+)
+def test_welch_parseval(df):
+    """
+    Test to confirm that `scaling="parseval"` maintains consistency with the
+    time-domain RMS.
+    """
+    df_psd = psd.welch(df, bin_width=1, scaling="parseval")
+    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,
+        shape=(20, 3),
+        elements=hyp_st.floats(0, 1e20),
+    ).map(lambda array: pd.DataFrame(array, index=np.arange(len(array)) * 10)),
+    freq_splits=hyp_np.arrays(
+        dtype=np.float64,
+        shape=(8,),
+        elements=hyp_st.floats(0, 200, exclude_min=True),
+        unique=True,
+    ).map(lambda array: np.sort(array)),
+)
+@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,
+    )
+
+
+@pytest.mark.skip(
+    reason="timing test -> does not enforce functionality; takes too long"
+)
+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]
+    """
+
+    t_direct = timeit.timeit(
+        "psd.to_jagged(psd_df, freq_splits, agg=np.sum)",
+        setup=setup,
+        number=3,
+    )
+    t_hist = timeit.timeit(
+        "psd.to_jagged(psd_df, freq_splits, agg='sum')",
+        setup=setup,
+        number=3,
+    )
+
+    print(f"direct form time: {t_direct}")
+    print(f"histogram time: {t_hist}")
+    assert t_hist < t_direct
+
+
+_TestStruct = namedtuple("_TestStruct", "psd_df, agg, expt_f, expt_array")
+
+
+@pytest.mark.parametrize(
+    ", ".join(_TestStruct._fields),
+    [
+        _TestStruct(
+            psd_df=pd.DataFrame([1, 0, 0, 0, 0, 0, 0, 0]),
+            agg="sum",
+            expt_f=[1, 2, 4, 8],
+            expt_array=[0, 0, 0, 0],
+        ),
+        _TestStruct(
+            psd_df=pd.DataFrame([0, 1, 0, 0, 0, 0, 0, 0]),
+            agg="sum",
+            expt_f=[1, 2, 4, 8],
+            expt_array=[1, 0, 0, 0],
+        ),
+        _TestStruct(
+            psd_df=pd.DataFrame([0, 0, 1, 0, 0, 0, 0, 0]),
+            agg="sum",
+            expt_f=[1, 2, 4, 8],
+            expt_array=[0, 1, 0, 0],
+        ),
+        _TestStruct(
+            psd_df=pd.DataFrame([0, 0, 0, 1, 1, 1, 0, 0]),
+            agg="sum",
+            expt_f=[1, 2, 4, 8],
+            expt_array=[0, 0, 3, 0],
+        ),
+        _TestStruct(
+            psd_df=pd.DataFrame([0, 0, 0, 0, 0, 0, 1, 1]),
+            agg="sum",
+            expt_f=[1, 2, 4, 8],
+            expt_array=[0, 0, 0, 2],
+        ),
+    ],
+)
+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
+    assert calc_df.to_numpy().flatten().tolist() == expt_array
diff --git a/tests/calc/test_shock.py b/tests/calc/test_shock.py
new file mode 100644
index 0000000..d4df33e
--- /dev/null
+++ b/tests/calc/test_shock.py
@@ -0,0 +1,270 @@
+# -*- coding: utf-8 -*-
+
+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
+
+from endaq.calc import shock
+
+
+@hyp.given(
+    freq=hyp_st.floats(12.5, 1000),
+    damp=hyp_st.floats(0, 1, exclude_max=True),
+)
+def test_rel_displ(freq, damp):
+    """
+    This test uses a step-function input acceleration. In a SDOF spring system,
+    the spring should be relaxed in the first portion where `a(t < t0) = 0`.
+    Once the acceleration flips on (`a(t > t0) = 1`), the mass should begin to
+    oscillate.
+
+    (This scenario is mathematically identical to having the mass pulled out
+    some distance and held steady with a constant force at `t=0`, then
+    releasing the mass at `t > t0` and letting it oscillate freely.)
+
+    This system is tested over a handful of different oscillation parameter
+    (i.e., frequency & damping rate) configurations.
+    """
+    # Data parameters
+    signal = np.zeros(1000, dtype=float)
+    signal[200:] = 1
+    fs = 10 ** 4  # Hz
+    # Other parameters
+    omega = 2 * np.pi * freq
+
+    # Calculate result
+    calc_result = (
+        shock.rel_displ(
+            pd.DataFrame(signal, index=np.arange(len(signal)) / fs),
+            omega=omega,
+            damp=damp,
+        )
+        .to_numpy()
+        .flatten()
+    )
+
+    # Calculate expected result
+    t = np.arange(1, 801) / fs
+    atten = omega * (-damp + 1j * np.sqrt(1 - damp ** 2))
+    assert np.angle(atten) == pytest.approx(np.arccos(-damp))
+    assert np.abs(atten) == pytest.approx(omega)
+
+    # γ = -ζ + i√(1 - ζ²)
+    # h(t) = (1/Im{γ}) Im{exp(γωt)}
+    # u(t) := Heaviside Step Function
+    # -> result = {h * u}(t) = ∫h(t) dt
+    #     = C + (1 / Im{γ}) Im{1/γω exp(γωt)}
+    expt_result = np.zeros_like(signal)
+    expt_result[200:] = (-1 / np.imag(atten)) * np.imag(
+        np.exp(t * atten) / atten
+    ) - 1 / omega ** 2
+
+    # Test results
+    assert np.allclose(calc_result, expt_result)
+
+
+@hyp.given(
+    freq=hyp_st.floats(12.5, 1000),
+    damp=hyp_st.floats(0, 1, exclude_max=True),
+)
+def test_abs_accel(freq, damp):
+    """
+    This test uses a step-function input acceleration. In a SDOF spring system,
+    the spring should be relaxed in the first portion where `a(t < t0) = 0`.
+    Once the acceleration flips on (`a(t > t0) = 1`), the mass should begin to
+    oscillate.
+
+    (This scenario is mathematically identical to having the mass pulled out
+    some distance and held steady with a constant force at `t=0`, then
+    releasing the mass at `t > t0` and letting it oscillate freely.)
+
+    This system is tested over a handful of different oscillation parameter
+    (i.e., frequency & damping rate) configurations.
+    """
+    # Data parameters
+    signal = np.zeros(1000, dtype=float)
+    signal[200:] = 1
+    fs = 10 ** 4  # Hz
+    # Other parameters
+    omega = 2 * np.pi * freq
+
+    # Calculate result
+    calc_result = (
+        shock.abs_accel(
+            pd.DataFrame(signal, index=np.arange(len(signal)) / fs),
+            omega=omega,
+            damp=damp,
+        )
+        .to_numpy()
+        .flatten()
+    )
+
+    # Calculate expected result
+    t = np.arange(1, 801) / fs
+    atten = omega * (-damp + 1j * np.sqrt(1 - damp ** 2))
+    assert np.angle(atten) == pytest.approx(np.arccos(-damp))
+    assert np.abs(atten) == pytest.approx(omega)
+
+    # γ = -ζ + i√(1 - ζ²)
+    # h(t) = (2ζω) Re{exp(γωt)} + ((1 - 2ζ)/Im{γ}) Im{exp(γωt)}
+    # u(t) := Heaviside Step Function
+    # -> result = {h * u}(t) = ∫h(t) dt
+    #     = C + (2ζω) Re{1/γω exp(γωt)} + ((1 - 2ζ)/Im{γ}) Im{1/γω exp(γωt)}
+    expt_result = np.zeros_like(signal)
+    expt_result[200:] = (
+        omega
+        * (
+            np.real(np.exp(t * atten) / atten) * 2 * damp
+            + np.imag(np.exp(t * atten) / atten)
+            * (1 - 2 * damp ** 2)
+            / np.sqrt(1 - damp ** 2)
+        )
+        + 1
+    )
+
+    # Test results
+    assert np.allclose(calc_result, expt_result)
+
+
+@hyp.given(
+    df_accel=hyp_np.arrays(
+        dtype=np.float64,
+        shape=(40, 2),
+        elements=hyp_st.floats(1e-20, 1e20),
+    ).map(lambda array: pd.DataFrame(array, index=np.arange(40) * 1e-4)),
+    freq=hyp_st.floats(1, 20),
+    damp=hyp_st.floats(0, 1, exclude_max=True),
+    mode=hyp_st.sampled_from(["srs", "pvss"]),
+    aggregate_axes_two_sided=hyp_st.sampled_from(
+        [(False, False), (False, True), (True, False)]
+    ),
+    factor=hyp_st.floats(-1e2, 1e2),
+)
+def test_shock_spectrum_linearity(
+    df_accel,
+    freq,
+    damp,
+    mode,
+    aggregate_axes_two_sided,
+    factor,
+):
+    aggregate_axes, two_sided = aggregate_axes_two_sided
+
+    calc_result = shock.shock_spectrum(
+        df_accel,
+        [freq],
+        damp=damp,
+        mode=mode,
+        aggregate_axes=aggregate_axes,
+        two_sided=two_sided,
+    )
+    calc_result_prescale = shock.shock_spectrum(
+        factor * df_accel,
+        [freq],
+        damp=damp,
+        mode=mode,
+        aggregate_axes=aggregate_axes,
+        two_sided=two_sided,
+    )
+    if two_sided:
+        calc_result_postscale = tuple(
+            df_calc * np.abs(factor) for df_calc in calc_result
+        )[slice(None) if factor >= 0 else slice(None, None, -1)]
+        for prescale, postscale in zip(calc_result_prescale, calc_result_postscale):
+            pd.testing.assert_frame_equal(prescale, postscale)
+    else:
+        pd.testing.assert_frame_equal(
+            calc_result_prescale,
+            calc_result.mul(factor).abs(),
+        )
+
+
+@hyp.given(
+    df_accel=hyp_np.arrays(
+        dtype=np.float64,
+        shape=(40, 2),
+        elements=hyp_st.floats(1e-20, 1e20),
+    ).map(
+        lambda array: pd.DataFrame(
+            np.concatenate([array, np.zeros_like(array)], axis=0),
+            index=np.arange(2 * array.shape[0]) * 1e-4,
+        )
+    ),
+    freq=hyp_st.floats(1, 20),
+    damp=hyp_st.floats(0, 1, exclude_max=True),
+    mode=hyp_st.sampled_from(["srs", "pvss"]),
+    aggregate_axes_two_sided=hyp_st.sampled_from(
+        [(False, False), (False, True), (True, False)]
+    ),
+)
+def test_pseudo_velocity_zero_padding(
+    df_accel, freq, damp, mode, aggregate_axes_two_sided
+):
+    aggregate_axes, two_sided = aggregate_axes_two_sided
+
+    # Check that the padding is all zeros
+    assert np.all(df_accel.iloc[40:] == 0)
+
+    # First, we calculate the PVSS of the data as-is
+    calc_result = shock.shock_spectrum(
+        df_accel.iloc[:40],
+        [freq],
+        damp=damp,
+        mode=mode,
+        aggregate_axes=aggregate_axes,
+        two_sided=two_sided,
+    )
+
+    # Now we re-run the PVSS on the full, zero-padded data
+    calc_result_padded = shock.shock_spectrum(
+        df_accel,
+        [freq],
+        damp=damp,
+        mode=mode,
+        aggregate_axes=aggregate_axes,
+        two_sided=two_sided,
+    )
+
+    # If the calculation is correct, there should be *no* amount of zero-padding
+    # that changes the result
+    if two_sided:
+        for i in range(2):
+            pd.testing.assert_frame_equal(calc_result[i], calc_result_padded[i])
+    else:
+        pd.testing.assert_frame_equal(calc_result, calc_result_padded)
+
+
+@hyp.given(
+    df_pvss=hyp_np.arrays(
+        dtype=np.float64,
+        shape=(40, 2),
+        elements=hyp_st.floats(1e-20, 1e20),
+    ).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)])
+    pulse_pvss = shock.shock_spectrum(
+        pd.DataFrame(pulse, index=times), 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
diff --git a/tests/calc/test_stats.py b/tests/calc/test_stats.py
new file mode 100644
index 0000000..3c96512
--- /dev/null
+++ b/tests/calc/test_stats.py
@@ -0,0 +1,23 @@
+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
+
+from endaq.calc import stats
+
+
+@hyp.given(
+    df=hyp_np.arrays(
+        dtype=np.float64,
+        shape=(40, 2),
+        elements=hyp_st.floats(-1e20, 1e20),
+    ).map(lambda array: pd.DataFrame(array, index=np.arange(40) * 1e-4)),
+    window_len=hyp_st.integers(1, 40),
+)
+def test_rolling_rms(df, window_len):
+    calc_result = stats.rolling_rms(df, window_len)
+    expt_result = df.rolling(window_len).apply(stats.rms)
+
+    pd.testing.assert_frame_equal(calc_result, expt_result)
diff --git a/tests/calc/test_utils.py b/tests/calc/test_utils.py
new file mode 100644
index 0000000..4b6abb0
--- /dev/null
+++ b/tests/calc/test_utils.py
@@ -0,0 +1,59 @@
+import pytest
+import hypothesis as hyp
+import hypothesis.strategies as hyp_st
+
+import numpy as np
+import pandas as pd
+
+from endaq.calc import utils
+
+
+@hyp_st.composite
+def logfreq_input(draw):
+    dt = draw(hyp_st.floats(1e-7, 1e7))
+    n = 40
+    fs = 1 / dt
+    df = fs / n
+
+    dframe = pd.DataFrame(np.zeros((n, 2)), index=dt * np.arange(n))
+    init_freq = draw(hyp_st.floats(df, fs / 2, exclude_max=True))
+    bins_per_octave = draw(hyp_st.floats(0.1, 10))
+
+    return dframe, init_freq, bins_per_octave
+
+
+@hyp.given(input_vars=logfreq_input())
+def test_logfreqs(input_vars):
+    (dframe, init_freq, bins_per_octave) = input_vars
+
+    n = len(dframe.index)
+    dt = (dframe.index[-1] - dframe.index[0]) / (n - 1)
+    fs = 1 / dt
+    df = fs / n
+
+    calc_result = utils.logfreqs(dframe, init_freq, bins_per_octave)
+
+    assert calc_result.ndim == 1
+    if init_freq > fs / 2:
+        assert len(calc_result) == 0
+        return
+    assert calc_result[0] == pytest.approx(init_freq)
+    assert fs / 2 ** (1 + 1 / bins_per_octave) < calc_result[-1] < fs / 2
+    np.testing.assert_allclose(np.diff(np.log2(calc_result)), 1 / bins_per_octave)
+
+
+@hyp.given(value=hyp_st.floats(1e-7, 1e7))
+def test_to_dB_ref(value):
+    assert utils.to_dB(value, value) == 0
+
+
+@hyp.given(
+    value=hyp_st.floats(1e-7, 1e7),
+    reference=hyp_st.floats(1e-7, 1e7),
+    squared=hyp_st.booleans(),
+)
+def test_to_dB_scale(value, reference, squared):
+    scale = 10 if squared else 20
+    assert utils.to_dB(10 * value, reference, squared) == pytest.approx(
+        scale + utils.to_dB(value, reference, squared)
+    )
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..8c8c8c0
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,3 @@
+import hypothesis
+
+hypothesis.settings.register_profile("ci", deadline=None)
diff --git a/tests/ide/__init__.py b/tests/ide/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/ide/test.ide b/tests/ide/test.ide
new file mode 100644
index 0000000..1442df0
Binary files /dev/null and b/tests/ide/test.ide differ
diff --git a/tests/ide/test_get_doc.py b/tests/ide/test_get_doc.py
new file mode 100644
index 0000000..19a64c4
--- /dev/null
+++ b/tests/ide/test_get_doc.py
@@ -0,0 +1,93 @@
+import os.path
+import unittest
+
+from idelib.dataset import Dataset
+from idelib.importer import importFile
+from endaq.ide import files
+
+
+IDE_FILENAME = os.path.join(os.path.dirname(__file__), "test.ide")
+IDE_LOCAL_URL = "file://" + IDE_FILENAME.replace('\\', '/')
+
+IDE_URL_HTTP = "http://mide.services/software/test.ide"
+IDE_URL_HTTPS = "https://mide.services/software/test.ide"
+IDE_GDRIVE_URL = "https://drive.google.com/file/d/1t3JqbZGhuZbIK9agH24YZIdVE26-NOF5/view?usp=sharing"
+
+
+class GetDocTests(unittest.TestCase):
+
+    def setUp(self):
+        self.dataset = importFile(IDE_FILENAME)
+
+
+    def test_get_doc_basics(self):
+        # Parameter tests
+        self.assertRaises(TypeError, files.get_doc)
+        self.assertRaises(TypeError, files.get_doc, IDE_FILENAME, url=IDE_LOCAL_URL)
+        self.assertRaises(TypeError, files.get_doc, filename=IDE_FILENAME, url=IDE_LOCAL_URL)
+
+
+    def test_get_doc_files(self):
+        """ Test basic opening of a file from a filename. """
+        # The baseline file
+        doc1 = files.get_doc(IDE_FILENAME)
+        self.assertEqual(len(self.dataset.ebmldoc), len(doc1.ebmldoc),
+                         "get_doc(filename) copy length did not match that of local copy")
+        self.assertTrue(all(a == b for a, b in zip(self.dataset.ebmldoc, doc1.ebmldoc)),
+                        "get_doc(filename) copy contents did not match that of local copy")
+
+        self.assertIsInstance(doc1, Dataset,
+                              "get_doc(filename) did not return a Dataset")
+
+        # Verify `name` and `filename` parameters equivalent
+        doc2 = files.get_doc(filename=IDE_FILENAME)
+        self.assertEqual(doc1.filename, doc2.filename)
+
+        # Verify a "file:" URL (with forward slashes) translates
+        doc3 = files.get_doc(IDE_LOCAL_URL)
+        self.assertEqual(doc1.filename, doc3.filename)
+
+        # Verify a "file:" URL (with Windows delimiter) translates
+        if "\\" in IDE_FILENAME:
+            doc4 = files.get_doc("file://" + IDE_FILENAME)
+            self.assertEqual(doc1.filename, doc4.filename)
+
+        # Verify validation using known non-IDE (this file)
+        self.assertRaises(ValueError, files.get_doc, __file__)
+
+
+    def test_get_doc_url(self):
+        """ Test getting an IDE from a URL. """
+        # This is admittedly a simplistic test, but it reveals a great deal.
+        # It is unlikely that only the content within EBML elements would be
+        # corrupted but not the EBML structure.
+        doc_http = files.get_doc(IDE_URL_HTTP)
+        self.assertIsInstance(doc_http, Dataset,
+                              "get_doc() did not return a Dataset")
+        self.assertEqual(len(self.dataset.ebmldoc), len(doc_http.ebmldoc),
+                         "HTTP copy length did not match that of local copy")
+        self.assertTrue(all(a == b for a, b in zip(self.dataset.ebmldoc, doc_http.ebmldoc)),
+                        "HTTP copy contents did not match that of local copy")
+
+        doc_https = files.get_doc(IDE_URL_HTTPS)
+        self.assertIsInstance(doc_https, Dataset,
+                              "get_doc() did not return a Dataset")
+        self.assertEqual(len(self.dataset.ebmldoc), len(doc_https.ebmldoc),
+                         "HTTPS copy length did not match that of local copy")
+        self.assertTrue(all(a == b for a, b in zip(self.dataset.ebmldoc, doc_https.ebmldoc)),
+                        "HTTPS copy contents did not match that of local copy")
+
+
+    def test_get_doc_gdrive(self):
+        """ Test getting an IDE from a Google Drive URL. """
+        doc = files.get_doc(IDE_GDRIVE_URL)
+        self.assertIsInstance(doc, Dataset,
+                              "get_doc() did not return a Dataset")
+        self.assertEqual(len(self.dataset.ebmldoc), len(doc.ebmldoc),
+                         "Google Drive copy length did not match that of local copy")
+        self.assertTrue(all(a == b for a, b in zip(self.dataset.ebmldoc, doc.ebmldoc)),
+                        "Google Drive copy contents did not match that of local copy")
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/ide/test_info.py b/tests/ide/test_info.py
new file mode 100644
index 0000000..d50ecc8
--- /dev/null
+++ b/tests/ide/test_info.py
@@ -0,0 +1,157 @@
+from datetime import datetime, timedelta
+import os.path
+import unittest
+
+import pytest
+import numpy as np
+from idelib.importer import importFile
+from endaq.ide import info
+
+
+IDE_FILENAME = os.path.join(os.path.dirname(__file__), "test.ide")
+
+
+@pytest.fixture
+def test_IDE():
+    with importFile(IDE_FILENAME) as ds:
+        yield ds
+
+
+class TimeParseTest(unittest.TestCase):
+    """ Test the time parsing function.
+    """
+    def test_parse_time(self):
+        """ Test basic handling of non-strings. """
+        self.assertEqual(info.parse_time(None), None)
+        self.assertEqual(info.parse_time(""), None)
+        self.assertEqual(info.parse_time(42), 42)
+        self.assertRaises(TypeError, lambda *x: info.parse_time([1]),
+                          "parse_time() accepted a bad type (list)")
+
+
+    def test_parse_time_str(self):
+        """ Test time string parsing. """
+        self.assertEqual(info.parse_time("11"), 11 * 10**6)
+        self.assertEqual(info.parse_time("22:11"), 1331000000)
+        self.assertEqual(info.parse_time("3:22:11"), 12131000000)
+        self.assertEqual(info.parse_time("1d 3:22:11"), 98531000000)
+        self.assertRaises(ValueError, lambda *x: info.parse_time("bogus"),
+                          "parse_time() accepted a bad string")
+
+
+    def test_parse_time_datetime(self):
+        """ Test `datetime.datetime` and `datetime.timedelta` parsing. """
+        t1 = datetime(2021, 7, 8, 11, 28, 40, 800752)
+        t2 = datetime(2021, 7, 7, 0, 0)
+        td = timedelta(days=1, seconds=2345, microseconds=6789)
+
+        self.assertEqual(info.parse_time(td), td.total_seconds() * 10**6)
+        self.assertEqual(info.parse_time(t1 - t2), 127720.800752 * 10**6)
+        self.assertEqual(info.parse_time(t1, t2), 127720.800752 * 10**6)
+
+
+class ChannelTableFormattingTests(unittest.TestCase):
+    """ Test the individual column value formatting functions.
+    """
+
+    def test_format_channel_id(self):
+        dataset = importFile(IDE_FILENAME)
+        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")
+
+
+    def test_format_timedelta(self):
+        # Note: only the start of strings is checked in order to avoid
+        # differences in selected number of significant digits
+        td = timedelta(seconds=0)
+        self.assertTrue(info.format_timedelta(td).startswith('00:00.'))
+
+        td = timedelta(seconds=1623430749.8969631)
+        self.assertTrue(info.format_timedelta(td).startswith('18789d 16:59:09.'))
+
+        # 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")
+
+
+    def test_format_timestamp(self):
+        for i in range(0, 10000, 123):
+            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")
+
+
+class ChannelTableTests(unittest.TestCase):
+    """ Test the "channel table" generating functionality
+    """
+    def setUp(self):
+        self.dataset = importFile(IDE_FILENAME)
+
+    def test_get_channel_table(self):
+        # TODO: Implement additional get_channel_table() tests?
+        ct = info.get_channel_table(self.dataset)
+
+        self.assertEqual(len(ct.data), len(self.dataset.getPlots()),
+                         "Length of table's data did not match number of subchannels in IDE")
+
+
+    def test_channel_table_ranges(self):
+        # Exclude channels with extremely low sample rates
+        # TODO: change acceptable delta based on sample rate
+        MIN_RATE = 10  # Hz
+        DELTA = 0.01  # seconds
+
+        # Test with a specified start
+        ct1 = info.get_channel_table(self.dataset, start="2s")
+        for n, t in enumerate(ct1.data['start']):
+            if ct1.data['rate'][n] < MIN_RATE:
+                continue
+            self.assertAlmostEqual(t / 10**6, 2, delta=DELTA, msg=f"{ct1.data['channel'][n]}")
+
+        # Test with a specified end
+        ct2 = info.get_channel_table(self.dataset, end="10s")
+        for n, t in enumerate(ct2.data['end']):
+            if ct1.data['rate'][n] < MIN_RATE:
+                continue
+            self.assertAlmostEqual(t / 10**6, 10, delta=DELTA, msg=f"{ct2.data['channel'][n]}")
+
+        # Test with both start and end specified
+        ct3 = info.get_channel_table(self.dataset, start="2s", end="10s")
+        self.assertListEqual(list(ct3.data['start']), list(ct1.data['start']))
+        self.assertListEqual(list(ct3.data['end']), list(ct2.data['end']))
+
+
+@pytest.mark.parametrize("time_mode, subchannel", [
+    ("seconds", False),
+    ("timedelta", False),
+    ("datetime", False),
+    ("seconds", True),
+    ("timedelta", True),
+    ("datetime", True),
+])
+def test_to_pandas(test_IDE, time_mode, subchannel):
+    channel = test_IDE.channels[32]
+    if subchannel:
+        channel = channel.subchannels[0]
+    eventarray = channel.getSession()
+
+    result = info.to_pandas(channel, time_mode=time_mode)
+
+    assert len(result) == len(eventarray)
+    if subchannel:
+        assert result.columns.tolist() == [channel.name]
+    else:
+        assert result.columns.tolist() == [sch.name for sch in channel.subchannels]
+    assert np.issubdtype(
+        result.index.values.dtype,
+        dict(seconds=np.float64, timedelta=np.timedelta64, datetime=np.datetime64)[time_mode],
+    )
+    assert np.all(result.to_numpy() == eventarray.arrayValues().T)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/ide/test_measurement.py b/tests/ide/test_measurement.py
new file mode 100644
index 0000000..3c2e7e5
--- /dev/null
+++ b/tests/ide/test_measurement.py
@@ -0,0 +1,129 @@
+import os.path
+import unittest
+
+from idelib.importer import importFile
+from endaq.ide import measurement
+
+
+IDE_FILENAME = os.path.join(os.path.dirname(__file__), "test.ide")
+
+
+class MeasurementTypeTests(unittest.TestCase):
+    """ Basic tests of the MeasurementType class and constant instances.
+    """
+
+    def setUp(self):
+        self.types = [m for m in measurement.__dict__.values() if isinstance(m, measurement.MeasurementType)]
+
+
+    def test_uniqueness(self):
+        # Trying to instantiate a duplicate MeasurementType should return the original instance
+        FOO = measurement.MeasurementType("Foo", "foo")
+        BAR = measurement.MeasurementType("Foo", "foo")
+        self.assertEqual(FOO, BAR)
+        self.assertIs(FOO, BAR)
+
+        # Again, this time testing against one predefined in the module
+        ACCEL = measurement.MeasurementType("Acceleration", "acc")
+        self.assertEqual(ACCEL, measurement.ACCELERATION)
+        self.assertIs(ACCEL,  measurement.ACCELERATION)
+
+
+    def test_comp(self):
+        # Test that all predefined MeasurementTypes are equal to their string equivalents
+        for mt in self.types:
+            self.assertEqual(mt, str(mt), f"{mt!r} not equal to string '{mt}'")
+
+
+    def test_strings(self):
+        # Adding MeasurementTypes concatenates their strings
+        self.assertEqual(measurement.ACCELERATION + measurement.PRESSURE,
+                         f"{measurement.ACCELERATION} {measurement.PRESSURE}")
+
+        # Negating creates a string prefixed by ``"-"``
+        self.assertEqual(-measurement.ACCELERATION,
+                         f"-{measurement.ACCELERATION}")
+
+        self.assertEqual(measurement.ACCELERATION - measurement.PRESSURE,
+                         f"{measurement.ACCELERATION} -{measurement.PRESSURE}")
+
+
+
+class GetByTypeTests(unittest.TestCase):
+    """ Test the functions that retrieve `Channel` and/or `SubChannel` objects by
+        measurement type from an IDE file (`idelib.dataset.Dataset`).
+    """
+
+    def setUp(self):
+        self.dataset = importFile(IDE_FILENAME)
+
+
+    def test_get_measurement_type(self):
+        self.assertEqual(measurement.get_measurement_type(self.dataset.channels[32][0]),
+                         measurement.ACCELERATION)
+        self.assertListEqual(measurement.get_measurement_type(self.dataset.channels[80]),
+                            [measurement.ACCELERATION]*3)
+
+
+    def test_split_types(self):
+        inc, exc = measurement.split_types("*")
+        self.assertEqual(len(measurement.MeasurementType.names), len(inc))
+        self.assertEqual(len(exc), 0)
+
+        inc, exc = measurement.split_types(measurement.ACCELERATION + measurement.PRESSURE - measurement.LIGHT)
+        self.assertIn(measurement.ACCELERATION, inc)
+        self.assertIn(measurement.PRESSURE, inc)
+        self.assertIn(measurement.LIGHT, exc)
+        self.assertTrue(measurement.LIGHT not in inc)
+        self.assertTrue(measurement.PRESSURE not in exc)
+
+
+    def test_filter_channels(self):
+        """ Test test_filter_channels() filtering of Channels (filter applies
+            if any SubChannel matches).
+        """
+        channels = self.dataset.channels
+        everything = measurement.filter_channels(channels)
+        self.assertListEqual(everything, measurement.filter_channels(list(channels.values())),
+                             "filter_channels(list) did not match filter_channels(dict)")
+
+        accels = measurement.filter_channels(channels, measurement.ACCELERATION)
+        self.assertEqual(len(accels), 2)
+
+        noaccel = measurement.filter_channels(channels, -measurement.ACCELERATION)
+        self.assertEqual(len(noaccel), len(everything) - len(accels))
+
+
+    def test_filter_channels_subchannels(self):
+        """ Test test_filter_channels() filtering of SubChannels.
+        """
+        subchannels = self.dataset.getPlots()
+        everything = measurement.filter_channels(subchannels)
+
+        accels = measurement.filter_channels(subchannels, measurement.ACCELERATION)
+        self.assertEqual(len(accels), 6)
+
+        noaccel = measurement.filter_channels(subchannels, -measurement.ACCELERATION)
+        self.assertEqual(len(noaccel), len(everything) - len(accels))
+
+
+    def test_get_channels(self):
+        everything = measurement.get_channels(self.dataset)
+
+        accels = measurement.get_channels(self.dataset, measurement.ACCELERATION)
+        self.assertEqual(len(accels), 6)
+
+        noaccel = measurement.get_channels(self.dataset, -measurement.ACCELERATION)
+        self.assertEqual(len(noaccel), len(everything) - len(accels))
+
+        everything = measurement.get_channels(self.dataset, subchannels=False)
+
+        accels = measurement.get_channels(self.dataset, measurement.ACCELERATION, subchannels=False)
+        self.assertEqual(len(accels), 2)
+
+        noaccel = measurement.get_channels(self.dataset, -measurement.ACCELERATION, subchannels=False)
+        self.assertEqual(len(noaccel), len(everything) - len(accels))
+
+
+if __name__ == '__main__':
+    unittest.main()