Repository: MideTechnology/idelib (language: python)

Issue:
Feature/70 import range
This reworks the `util.extractTime()` logic introduced in PR #60 to be more general, adding the ability to import a specific time range from a file.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index eef9eb89..7842fb1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,6 +67,7 @@ instance/
 docs/_build/
 docs/html/
 docs/latex/
+docs/doctest/
 
 # PyBuilder
 target/
@@ -112,3 +113,6 @@ venv.bak/
 
 # pycharm stuff
 .idea
+
+# Other
+.DS_Store
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 00000000..8587c541
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,16 @@
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+   configuration: docs/source/conf.py
+
+# Optionally set the version of Python and requirements required to build your docs
+python:
+   version: 3.8
+   install:
+    - method: pip
+      path: .
diff --git a/.travis.yml b/.travis.yml
index 9e0eb6a2..6d7225db 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,22 +3,15 @@ before_install:
   - python --version
 install:
   - pip install .[test]
-script: python -m pytest ./testing/ --cov=idelib-archive
+script:
+  - python -m pytest ./testing/ --cov=idelib
+  - sphinx-build -W -b html docs/source docs/html
+  - sphinx-build -W -b doctest docs/source docs/doctest
 after_success:
-  - codecov
+  - bash <(curl -s https://codecov.io/bash)
 
 matrix:
   include:
-    # This build is stalling after passing, not sure why.  Since python 3.5 is currently being tested, skip for now
-    # Windows doesn't support native python yet, so here we are - 3.5
-    # - os: windows
-    #   language: shell
-    #   env: PATH=/c/python35:/c/Python35/scripts:$PATH
-    #   before_install:
-    #     - choco install python3 --version=3.5.4
-    #     - python -m pip install virtualenv
-    #     - virtualenv $HOME/venv
-    #     - source $HOME/venv/Scripts/activate
     # Windows doesn't support native python yet, so here we are - 3.6
     - os: windows
       language: shell
@@ -42,7 +35,7 @@ matrix:
       language: shell
       env: PATH=/c/python38:/c/Python38/scripts:$PATH
       before_install:
-        - choco install python3 --version=3.8.8
+        - choco install python3 --version=3.8.10
         - python -m pip install virtualenv
         - virtualenv $HOME/venv
         - source $HOME/venv/Scripts/activate
@@ -51,29 +44,10 @@ matrix:
       language: shell
       env: PATH=/c/python39:/c/Python39/scripts:$PATH
       before_install:
-        - choco install python3 --version=3.9.2
+        - choco install python3 --version=3.9.5
         - python -m pip install virtualenv
         - virtualenv $HOME/venv
         - source $HOME/venv/Scripts/activate
-    # Windows doesn't support native python yet, so here we are - 3.10
-    - os: windows
-      language: shell
-      env:
-        - PATH=/c/python310:/c/Python310/scripts:$PATH
-        - ALLOW_FAILURES=TRUE
-      before_install:
-        - choco install python3 --pre
-        - python -m pip install virtualenv
-        - virtualenv $HOME/venv
-        - source $HOME/venv/Scripts/activate
-    - os: linux
-      language: python
-      python: "3.10-dev"
-      env: ALLOW_FAILURES=TRUE
-    - os: linux
-      language: python
-      python: "nightly"
-      env: ALLOW_FAILURES=TRUE
   allow_failures:
     - if: env(ALLOW_FAILURES)=TRUE
 
@@ -81,7 +55,6 @@ matrix:
 os: linux
 language: python
 python:
-  - "3.5"
   - "3.6"
   - "3.7"
   - "3.8"
diff --git a/README.md b/README.md
index 0ee76214..27deb065 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
-[![Build Status](https://travis-ci.com/MideTechnology/idelib.svg?branch=main)](https://travis-ci.com/MideTechnology/idelib)
+[![Build Status](https://travis-ci.com/MideTechnology/idelib.svg?branch=main)](https://travis-ci.com/MideTechnology/idelib) [![codecov](https://codecov.io/gh/MideTechnology/idelib/branch/develop/graph/badge.svg)](https://codecov.io/gh/MideTechnology/idelib)
 
 
 # _idelib_ README
 
 _idelib_ is a Python API for accessing [enDAQ's](http://endaq.com) IDE recordings.  The IDE format is
 an [EBML](http://matroska-org.github.io/libebml/specs.html) encoded file using a 
-custom schemata.  This library utilizes our 
+custom schema.  This library utilizes our 
 [ebmlite](https://github.com/MideTechnology/ebmlite) to parse the files, and 
 provides classes that make reading data simple.
 
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 00000000..6f875b85
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,2 @@
+coverage:
+  range: 50..90
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 00000000..4d9eb83d
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 00000000..e7da022b
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,46 @@
+alabaster==0.7.12
+atomicwrites==1.3.0
+attrs==19.3.0
+Babel==2.8.0
+backports.functools-lru-cache==1.6.1
+certifi==2019.11.28
+chardet==3.0.4
+configparser==4.0.2
+contextlib2==0.6.0.post1
+cycler==0.10.0
+docutils==0.16
+funcsigs==1.0.2
+idna==2.9
+imagesize==1.2.0
+importlib-metadata==1.5.0
+Jinja2==2.11.1
+kiwisolver==1.1.0
+MarkupSafe==1.1.1
+matplotlib==2.2.4
+mock==3.0.5
+more-itertools==5.0.0
+numpy==1.16.6
+packaging==20.3
+pathlib2==2.3.5
+Pillow==6.2.2
+pluggy==0.13.1
+py==1.8.1
+pyftdi==0.13.4
+Pygments==2.5.2
+pyparsing==2.4.6
+pyserial==3.4
+pytest==4.6.9
+python-dateutil==2.8.1
+pytz==2019.3
+pyusb==1.0.2
+requests==2.23.0
+scandir==1.10.0
+six==1.14.0
+snowballstemmer==2.0.0
+Sphinx==1.8.5
+sphinxcontrib-websupport==1.1.2
+subprocess32==3.5.4
+typing==3.7.4.1
+urllib3==1.25.8
+wcwidth==0.1.8
+zipp==1.2.0
diff --git a/docs/source/README.md b/docs/source/README.md
new file mode 100644
index 00000000..7d1d97dd
--- /dev/null
+++ b/docs/source/README.md
@@ -0,0 +1,1 @@
+This directory contains the 'raw' files used by [Sphinx](https://www.sphinx-doc.org) to generate the documentation.
diff --git a/docs/source/api_ref.rst b/docs/source/api_ref.rst
new file mode 100644
index 00000000..0e1a49b8
--- /dev/null
+++ b/docs/source/api_ref.rst
@@ -0,0 +1,8 @@
+`idelib` API Reference
+======================
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   idelib/dataset
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 00000000..63dd7a28
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,186 @@
+# -*- coding: utf-8 -*-
+#
+# Configuration file for the Sphinx documentation builder.
+#
+# This file does only contain a selection of the most common options. For a
+# full list see the documentation:
+# http://www.sphinx-doc.org/en/master/config
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+
+import idelib
+
+# -- Project information -----------------------------------------------------
+
+project = u'idelib'
+copyright = u'2021, Mide Technology Corp.'
+author = u''
+
+# The short X.Y version
+version = u'.'.join([str(i) for i in idelib.__version__[:2]])
+# The full version, including alpha/beta/rc tags
+release = u'.'.join([u'{}'.format(i) for i in idelib.__version__])
+
+
+# -- General configuration ---------------------------------------------------
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'sphinx.ext.autodoc',
+    'sphinx.ext.doctest',
+    'sphinx.ext.intersphinx',
+    'sphinx.ext.ifconfig',
+    'sphinx.ext.githubpages',
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = None
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = []
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = None
+
+
+autoclass_content = "both"
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'alabaster'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = []
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# The default sidebars (for documents that don't match any pattern) are
+# defined by theme itself.  Builtin themes are using these templates by
+# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
+# 'searchbox.html']``.
+#
+# html_sidebars = {}
+
+
+# -- Options for HTMLHelp output ---------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'enDAQ-Lab-docs'
+
+
+# -- Options for LaTeX output ------------------------------------------------
+
+latex_elements = {
+    # The paper size ('letterpaper' or 'a4paper').
+    #
+    # 'papersize': 'letterpaper',
+
+    # The font size ('10pt', '11pt' or '12pt').
+    #
+    # 'pointsize': '10pt',
+
+    # Additional stuff for the LaTeX preamble.
+    #
+    # 'preamble': '',
+
+    # Latex figure (float) alignment
+    #
+    # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+    (master_doc, 'enDAQLab.tex', u'enDAQ Lab Documentation',
+     u'David Randall Stokes', 'manual'),
+]
+
+
+# -- Options for manual page output ------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    (master_doc, 'endaqlab', u'enDAQ Lab Documentation',
+     [author], 1)
+]
+
+
+# -- Options for Texinfo output ----------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    (master_doc, 'enDAQ-Lab', u'enDAQ Lab Documentation',
+     author, 'enDAQ-Lab', 'One line description of project.',
+     'Miscellaneous'),
+]
+
+
+# -- Options for Epub output -------------------------------------------------
+
+# Bibliographic Dublin Core info.
+epub_title = project
+
+# The unique identifier of the text. This can be a ISBN number
+# or the project homepage.
+#
+# epub_identifier = ''
+
+# A unique identification for the text.
+#
+# epub_uid = ''
+
+# A list of files that should not be packed into the epub file.
+epub_exclude_files = ['search.html']
+
+
+# -- Extension configuration -------------------------------------------------
+
+# -- Options for intersphinx extension ---------------------------------------
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {'python': ('https://docs.python.org/2', None)}
diff --git a/docs/source/ide_basics.rst b/docs/source/ide_basics.rst
new file mode 100644
index 00000000..5fed9749
--- /dev/null
+++ b/docs/source/ide_basics.rst
@@ -0,0 +1,155 @@
+IDE File Basics
+===============
+
+What's an IDE file?
+-------------------
+
+An IDE file is a read-only hierarchical data format that stores recording information generated by an enDAQ sensor device. It contains both time-indexed data from several different kinds of sensors (like acceleration, pressure, temperature, etc.), as well as metadata about the recording device (like device serial number, model number, device name, etc.) and recording settings.
+
+
+.. _dataset_desc:
+
+Accessing an IDE file
+---------------------
+
+The top-level interface for an IDE file is the :class:`Dataset` object, through which one can access all of the above-listed information. When you open a file for reading, for example, this is the type of object that is returned.
+
+Opening an IDE File
+~~~~~~~~~~~~~~~~~~~
+
+You can open an IDE file like so::
+
+    filename = "/path/to/your/file.IDE"
+    with idelib.importFile(filename) as ds:
+        print(type(ds))
+
+Note: a :class:`Dataset` object perfoms *lazy-loading*, meaning that it only loads information as is needed. As a result, it internally retains a handle to the source file which needs to be closed after use. This can be accomplished by either:
+
+* using :class:`Dataset` as a *context manager* (as seen above; this is the recommended method), or
+* by using :class:`Dataset` as a normal object and calling the ``close()`` method manually::
+
+    filename = "/path/to/your/file.IDE"
+    ds = idelib.importFile(filename)
+    # use `ds` here
+    ds.close()  # remember to close the file after use!
+
+
+Getting recording data
+----------------------
+
+Channels & subchannels
+~~~~~~~~~~~~~~~~~~~~~~
+
+IDE files organize recording data into *channels* and *subchannels*. A channel encapsulates data recorded by a particular individual sensor on the device (e.g., XYZ acceleration from the ADXL375 DC Accelerometer); a subchannel, if present, specifies a particular data stream within a channel (e.g., the X-coordinate acceleration from the ADXL375 DC Accelerometer).
+
+At the top-level, a :class:`Dataset` object has a ``channels`` member, which is a dict of all channels recorded in the file. The dict is keyed by channel ID numbers, with :class:`Channel` objects in the values.
+
+Each :class:`Channel` object has a ``subchannels`` member, which is a list of :class:`Subchannel` objects. If the channel has no subchannels, this member will be ``None``.
+
+The channels, channel ID numbers and subchannels that may appear in a given recording file depend on the physical sensors available on the recording device, which are indicated by the device's *product number*. Below are some product number abbreviations used herein:
+
+========================= ============================================================================================== =======================
+(Abbreviated) Product No. Description                                                                                    Example Product Nos.
+========================= ============================================================================================== =======================
+S/W-D                     enDAQ S-series & W-series devices with a single digital accelerometer                          S3-D16, W5-D40
+------------------------- ---------------------------------------------------------------------------------------------- -----------------------
+S/W-DD                    enDAQ S-series & W-series devices with dual digital accelerometers                             S1-D100D40, S2-D25D16
+------------------------- ---------------------------------------------------------------------------------------------- -----------------------
+S/W-ED                    enDAQ S-series & W-series devices with an analog electrocapacitive and a digital accelerometer W8-E25D40, S4-E100D40
+------------------------- ---------------------------------------------------------------------------------------------- -----------------------
+S/W-RD                    enDAQ S-series & W-series devices with an analog piezoresistive and a digital accelerometer    S4-R500D40, W8-R2000D40
+------------------------- ---------------------------------------------------------------------------------------------- -----------------------
+SSX                       Mide Slam Stick X data recorders                                                               SSX
+------------------------- ---------------------------------------------------------------------------------------------- -----------------------
+SSC                       Mide Slam Stick C data recorders                                                               SSC
+------------------------- ---------------------------------------------------------------------------------------------- -----------------------
+SSS                       Mide Slam Stick S data recorders                                                               SSS
+========================= ============================================================================================== =======================
+
+The below table lists channel ID numbers used in a recording file based on the recording device's product number (device series numbers and accelerometer sensitivity ranges are omitted when applicable to all such devices):
+
+===================== ========== ======================================== =====================================
+Sensor                Channel ID Valid Devices                            Subchannels
+===================== ========== ======================================== =====================================
+Main Accelerometer    8          S/W-RD, S/W-ED, SSS, SSX                 X-, Y-, Z-axis Acceleration
+--------------------- ---------- ---------------------------------------- -------------------------------------
+16/200g Accelerometer 32         S/W-DD, SSX, SSS, SSC, S/W-D16, S/W-D200 X-, Y-, Z-axis Acceleration
+--------------------- ---------- ---------------------------------------- -------------------------------------
+8/40g Accelerometer   80         S/W-RD, S/W-DD, S/W-ED, S/W-D40, S/W-D8  X-, Y-, Z-axis Acceleration
+--------------------- ---------- ---------------------------------------- -------------------------------------
+IMU Gyroscope         47         All [1]_                                 X-, Y-, Z-axis Rotation
+--------------------- ---------- ---------------------------------------- -------------------------------------
+Absolute Orientation  65         All [1]_                                 X-, Y-, Z-, W-axis Quaternion; Acc
+--------------------- ---------- ---------------------------------------- -------------------------------------
+Relative Orientation  70         All [1]_                                 X-, Y-, Z-, W-axis Quaternion
+--------------------- ---------- ---------------------------------------- -------------------------------------
+MPL3115               36         All [1]_                                 Pressure, Temperature [2]_
+--------------------- ---------- ---------------------------------------- -------------------------------------
+MS8607                59         All [1]_                                 Pressure, Temperature, Humidity [3]_
+--------------------- ---------- ---------------------------------------- -------------------------------------
+SI1133                76         All [1]_                                 Lux, UV
+===================== ========== ======================================== =====================================
+
+.. [1] excluding early SSC/SSS/SSX models
+.. [2] 1 Hz Internal Measurements
+.. [3] 10 Hz Control Pad Measurements
+
+
+To simply use all recording data, we can iterate through each subchannel in a dataset like so::
+
+    for ch in ds.channels.values():
+        for sch in ch.subchannels:
+            print(sch)
+
+.. _eventarray_desc:
+
+EventArrays & raw data
+~~~~~~~~~~~~~~~~~~~~~~
+
+Each :class:`Channel` and :class:`SubChannel` object has a ``getSession()`` method, which returns an :class:`EventArray` object. :class:`EventArray` is a wrapper around a channel's underlying recording data that loads data on demand from the source file. You can index an :class:`EventArray` (e.g., ``eventarray[i]`` for some index ``i``) to get a numpy ``ndarray`` of data. Data is organized in an n-dimensional array.
+
+For subchannels, this will always be a 2-by-n array, where n is the number of samples recorded; ``eventarray[1]`` indexes the samples, ``eventarray[0]`` indexes the respective timestamps.
+
+For channels, this will be a (c+1)-by-n array, where n is the number of samples recorded and c is the number of subchannels; ``eventarray[1:]`` indexes the samples, ``eventarray[0]`` indexes the respective timestamps.
+
+
+Getting metadata
+----------------
+
+.. testsetup:: *
+
+    import pathlib
+    import idelib
+
+    ds = idelib.importFile(pathlib.Path("testing/test3.IDE"))
+    ds.filename = '/home/enDAQ/recordings/test.IDE'
+
+    eventarray = ds.channels[80].subchannels[0].getSession()
+
+:class:`Dataset` makes available some basic metadata. Some useful pieces of information are stored directly as members:
+
+.. doctest::
+    
+    >>> ds.filename
+    '/home/enDAQ/recordings/test.IDE'
+
+Other data is stored in the dict member ``recorderInfo``:
+
+.. doctest::
+
+    >>> ds.recorderInfo['RecorderSerial']
+    10118
+    >>> ds.recorderInfo['PartNumber']
+    'W8-E100D40'
+
+:class:`EventArray` also stores some sample-specific metadata, like the data's units:
+
+.. doctest::
+    
+    >>> eventarray.units
+    ('Acceleration', 'g')
+
+
+.. testcleanup::
+    
+    ds.close()
diff --git a/docs/source/idelib/dataset.rst b/docs/source/idelib/dataset.rst
new file mode 100644
index 00000000..8d09f1e5
--- /dev/null
+++ b/docs/source/idelib/dataset.rst
@@ -0,0 +1,26 @@
+dataset.py
+============
+
+.. automodule:: idelib.dataset
+
+Classes
+-------
+
+.. autoclass:: idelib.dataset.Dataset
+  :members:
+  :inherited-members:
+
+.. autoclass:: idelib.dataset.Channel
+  :members:
+  :inherited-members:
+
+.. autoclass:: idelib.dataset.SubChannel
+  :members:
+  :inherited-members:
+
+.. autoclass:: idelib.dataset.EventArray
+  :members:
+  :inherited-members:
+
+.. autoclass:: idelib.dataset.WarningRange
+  :members:
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 00000000..49eee2ec
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,20 @@
+`idelib`: a Python Library for accessing enDAQ sensor recordings
+================================================================
+
+`idelib` is a Python API for accessing `enDAQ <https://endaq.com/>`_'s IDE recordings. The IDE format is an `EBML <https://github.com/ietf-wg-cellar/ebml-specification>`_ encoded file using a custom schema. This library utilizes our `ebmlite <https://github.com/MideTechnology/ebmlite>`_ to parse the files, and provides classes that make reading data simple.
+
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   ide_basics
+   api_ref
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/examples/read_ide_file.py b/examples/read_ide_file.py
index 250257c5..9c182331 100644
--- a/examples/read_ide_file.py
+++ b/examples/read_ide_file.py
@@ -32,7 +32,7 @@
 __credits__ = ['Connor Flanigan']
 
 # Loading an ide file is very simple, the supporting files are defined by
-# default in idelib-archive.  This function returns a Document object which contains
+# default in idelib.  This function returns a Document object which contains
 # the data for the given file
 doc = idelib.importFile('../test.ide')
 
diff --git a/idelib/attributes.py b/idelib/attributes.py
new file mode 100644
index 00000000..e61796a4
--- /dev/null
+++ b/idelib/attributes.py
@@ -0,0 +1,91 @@
+"""
+Utility functions for encoding and decoding `Attribute` EBML elements,
+used in IDE and other Mide/enDAQ files, typically for diagnostic and
+debugging purposes.
+"""
+
+from collections import OrderedDict
+import datetime
+import time
+
+from ebmlite import loadSchema
+
+
+# ==============================================================================
+# 
+# ==============================================================================
+
+def decode_attributes(data, withTypes=False):
+    """ Convert a set of Attributes (as a list of dictionaries containing an
+        `AttributeName` and one of the Attribute value elements (`IntAttribute`,
+        `FloatAttribute`, etc.) to a proper dictionary. Attributes are tagged
+        as 'multiple,' so they become lists when the EBML is parsed.
+    """
+    result = OrderedDict()
+    for atts in data:
+        k = atts.pop('AttributeName', None)
+        if k is None:
+            continue
+        t, v = list(atts.items())[0]
+        if withTypes:
+            att = (v, t)
+        else:
+            att = v
+        result.setdefault(k, []).append(att)
+    return result
+
+    
+def encode_attributes(data):
+    """ Construct a set of `Attribute` dicts from a dictionary or list of
+        tuples/lists. Each value should be either a simple data type or a
+        tuple containing the value and the specific value element name
+        (IntAttribute, FloatAttribute, etc.). The value element type will 
+        otherwise be inferred.
+    """
+    datetimeTypes = (datetime.datetime, datetime.date)
+    attTypes = ((str, 'UnicodeAttribute'),
+                (bytes, 'StringAttribute'),
+                (float, 'FloatAttribute'),
+                (int, 'IntAttribute'),
+                (time.struct_time, 'DateAttribute'),
+                (datetimeTypes, 'DateAttribute'))
+    
+    if isinstance(data, dict):
+        data = list(data.items())
+    
+    result = []
+    elementType = None
+
+    for d in data:
+        if isinstance(d[1], (tuple, list)) and not isinstance(d[1], time.struct_time):
+            k = d[0]
+            v, elementType = d[1]
+        else:
+            k, v = d
+            for t, elementType in attTypes:
+                if isinstance(v, t):
+                    break
+                elementType = 'BinaryAttribute'
+        
+        if elementType == 'DateAttribute':
+            if isinstance(v, (int, float)):
+                v = int(v)
+            elif isinstance(data, datetimeTypes):
+                v = int(time.mktime(data.timetuple()))
+            elif isinstance(data, time.struct_time):
+                v = int(time.mktime(v))
+                
+        result.append({'AttributeName': k, elementType: v})
+
+    return result
+    
+
+def build_attributes(data):
+    """ Construct `Attribute` EBML from dictionary or list of key/value pairs. 
+        Each value should be either a simple data type or a tuple containing 
+        the value and the specific value element name (`IntAttribute`, 
+        `FloatAttribute`, etc.). The value element type will otherwise be 
+        inferred.
+    """
+    mideSchema = loadSchema('mide_ide.xml')
+    return mideSchema['Attribute'].encode(encode_attributes(data))
diff --git a/idelib/dataset.py b/idelib/dataset.py
index 3cddeaf2..ab8f8c4f 100644
--- a/idelib/dataset.py
+++ b/idelib/dataset.py
@@ -17,18 +17,18 @@
 #    creating more than one way to do the same thing. More often than not, 
 #    they are the result of over-engineering things.
 #
-# TODO: The new, cached EventList transforms save the combined transforms on
-#    the parent EventList. This should probably be revised to have the cached
+# TODO: The new, cached EventArray transforms save the combined transforms on
+#    the parent EventArray. This should probably be revised to have the cached
 #    transforms saved on the children; this will allow multiple copies of
-#    EventLists to have different transforms, simplifying the whole regular
+#    EventArrays to have different transforms, simplifying the whole regular
 #    versus 'display' things and make 'useAllTransforms' unnecessary. 
 #
 # TODO: Handle files with channels containing a single sample better. Right now,
 #    they are ignored, causing problems with other calculations.
 #
-# TODO: Consider an EventList subclass for subchannels to reduce the number
+# TODO: Consider an EventArray subclass for subchannels to reduce the number
 #    of conditionals evaluated, and/or see about making parent Channels' 
-#    EventLists flat.
+#    EventArrays flat.
 #   
 # TODO: Nice discontinuity handing. This will probably be conveyed as events 
 #     with null values. An attribute/keyword may be needed to suppress this when 
@@ -38,18 +38,19 @@
 #    `yield`  instead (e.g. parseElement(), etc.)
 # 
 # TODO: Consider thread safety. Use a `threading.RLock` around adding/ending
-#    a Session, updating/using Transforms, appending/accessing EventList data,
+#    a Session, updating/using Transforms, appending/accessing EventArray data,
 #    etc. Not (yet?) a serious problem, but it could be in the future; current
 #    handling of race conditions is a hack. Also make some flags (like
 #    `Dataset.loading`) properties that get/set a `threading.Event`?
 
-__all__ = ['Channel', 'Dataset', 'EventList', 'EventArray', 'Plot', 'Sensor',
-           'Session', 'SubChannel', 'WarningRange', 'Cascading',
-           'Transformable']
+__all__ = ['Channel', 'Dataset', 'EventArray', 'Plot', 'Sensor', 'Session',
+           'SubChannel', 'WarningRange', 'Cascading', 'Transformable']
 
 from bisect import bisect_right
 from collections.abc import Iterable, Sequence
 from datetime import datetime
+from threading import Lock
+import warnings
 
 from functools import partial
 import os.path
@@ -60,9 +61,10 @@
 
 from ebmlite.core import loadSchema
 import numpy as np
+import numpy.lib.recfunctions as np_recfunctions
 
 from .transforms import Transform, CombinedPoly, PolyPoly
-from .parsers import getParserTypes, getParserRanges
+from .parsers import getParserTypes, getParserRanges, ChannelDataBlock
 
 
 SCHEMA_FILE = 'mide_ide.xml'
@@ -72,7 +74,7 @@
 #===============================================================================
 
 import logging
-logger = logging.getLogger('idelib-archive')
+logger = logging.getLogger('idelib')
 logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
 
 
@@ -222,17 +224,18 @@ class Dataset(Cascading):
             for adjusting/calibrating sensor data.
     """
 
-    def __init__(self, stream, name=None, exitCondition=None, quiet=True):
+    def __init__(self, stream, name=None, quiet=True, attributes=None):
         """ Constructor. Typically, these objects will be instantiated by
             functions in the `importer` module.
         
             :param stream: A file-like stream object containing EBML data.
             :keyword name: An optional name for the Dataset. Defaults to the
                 base name of the file (if applicable).
-            :keyword exitCondition: The numeric code number for the condition
-                that stopped the recording. 
             :keyword quiet: If `True`, non-fatal errors (e.g. schema/file
                 version mismatches) are suppressed. 
+            :keyword attributes: A dictionary of arbitrary attributes, e.g.
+                ``Attribute`` elements parsed from the file. Typically
+                used for diagnostic data.
         """
         self.lastUtcTime = None
         self.sessions = []
@@ -246,8 +249,8 @@ def __init__(self, stream, name=None, exitCondition=None, quiet=True):
         self.recorderInfo = {}
         self.recorderConfig = None
 
-        self.exitCondition = exitCondition
-        
+        self.attributes = attributes if attributes else {}
+
         # For keeping track of element parsers in import.
         self._parsers = None
         
@@ -255,6 +258,8 @@ def __init__(self, stream, name=None, exitCondition=None, quiet=True):
         self.loadCancelled = False
         self.loading = True
         self.filename = getattr(stream, "name", None)
+
+        self._channelDataLock = Lock()
         
         # Subsets: used when importing multiple files into the same dataset.
         self.subsets = []
@@ -270,7 +275,7 @@ def __init__(self, stream, name=None, exitCondition=None, quiet=True):
         if stream is not None:
             schema = loadSchema(SCHEMA_FILE)
             self.schemaVersion = schema.version
-            self.ebmldoc = schema.load(stream, 'MideDocument')
+            self.ebmldoc = schema.load(stream, 'MideDocument', headers=True)
             if not quiet:
                 # It is currently assumed future versions will be backwards
                 # compatible. Change if/when not, or if certain old versions aren't.
@@ -280,6 +285,12 @@ def __init__(self, stream, name=None, exitCondition=None, quiet=True):
                                                      self.schemaVersion))
 
 
+    @property
+    def exitCondition(self):
+        """ The numeric code number for the condition that stopped the recording. """
+        return self.attributes.get('ExitCond', [None])[0]
+
+
     @property
     def channels(self):
         """ A dictionary of individual Sensor channels. """
@@ -490,6 +501,11 @@ def updateTransforms(self):
         for ch in self.channels.values():
             ch.updateTransforms()
 
+    def fillCaches(self):
+        for channel in self.channels.values():
+            for ea in channel.sessions.values():
+                ea.fillCache()
+
 
 #===============================================================================
 # 
@@ -686,6 +702,8 @@ def __init__(self, dataset, channelId=None, parser=None, sensor=None,
         self.sampleRate = sampleRate
         self.attributes = attributes
        
+        self._unitsStr = None
+
         self.cache = bool(cache)
         self.singleSample = singleSample
 
@@ -735,8 +753,32 @@ def children(self):
 
 
     def __repr__(self):
-        return '<%s %d: %r at 0x%08x>' % (self.__class__.__name__, 
-                                          self.id, self.path(), id(self))
+        try:
+            if self._unitsStr is None:
+                # If there's no cached string of measurement types and units, make it.
+                if not self.children:
+                    units = [self.units]
+                else:
+                    units = [c.units for c in self.children]
+
+                    # If all subchannel units are the same, show only one.
+                    if len(set(units)) == 1:  # Cheesy test for identical units tuples
+                        units = [units[0]]
+
+                names = []
+                for measurement, name in units:
+                    if name:
+                        name = "(%s)" % name
+                    names.append(("%s %s" % (measurement, name)).strip())
+                self._unitsStr = ", ".join(names)
+
+            s = (": %s" % self._unitsStr) if self._unitsStr else ""
+            return '<%s %d %r%s>' % (self.__class__.__name__,
+                                     self.id, self.path(), s)
+
+        except (AttributeError, TypeError, ValueError):
+            # Just in case, so __repr__() never completely fails.
+            return object.__repr__(self)
 
 
     def __getitem__(self, idx):
@@ -937,6 +979,8 @@ def __init__(self, parent, subchannelId, name=None, units=('', ''),
         self.axisName = axisName
         self.attributes = attributes
         
+        self._unitsStr = None
+
         if name is None:
             self.name = "%s:%02d" % (parent.name, subchannelId)
         else:
@@ -944,7 +988,10 @@ def __init__(self, parent, subchannelId, name=None, units=('', ''),
                 name = name.decode()
             self.name = name
             if axisName is None:
-                self.axisName = self.name.split()[0]
+                if self.name:
+                    self.axisName = self.name.split()[0]
+                else:
+                    self.axisName = name
         
         # XXX: HACK HACK HACK REMOVE ME REMOVE ME
         if self.name == "Control Pad P":
@@ -1003,6 +1050,19 @@ def __init__(self, parent, subchannelId, name=None, units=('', ''),
         self.color = color
 
 
+    def __repr__(self):
+        try:
+            super().__repr__()  # Superclass' `__repr__()` generates `_unitsStr`
+            s = (": %s" % self._unitsStr) if self._unitsStr else ""
+            return '<%s %d.%d %r%s>' % (self.__class__.__name__,
+                                        self.parent.id, self.id,
+                                        self.path(), s)
+
+        except (AttributeError, TypeError, ValueError):
+            # Just in case, so __repr__() never completely fails.
+            return object.__repr__(self)
+
+
     @property
     def children(self):
         return []
@@ -1013,11 +1073,6 @@ def sampleRate(self):
         return self.parent.sampleRate
 
 
-    def __repr__(self):
-        return '<%s %d.%d: %r at 0x%08x>' % (self.__class__.__name__, 
-                                             self.parent.id, self.id, 
-                                             self.path(), id(self))
-
     def __len__(self):
         raise AttributeError('SubChannel has no children.')
 
@@ -1107,16 +1162,17 @@ def __eq__(self, other):
                
 
 #===============================================================================
-# 
+#
 #===============================================================================
 
-class EventList(Transformable):
+
+class EventArray(Transformable):
     """ A list-like object containing discrete time/value pairs. Data is 
         dynamically read from the underlying EBML file. 
     """
 
-    # Default 5 second rolling mean
-    DEFAULT_MEAN_SPAN = 5000000
+    # Width of rolling mean (microseconds), or -1 for total mean removal.
+    DEFAULT_MEAN_SPAN = -1
 
     def __init__(self, parentChannel, session=None, parentList=None):
         """ Constructor. This should almost always be done indirectly via
@@ -1164,6 +1220,7 @@ def __init__(self, parentChannel, session=None, parentList=None):
 
         self.removeMean = False
         self.hasMinMeanMax = True
+        self._rollingMeanSpan = None
         self.rollingMeanSpan = self.DEFAULT_MEAN_SPAN
 
         self.transform = None
@@ -1175,8 +1232,53 @@ def __init__(self, parentChannel, session=None, parentList=None):
             self.parseBlock = self.parent.parseBlock
         else:
             self.parseBlock = self.parent.parent.parseBlock
-        
-        
+
+        self._blockIndicesArray = np.array([], dtype=np.float64)
+        self._blockTimesArray = np.array([], dtype=np.float64)
+
+        self._mean = None
+
+        _format = self.parent.parser.format
+        if isinstance(self.parent, SubChannel):
+            self._npType = self.parent.parent.getSession()._npType[self.subchannelId]
+        elif len(_format) == 0:
+            self._npType = np.uint8
+        else:
+            if isinstance(_format, bytes):
+                _format = _format.decode()
+
+            if _format[0] in ['<', '>', '=']:
+                endian = _format[0]
+                dtypes = [endian + ChannelDataBlock.TO_NP_TYPESTR[x] for x in _format[1:]]
+            else:
+                dtypes = [ChannelDataBlock.TO_NP_TYPESTR[x] for x in str(_format)]
+
+            self._npType = np.dtype([(str(i), dtype) for i, dtype in enumerate(dtypes)])
+
+        self._channelDataLock = parentChannel.dataset._channelDataLock
+        self._cacheArray = None
+        self._cacheBytes = None
+        self._fullyCached = False
+        self._cacheStart = None
+        self._cacheEnd = None
+        self._cacheBlockStart = None
+        self._cacheBlockEnd = None
+        self._cacheLen = 0
+
+
+    @property
+    def rollingMeanSpan(self):
+        return self._rollingMeanSpan
+
+
+    @rollingMeanSpan.setter
+    def rollingMeanSpan(self, value):
+        if value != -1:
+            warnings.warn('Rolling mean has been deprecated, this behavior has '
+                          'been replaced with total mean removal.')
+        self._rollingMeanSpan = value
+
+
     def updateTransforms(self, recurse=True):
         """ (Re-)Build and (re-)apply the transformation functions.
         """
@@ -1226,7 +1328,11 @@ def units(self):
 
 
     def path(self):
-        return "%s, %s" % (self.parent.path(), self.session.sessionId)
+        """ Get the combined names of all the object's parents/grandparents.
+        """
+        if self.session and self.session.sessionId:
+            return "%s (session %s)" % (self.parent.path(), self.session.sessionId)
+        return self.parent.path()
 
 
     def copy(self, newParent=None):
@@ -1243,6 +1349,14 @@ def copy(self, newParent=None):
         newList.noBivariates = self.noBivariates
         newList._blockIndices = self._blockIndices
         newList._blockTimes = self._blockTimes
+        newList._channelDataLock = self._channelDataLock
+        newList._cacheArray = self._cacheArray
+        newList._cacheBytes = self._cacheBytes
+        newList._fullyCached = self._fullyCached
+        newList._cacheStart = self._cacheStart
+        newList._cacheEnd = self._cacheEnd
+        newList._cacheBlockStart = self._cacheBlockStart
+        newList._cacheBlockEnd = self._cacheBlockEnd
         return newList
     
 
@@ -1254,76 +1368,82 @@ def append(self, block):
             
             :attention: Added elements must be in chronological order!
         """
-        if block.numSamples is None:
-            block.numSamples = block.getNumSamples(self.parent.parser)
+        with self._channelDataLock:
+            if block.numSamples is None:
+                block.numSamples = block.getNumSamples(self.parent.parser)
 
-        # Set the session first/last times if they aren't already set.
-        # Possibly redundant if all sessions are 'closed.'
-        if self.session.firstTime is None:
-            self.session.firstTime = block.startTime
-        else:
-            self.session.firstTime = min(self.session.firstTime, block.startTime)
+            # Set the session first/last times if they aren't already set.
+            # Possibly redundant if all sessions are 'closed.'
+            if self.session.firstTime is None:
+                self.session.firstTime = block.startTime
+            else:
+                self.session.firstTime = min(self.session.firstTime, block.startTime)
 
-        if self.session.lastTime is None:
-            self.session.lastTime = block.endTime
-        else:
-            self.session.lastTime = max(self.session.lastTime, block.endTime)
+            if self.session.lastTime is None:
+                self.session.lastTime = block.endTime
+            else:
+                self.session.lastTime = max(self.session.lastTime, block.endTime)
+
+            # Check that the block actually contains at least one sample.
+            if block.numSamples < 1:
+                # Ignore blocks with empty payload. Could occur in FW <17.
+                # TODO: Make sure this doesn't hide too many errors!
+                logger.warning("Ignoring block with bad payload size for %r" % self)
+                return
+
+            block.cache = self.parent.cache
+            oldLength = self._length
+
+            block.blockIndex = len(self._data)
+            block.indexRange = (oldLength, oldLength + block.numSamples)
+
+            # _singleSample hint not explicitly set; set it based on this block.
+            # There will be problems if the first block has only one sample, but
+            # future ones don't. This shouldn't happen, though.
+            if self._singleSample is None:
+                self._singleSample = block.numSamples == 1
+                if self._parentList is not None:
+                    self._parentList._singleSample = self._singleSample
+                if self.parent.singleSample is None:
+                    self.parent.singleSample = self._singleSample
+                if self.parent.parent is not None:
+                    self.parent.parent.singleSample = self._singleSample
+
+            # HACK (somewhat): Single-sample-per-block channels get min/mean/max
+            # which is just the same as the value of the sample. Set the values,
+            # but don't set hasMinMeanMax.
+            if self._singleSample is True:# and not self.hasMinMeanMax:
+                block.minMeanMax = np.tile(block.payload, 3)
+                mmmArr = np_recfunctions.structured_to_unstructured(
+                        block._minMeanMax.view(self._npType))
+                block.min, block.mean, block.max = mmmArr
+                self.hasMinMeanMax = False
+            elif block.minMeanMax is not None:
+                mmmArr = np_recfunctions.structured_to_unstructured(
+                    np.frombuffer(block.minMeanMax, self._npType))
+                block.min, block.mean, block.max = mmmArr
+                self.hasMinMeanMax = True
+            else:
+                # XXX: Attempt to calculate min/mean/max here instead of
+                #  in _computeMinMeanMax(). Causes issues with pressure for some
+                #  reason - it starts removing mean and won't plot.
+                vals = np_recfunctions.structured_to_unstructured(block.payload.view(self._npType))
+                block.min = vals.min(axis=-1)
+                block.mean = vals.mean(axis=-1)
+                block.max = vals.max(axis=-1)
+                self.hasMinMeanMax = True
+
+            # Cache the index range for faster searching
+            self._blockIndices.append(oldLength)
+            self._blockTimes.append(block.startTime)
+
+            self._hasSubsamples = self._hasSubsamples or block.numSamples > 1
+
+            self._data.append(block)
+            self._length += block.numSamples
+
+            block._payload = np.frombuffer(block._payloadEl.dump(), dtype=self._npType)
 
-        # Check that the block actually contains at least one sample.
-        if block.numSamples < 1:
-            # Ignore blocks with empty payload. Could occur in FW <17.
-            # TODO: Make sure this doesn't hide too many errors!
-            logger.warning("Ignoring block with bad payload size for %r" % self)
-            return
-        
-        block.cache = self.parent.cache
-        oldLength = self._length
-
-        block.blockIndex = len(self._data)
-        block.indexRange = (oldLength, oldLength + block.numSamples)
-
-        # _singleSample hint not explicitly set; set it based on this block. 
-        # There will be problems if the first block has only one sample, but
-        # future ones don't. This shouldn't happen, though.
-        if self._singleSample is None:
-            self._singleSample = block.numSamples == 1
-            if self._parentList is not None:
-                self._parentList._singleSample = self._singleSample
-            if self.parent.singleSample is None:
-                self.parent.singleSample = self._singleSample
-            if self.parent.parent is not None:
-                self.parent.parent.singleSample = self._singleSample
-
-        # HACK (somewhat): Single-sample-per-block channels get min/mean/max
-        # which is just the same as the value of the sample. Set the values,
-        # but don't set hasMinMeanMax.
-        if self._singleSample is True:# and not self.hasMinMeanMax:
-            block.minMeanMax = np.tile(block.payload, 3)
-            block.parseMinMeanMax(self.parent.parser)
-            self.hasMinMeanMax = False
-        elif block.minMeanMax is not None:
-            block.parseMinMeanMax(self.parent.parser)
-            self.hasMinMeanMax = True #self.hasMinMeanMax and True
-        else:
-            # XXX: Attempt to calculate min/mean/max here instead of 
-            # in _computeMinMeanMax(). Causes issues with pressure for some
-            # reason - it starts removing mean and won't plot.
-            vals = self.parseBlock(block)
-            block.min = vals.min(axis=-1)
-            block.mean = vals.mean(axis=-1)
-            block.max = vals.max(axis=-1)
-            self.hasMinMeanMax = True
-#             self.hasMinMeanMax = False
-#             self.allowMeanRemoval = False
-
-        # Cache the index range for faster searching
-        self._blockIndices.append(oldLength)
-        self._blockTimes.append(block.startTime)
-
-        self._hasSubsamples = self._hasSubsamples or block.numSamples > 1
-
-        self._data.append(block)
-        self._length += block.numSamples
 
     @property
     def _firstTime(self):
@@ -1341,66 +1461,20 @@ def getInterval(self):
 
         return self._firstTime, self._lastTime
 
-
-    def _getBlockIndexRange(self, blockIdx):
-        """ Get the first and last index of the subsamples within a block,
-            as if the channel were just a flat list of subsamples.
-        """
-        block = self._data[blockIdx]
-        # EventList.append() should set block.indexRange. In case it didn't:
-        if block.indexRange is None:
-            total = 0
-            for i in range(blockIdx+1):
-                if self._data[i].indexRange is None:
-                    numSamples = block.getNumSamples(self.parent.parser)
-                    self._data[i].indexRange = (total, total+numSamples)
-                    total += numSamples 
-        return block.indexRange
-            
-
-    def _getBlockTimeRange(self, blockIdx):
-        """ Get the start and end times of an individual data block.
-            Note that this takes an index, not a reference to the actual
-            element itself!
-
-            :param blockIdx: The index of the block to check.
-            :return: A tuple with the blocks start and end times.
-        """
-        block = self._data[blockIdx]
-        try:
-            return block._timeRange
-        except AttributeError:
-            if block.endTime is None:
-                # Probably a SimpleChannelDataBlock, which doesn't record end.
-                if len(self._data) == 1:
-                    # Can't compute without another block's start.
-                    # Don't cache; another thread may still be loading document
-                    # TODO: Have sensor description provide nominal sample rate?
-                    return block.startTime, None
-                
-                elif block.numSamples <= 1:
-                    block.endTime = block.startTime + self._getBlockSampleTime(blockIdx)
-    
-                elif blockIdx < len(self._data)-1:
-                    block.endTime = self._data[blockIdx+1].startTime - \
-                                    self._getBlockSampleTime(blockIdx)
-                else:
-                    block.endTime = block.startTime + \
-                                    (block.getNumSamples(self.parent.parser)-1) * \
-                                    self._getBlockSampleTime(blockIdx)
-                
-            block._timeRange = block.startTime, block.endTime
-            return block._timeRange
+    #===========================================================================
+    # Old utility methods
+    #===========================================================================
         
-
     def _getBlockIndexWithIndex(self, idx, start=0, stop=None):
         """ Get the index of a raw data block that contains the given event
             index.
-            
+
             :param idx: The event index to find
             :keyword start: The first block index to search
             :keyword stop: The last block index to search
         """
+        # TODO: profile & determine if this change is beneficial
+        '''
         if stop:
             blockIdx = bisect_right(self._blockIndices, idx, start, stop)
         else:
@@ -1408,15 +1482,26 @@ def _getBlockIndexWithIndex(self, idx, start=0, stop=None):
         if blockIdx:
             return blockIdx-1
         return blockIdx
-    
-    
+
+        '''
+        if len(self._blockIndicesArray) != len(self._blockIndices):
+            self._blockIndicesArray = np.array(self._blockIndices)
+
+        idxOffset = max(start, 1)
+        return idxOffset-1 + np.searchsorted(
+            self._blockIndicesArray[idxOffset:stop], idx, side='right'
+        )
+
+
     def _getBlockIndexWithTime(self, t, start=0, stop=None):
         """ Get the index of a raw data block in which the given time occurs.
-        
+
             :param t: The time to find
             :keyword start: The first block index to search
             :keyword stop: The last block index to search
         """
+        # TODO: profile & determine if this change is beneficial
+        '''
         if stop:
             blockIdx = bisect_right(self._blockTimes, t, start, stop)
         else:
@@ -1424,9 +1509,17 @@ def _getBlockIndexWithTime(self, t, start=0, stop=None):
         if blockIdx:
             return blockIdx-1
         return blockIdx
+        '''
+        if len(self._blockTimesArray) != len(self._blockTimes):
+            self._blockTimesArray = np.array(self._blockTimes)
 
+        idxOffset = max(start, 1)
+        return idxOffset-1 + np.searchsorted(
+            self._blockTimesArray[idxOffset:stop], t, side='right'
+        )
 
-    def _getBlockRollingMean(self, blockIdx, force=False):
+
+    def _getBlockRollingMean_old(self, blockIdx, force=False):
         """ Get the mean of a block and its neighbors within a given time span.
             Note: Values are taken pre-calibration, and all subchannels are
             returned.
@@ -1482,69 +1575,67 @@ def _getBlockRollingMean(self, blockIdx, force=False):
             # May no longer occur with new EBML library.
 #             logger.info( "Type error!")
             return None
-    
+
+
+    def _getBlockRollingMean(self, blockIdx, force=False):
+        """ Get the mean of a block and its neighbors within a given time span.
+            Note: Values are taken pre-calibration, and all subchannels are
+            returned.
+            
+            :param blockIdx: The index of the block to check.
+            :return: An array containing the mean values of each subchannel. 
+        """
+        if isinstance(blockIdx, Sequence):
+            blockIdx = np.array(blockIdx)
+        elif not isinstance(blockIdx, np.ndarray):
+            return self._getBlockRollingMean_old(blockIdx, force)
+
+        uniqueBlockIndices, blocksPerm = np.unique(blockIdx, return_inverse=True)
+
+        uniqueBlockMeans = np.stack([
+            self._getBlockRollingMean_old(idx, force)
+            for idx in uniqueBlockIndices
+        ], axis=-1)
+        return uniqueBlockMeans[:, blocksPerm]
+
+
+    #===========================================================================
+    # Main API methods
+    #===========================================================================
 
     def __getitem__(self, idx, display=False):
         """ Get a specific data point by index.
-        
-            :param idx: An index, a `slice`, or a
-             tuple of one or both
+
+            :param idx: An integer index or a `slice`
             :return: For single results, a tuple containing (time, value).
-                For multiple results, a list of (time, value) tuples.
+                For multiple results, a structured array of (time, value)
+                pairs.
         """
         # TODO: Cache this; a Channel's SubChannels will often be used together.
-        if self.useAllTransforms:
-            xform = self._fullXform
-            if display:
-                xform = self._displayXform or xform
-        else:
+        if not self.useAllTransforms:
             xform = self._comboXform
-            
-        if isinstance(idx, int):
-            
+        elif display:
+            xform = self._displayXform or self._fullXform
+        else:
+            xform = self._fullXform
+
+        if isinstance(idx, (int, np.integer)):
+
+
             if idx >= len(self):
-                raise IndexError("EventList index out of range")
-            
+                raise IndexError("EventArray index out of range")
+
             if idx < 0:
                 idx = max(0, len(self) + idx)
-            
-            blockIdx = self._getBlockIndexWithIndex(idx)
-            subIdx = idx - self._getBlockIndexRange(blockIdx)[0]
-            
-            block = self._data[blockIdx]
-            
-            t = block.startTime + self._getBlockSampleTime(blockIdx) * subIdx
-            val = self.parent.parseBlock(block, start=subIdx, end=subIdx+1)[0]
-            
-            eventx = xform(t, val, session=self.session, noBivariates=self.noBivariates)
-            if eventx is None:
-                logger.info( "%s: bad transform %r %r" % (self.parent.name,t, val))
-                sleep(0.001)
-                eventx = xform(t, val, session=self.session, noBivariates=self.noBivariates)
-                if eventx is None:
-                    return None
-            tx, valx = eventx
-
-            m = self._getBlockRollingMean(blockIdx)
-            if m is not None:
-                mx = xform(t, m, session=self.session, noBivariates=self.noBivariates)
-                if mx is None:
-                    logger.info( "%s: bad offset @%s" % (self.parent.name,t))
-                    sleep(0.001)
-                    mx = xform(t, m, session=self.session, noBivariates=self.noBivariates)
-                valx = tuple(valx - np.array(mx[1]))
-                
-            if self.hasSubchannels:
-                return (tx,) + valx
-            else:
-                # Doesn't quite work; transform dataset attribute not set?
-                return (tx, valx[self.subchannelId])
+
+            return self.arraySlice(idx, idx + 1)[:, 0]
 
         elif isinstance(idx, slice):
-            return list(self.iterSlice(idx.start, idx.stop, idx.step))
-        
-        else:
-            raise TypeError("EventList indices must be integers or slices, not %s (%r)" % (type(idx), idx))
+            # vvv Main difference from `EventList.__getitem__` vvv
+            return self.arraySlice(idx)
+
+        raise TypeError("EventArray indices must be integers or slices,"
+                        " not %s (%r)" % (type(idx), idx))
 
 
     def __iter__(self):
@@ -1603,68 +1694,49 @@ def __eq__(self, other):
                and self.allowMeanRemoval == other.allowMeanRemoval 
 
 
-    def itervalues(self, start=None, end=None, step=1, subchannels=True, display=False):
-        """ Iterate all values in the list (no times).
-        
+    def itervalues(self, start=None, end=None, step=1, subchannels=True,
+                   display=False):
+        """ Iterate all values in the given index range (w/o times).
+
             :keyword start: The first index in the range, or a slice.
             :keyword end: The last index in the range. Not used if `start` is
                 a slice.
             :keyword step: The step increment. Not used if `start` is a slice.
             :keyword subchannels: A list of subchannel IDs or Boolean. `True`
                 will return all subchannels in native order.
-            :keyword display: If `True`, the `EventList` transform (i.e. the 
+            :keyword display: If `True`, the `EventArray` transform (i.e. the
                 'display' transform) will be applied to the data.
+            :return: an iterable of structured array value blocks in the
+                specified index range.
         """
-        # TODO: Optimize; times don't need to be computed since they aren't used
-        if self.hasSubchannels and subchannels != True:
-            # Create a function instead of chewing the subchannels every time
-            chFilter = eval("lambda x: (%s,)" % ",".join(
-                "x[%d]" % (1+ch) for ch in subchannels
-            ))
-            return (chFilter(event)
-                    for event in self.iterSlice(start, end, step, display))
-        else:
-            return (event[1:]
-                    for event in self.iterSlice(start, end, step, display))
 
+        warnings.warn(DeprecationWarning('iter methods should be expected to be '
+                                         'removed in future versions of idelib'))
+
+        out = self.arrayValues(start=start, end=end, step=step)
+
+        yield from out.T
+
+
+    def arrayValues(self, start=None, end=None, step=1, subchannels=True,
+                    display=False):
+        """ Get all values in the given index range (w/o times).
 
-    def iterSlice(self, start=None, end=None, step=1, display=False):
-        """ Create an iterator producing events for a range indices.
-        
             :keyword start: The first index in the range, or a slice.
             :keyword end: The last index in the range. Not used if `start` is
                 a slice.
             :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword display: If `True`, the `EventList` transform (i.e. the 
+            :keyword subchannels: A list of subchannel IDs or Boolean. `True`
+                will return all subchannels in native order.
+            :keyword display: If `True`, the `EventArray` transform (i.e. the
                 'display' transform) will be applied to the data.
+            :return: a structured array of values in the specified index range.
         """
-        # TODO: optimization: refactor calls of iterSlice() to pass slices?
+
         if not isinstance(start, slice):
             start = slice(start, end, step)
         start, end, step = start.indices(len(self))
 
-        startBlockIdx = self._getBlockIndexWithIndex(start) if start > 0 else 0
-        endBlockIdx = self._getBlockIndexWithIndex(end-1, start=startBlockIdx)
-
-        blockStep = max(1, (step + 0.0) / self._data[startBlockIdx].numSamples)
-        numBlocks = int((endBlockIdx - startBlockIdx) / blockStep)+1
-        
-        subIdx = start - self._getBlockIndexRange(startBlockIdx)[0]
-        endSubIdx = end - self._getBlockIndexRange(endBlockIdx)[0]
-
-        # OPTIMIZATION: making local variables for faster access
-        parent = self.parent
-        parent_parseBlock = parent.parseBlock
-        session = self.session
-        hasSubchannels = self.hasSubchannels
-        if not hasSubchannels:
-            subchannelId = parent.id 
-        _data = self._data
-        _getBlockSampleTime = self._getBlockSampleTime
-        _getBlockRollingMean = self._getBlockRollingMean
-        removeMean = self.allowMeanRemoval and self.removeMean #and self.hasMinMeanMax
-        offset = None
-
         if self.useAllTransforms:
             xform = self._fullXform
             if display:
@@ -1672,145 +1744,190 @@ def iterSlice(self, start=None, end=None, step=1, display=False):
         else:
             xform = self._comboXform
 
-        # in each block, the next subIdx is (step+subIdx)%numSamples
-        for i in range(numBlocks):
-            blockIdx = int(startBlockIdx + (i * blockStep))
-            block = _data[blockIdx]
-            sampleTime = _getBlockSampleTime(blockIdx)
-            lastSubIdx = endSubIdx if blockIdx == endBlockIdx else block.numSamples
-            times = (block.startTime + sampleTime * t for t in range(subIdx, lastSubIdx, step))
-            
-            values = parent_parseBlock(block, start=subIdx, end=lastSubIdx, step=step)
-
-            if removeMean:
-                offset = _getBlockRollingMean(blockIdx)
-                if offset is None:
-                    logger.info( "%s: bad offset (1) @%s" % (self.parent.name,block.startTime))
-                    sleep(0.001)
-                    offset = _getBlockRollingMean(blockIdx)
-                mx = xform(block.startTime, offset, session=session, noBivariates=self.noBivariates)
-                if mx is None:
-                    logger.info( "%s: bad offset(2) @%s" % (self.parent.name,block.startTime))
-                    sleep(0.001)
-                    mx = xform(block.startTime, offset, session=session, noBivariates=self.noBivariates)
-                if mx is not None:
-                    offset = np.array(mx[1])
-                
-            for t, vals in zip(times, values):
-                eventx = xform(t, vals, session=session, noBivariates=self.noBivariates)
-                if eventx is None:
-                    logger.info( "%s: bad transform @%s" % (self.parent.name,t))
-                    sleep(0.001)
-                    eventx = xform(t, vals, session=session, noBivariates=self.noBivariates)
-                t, vals = eventx
-                    
-                if offset is not None:
-                    vals = tuple(vals-offset)
-                if hasSubchannels:
-                    yield (t,) + vals
-                else:
-                    yield (t, vals[subchannelId])
-            
-            subIdx = (lastSubIdx-1+step) % block.numSamples
+        rawData = self._accessCache(start, end, step)
 
+        if isinstance(self.parent, SubChannel):
+            out = np.empty((1, len(rawData)))
+        else:
+            out = np.empty((len(rawData.dtype), len(rawData)))
+
+        if isinstance(self.parent, SubChannel):
+            xform.polys[self.subchannelId].inplace(rawData, out=out)
+        else:
+            xform.inplace(np_recfunctions.structured_to_unstructured(rawData).T, out=out)
+
+        if self.removeMean:
+            out[1:] -= out[1:].mean(axis=1, keepdims=True)
+
+        if isinstance(subchannels, Iterable):
+            return out[list(subchannels)]
+        else:
+            return out
+
+
+    def iterSlice(self, start=None, end=None, step=1, display=False):
+        """ Create an iterator producing events for a range of indices.
+
+            :keyword start: The first index in the range, or a slice.
+            :keyword end: The last index in the range. Not used if `start` is
+                a slice.
+            :keyword step: The step increment. Not used if `start` is a slice.
+            :keyword display: If `True`, the `EventArray` transform (i.e. the
+                'display' transform) will be applied to the data.
+            :return: an iterable of events in the specified index range.
+        """
+
+        warnings.warn(DeprecationWarning('iter methods should be expected to be '
+                                         'removed in future versions of idelib'))
+
+        out = self.arraySlice(start=start, end=end, step=step, display=display)
+
+        yield from out.T
+
+
+    def arraySlice(self, start=None, end=None, step=1, display=False):
+        """ Create an array of events within a range of indices.
 
-    def iterJitterySlice(self, start=None, end=None, step=1, jitter=0.5, display=False):
-        """ Create an iterator producing events for a range indices.
-        
             :keyword start: The first index in the range, or a slice.
             :keyword end: The last index in the range. Not used if `start` is
                 a slice.
             :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword jitter: The amount to vary the sample time, as a normalized
-                percent of the regular time between samples.
-            :keyword display: If `True`, the `EventList` transform (i.e. the 
+            :keyword display: If `True`, the `EventArray` transform (i.e. the
                 'display' transform) will be applied to the data.
+            :return: a structured array of events in the specified index range.
         """
-        # TODO: optimization: refactor calls of iterJitterySlice() to pass slices?
+
         if not isinstance(start, slice):
             start = slice(start, end, step)
         start, end, step = start.indices(len(self))
-        
-        startBlockIdx = self._getBlockIndexWithIndex(start) if start > 0 else 0
-        endBlockIdx = self._getBlockIndexWithIndex(end-1, start=startBlockIdx)
-
-        blockStep = max(1, (step + 0.0) / self._data[startBlockIdx].numSamples)
-        numBlocks = int((endBlockIdx - startBlockIdx) / blockStep)+1
-        
-        subIdx = start - self._getBlockIndexRange(startBlockIdx)[0]
-        endSubIdx = end - self._getBlockIndexRange(endBlockIdx)[0]
 
-        # OPTIMIZATION: making local variables for faster access
-        parent = self.parent
-        parent_parseBlockByIndex = parent.parseBlockByIndex
-        session = self.session
-        hasSubchannels = self.hasSubchannels
-        if not hasSubchannels:
-            subchannelId = parent.id 
-        _data = self._data
-        _getBlockSampleTime = self._getBlockSampleTime
-        _getBlockRollingMean = self._getBlockRollingMean
-        removeMean = self.allowMeanRemoval and self.removeMean
-        offset = None
-        
         if self.useAllTransforms:
             xform = self._fullXform
             if display:
                 xform = self._displayXform or xform
         else:
             xform = self._comboXform
-        
-        # in each block, the next subIdx is (step+subIdx)%numSamples
-        for i in range(numBlocks):
-            blockIdx = int(startBlockIdx + (i * blockStep))
-            block = _data[blockIdx]
-            sampleTime = _getBlockSampleTime(i)
-            lastSubIdx = endSubIdx if blockIdx == endBlockIdx else block.numSamples
-            
-            indices = list(range(subIdx, lastSubIdx, step))
-            if step > 1:
-                for x in range(2, len(indices)-1):
-                    indices[x] = int(indices[x] + (((random.random()*2)-1) * jitter * step))
-                
-            times = (block.startTime + sampleTime * t for t in indices)
-            values = parent_parseBlockByIndex(block, indices) 
-
-            # Note: _getBlockRollingMean returns None if removeMean==False
-            if removeMean:
-                offset = _getBlockRollingMean(blockIdx)
-                if offset is None:
-                    sleep(0.001)
-                    offset = _getBlockRollingMean(blockIdx)
-                
-                mx = xform(block.startTime, offset, session=session, noBivariates=self.noBivariates)
-                if mx is None:
-                    # Thread-induced race condition? Try again.
-                    logger.warning("iterJitterySlice: offset is None")
-                    sleep(0.001)
-                    mx = xform(block.startTime, offset, session=session, noBivariates=self.noBivariates)
-                offset = np.array(mx[1])
-                
-            for t, vals in zip(times, values):
-                eventx = xform(t, vals, session, noBivariates=self.noBivariates)
-                if eventx is None:
-                    # Thread-induced race condition? Try again.
-                    sleep(0.001)
-                    eventx = xform(t, vals, session, noBivariates=self.noBivariates)
-                t, vals = eventx
-                    
-                if offset is not None:
-                    vals = tuple(vals-offset)
-                else:
-                    logger.info('%r event offset is None' % self.parent.name)
-                if hasSubchannels:
-                    yield (t,) + vals
-                else:
-                    yield (t, vals[subchannelId])
 
-            subIdx = (lastSubIdx-1+step) % block.numSamples
+        rawData = self._accessCache(start, end, step)
 
-      
-    def getEventIndexBefore(self, t):
+        if isinstance(self.parent, SubChannel):
+            out = np.empty((2, len(rawData)))
+        else:
+            out = np.empty((len(rawData.dtype) + 1, len(rawData)))
+
+        self._inplaceTime(start, end, step, out=out[0])
+
+        if isinstance(self.parent, SubChannel):
+            xform.polys[self.subchannelId].inplace(rawData, out=out[1], timestamp=out[0])
+        else:
+            xform.inplace(np_recfunctions.structured_to_unstructured(rawData).T, out=out[1:], timestamp=out[0])
+
+        if self.removeMean:
+            out[1:] -= out[1:].mean(axis=1, keepdims=True)
+
+        return out
+
+
+    def iterJitterySlice(self, start=None, end=None, step=1, jitter=0.5,
+                         display=False):
+        """ Create an iterator producing events for a range of indices.
+
+            :keyword start: The first index in the range, or a slice.
+            :keyword end: The last index in the range. Not used if `start` is
+                a slice.
+            :keyword step: The step increment. Not used if `start` is a slice.
+            :keyword jitter: The amount by which to vary the sample time, as a
+                normalized percentage of the regular time between samples.
+            :keyword display: If `True`, the `EventArray` transform (i.e. the
+                'display' transform) will be applied to the data.
+            :return: an iterable of events in the specified index range.
+        """
+
+        warnings.warn(DeprecationWarning('iter methods should be expected to be '
+                                         'removed in future versions of idelib'))
+
+        self._computeMinMeanMax()
+
+        data = self.arrayJitterySlice(start=start, end=end, step=step, jitter=jitter, display=display)
+
+        yield from data.T
+        
+
+
+    def arrayJitterySlice(self, start=None, end=None, step=1, jitter=0.5,
+                          display=False):
+        """ Create an array of events within a range of indices.
+
+            :keyword start: The first index in the range, or a slice.
+            :keyword end: The last index in the range. Not used if `start` is
+                a slice.
+            :keyword step: The step increment. Not used if `start` is a slice.
+            :keyword jitter: The amount by which to vary the sample time, as a
+                normalized percentage of the regular time between samples.
+            :keyword display: If `True`, the `EventArray` transform (i.e. the
+                'display' transform) will be applied to the data.
+            :return: a structured array of events in the specified index range.
+        """
+
+        if not isinstance(start, slice):
+            start = slice(start, end, step)
+        start, end, step = start.indices(len(self))
+
+        # Check for non-jittered cases
+        if jitter is True:
+            jitter = 0.5
+        scaledJitter = jitter * abs(step)
+
+        if scaledJitter <= 0.5:
+            return self.arraySlice(start=start, end=end, step=step, display=display)
+
+        # begin as normal
+        self._computeMinMeanMax()
+
+        if not isinstance(start, slice):
+            start = slice(start, end, step)
+        start, end, step = start.indices(len(self))
+
+        if self.useAllTransforms:
+            xform = self._fullXform
+            if display:
+                xform = self._displayXform or xform
+        else:
+            xform = self._comboXform
+
+        # grab all raw data
+        rawData = self._accessCache(None, None, 1)
+
+        # slightly janky way of enforcing output length
+        if isinstance(self.parent, SubChannel):
+            out = np.empty((2, len(self._accessCache(start, end, step))))
+        else:
+            out = np.empty((len(rawData.dtype) + 1, len(self._accessCache(start, end, step))))
+
+        # save on space by being really clever and storing indices in timestamps
+        indices = out[0].view(np.int64)
+        indices[:] = np.arange(start, end, step, dtype=np.int64)
+        if len(indices) > 2:
+            indices[1:-1] += np.rint(np.random.uniform(
+                    -scaledJitter, scaledJitter, (len(indices) - 2,)
+                    )).astype(np.int64)
+
+        # now index raw data
+        rawData = rawData[indices]
+
+        # now times
+        self._inplaceTimeFromIndices(indices, out=out[0])
+
+        if isinstance(self.parent, SubChannel):
+            xform.polys[self.subchannelId].inplace(rawData, out=out[1], timestamp=out[0])
+        else:
+            for i, (k, _) in enumerate(rawData.dtype.descr):
+                xform.polys[i].inplace(rawData[k], out=out[i + 1], timestamp=out[0])
+
+        return out
+
+
+    def getEventIndexBefore(self, t):
         """ Get the index of an event occurring on or immediately before the
             specified time.
         
@@ -1818,14 +1935,17 @@ def getEventIndexBefore(self, t):
             :return: The index of the event preceding the given time, -1 if
                 the time occurs before the first event.
         """
-        if t <= self._data[0].startTime:
+        if t < self._data[0].startTime:
             return -1
+
+        if t >= self._data[-1].endTime:
+            return self._data[-1].indexRange[1]
+
         blockIdx = self._getBlockIndexWithTime(t)
         try:
             block = self._data[blockIdx]
         except IndexError:
-            blockIdx = len(self._data)-1
-            block = self._data[blockIdx]
+            block = self._data[-1]
         return int(block.indexRange[0] + \
                    ((t - block.startTime) / self._getBlockSampleTime(blockIdx)))
         
@@ -1838,11 +1958,16 @@ def getEventIndexNear(self, t):
         """
         if t <= self._data[0].startTime:
             return 0
+
+        if t >= self._data[-1].endTime:
+            return self._data[-1].indexRange[1]
+
         idx = self.getEventIndexBefore(t)
-        events = self[idx:idx+2]
-        if events[0][0] == t or len(events) == 1:
+        events = self[idx:idx+2][0]
+        if len(events) == 1:
             return idx
-        return min((t - events[0][0], idx), (events[1][0] - t, idx+1))[1]
+
+        return idx + abs(events - t).argmin()
 
 
     def getRangeIndices(self, startTime, endTime):
@@ -1881,7 +2006,7 @@ def getRangeIndices(self, startTime, endTime):
         return max(0, startIdx), min(endIdx, len(self))
     
 
-    def getRange(self, startTime=None, endTime=None, display=False):
+    def iterRange(self, startTime=None, endTime=None, step=1, display=False):
         """ Get a set of data occurring in a given interval.
         
             :keyword startTime: The first time (in microseconds by default),
@@ -1889,19 +2014,40 @@ def getRange(self, startTime=None, endTime=None, display=False):
             :keyword endTime: The second time, or `None` to use the end of
                 the session.
         """
-        return list(self.iterRange(startTime, endTime, display=display))
 
+        warnings.warn(DeprecationWarning('iter methods should be expected to be '
+                                         'removed in future versions of idelib'))
+
+        startIdx, endIdx = self.getRangeIndices(startTime, endTime)
+        return self.iterSlice(startIdx,endIdx,step,display=display)        
+
+
+    def arrayRange(self, startTime=None, endTime=None, step=1, display=False):
+        """ Get a set of data occurring in a given time interval.
 
-    def iterRange(self, startTime=None, endTime=None, step=1, display=False):
-        """ Get a set of data occurring in a given interval.
-        
             :keyword startTime: The first time (in microseconds by default),
                 `None` to start at the beginning of the session.
             :keyword endTime: The second time, or `None` to use the end of
                 the session.
+            :return: a structured array of events in the specified time
+                interval.
         """
+        self._computeMinMeanMax()
         startIdx, endIdx = self.getRangeIndices(startTime, endTime)
-        return self.iterSlice(startIdx,endIdx,step,display=display)        
+        return self.arraySlice(startIdx, endIdx, step, display=display)
+
+
+    def getRange(self, startTime=None, endTime=None, display=False):
+        """ Get a set of data occurring in a given time interval. (Currently
+            an alias of `arrayRange`.)
+
+            :keyword startTime: The first time (in microseconds by default),
+                `None` to start at the beginning of the session.
+            :keyword endTime: The second time, or `None` to use the end of
+                the session.
+            :return: a collection of events in the specified time interval.
+        """
+        return self.arrayRange(startTime, endTime, display=display)
 
 
     def iterMinMeanMax(self, startTime=None, endTime=None, padding=0,
@@ -1923,6 +2069,10 @@ def iterMinMeanMax(self, startTime=None, endTime=None, padding=0,
             :return: An iterator producing sets of three events (min, mean, 
                 and max, respectively).
         """
+
+        warnings.warn(DeprecationWarning('iter methods should be expected to be '
+                                         'removed in future versions of idelib'))
+
         if not self.hasMinMeanMax:
             self._computeMinMeanMax()
             
@@ -1998,57 +2148,119 @@ def iterMinMeanMax(self, startTime=None, endTime=None, padding=0,
             else:
                 yield tuple(result)
 
-    
+
+    def arrayMinMeanMax(self, startTime=None, endTime=None, padding=0,
+                        times=True, display=False, iterator=iter):
+        """ Get the minimum, mean, and maximum values for blocks within a
+            specified interval.
+
+            :todo: Remember what `padding` was for, and either implement or
+                remove it completely. Related to plotting; see `plots`.
+
+            :keyword startTime: The first time (in microseconds by default),
+                `None` to start at the beginning of the session.
+            :keyword endTime: The second time, or `None` to use the end of
+                the session.
+            :keyword times: If `True` (default), the results include the
+                block's starting time.
+            :keyword display: If `True`, the final 'display' transform (e.g.
+                unit conversion) will be applied to the results.
+            :return: A structured array of data block statistics (min, mean,
+                and max, respectively).
+        """
+
+        startBlock, endBlock = self._getBlockRange(startTime, endTime)
+        shape = (3, max(1, len(self._npType)) + int(times), endBlock - startBlock)
+        scid = self.subchannelId
+        isSubchannel = isinstance(self.parent, SubChannel)
+
+        out = np.empty(shape)
+
+        for i, d in enumerate(self._data[startBlock:endBlock]):
+            if isSubchannel:
+                if times:
+                    out[:, 0, i] = d.startTime
+                    out[0, 1, i] = d.min[scid]
+                    out[1, 1, i] = d.mean[scid]
+                    out[2, 1, i] = d.max[scid]
+                else:
+                    out[0, 0, i] = d.min[scid]
+                    out[1, 0, i] = d.mean[scid]
+                    out[2, 0, i] = d.max[scid]
+            else:
+                if times:
+                    out[:, 0, i] = d.startTime
+                    out[0, 1:, i] = d.min
+                    out[1, 1:, i] = d.mean
+                    out[2, 1:, i] = d.max
+                else:
+                    out[0, :, i] = d.min
+                    out[1, :, i] = d.mean
+                    out[2, :, i] = d.max
+
+        return out
+
+
     def getMinMeanMax(self, startTime=None, endTime=None, padding=0,
                       times=True, display=False, iterator=iter):
         """ Get the minimum, mean, and maximum values for blocks within a
-            specified interval.
-            
+            specified interval. (Currently an alias of `arrayMinMeanMax`.)
+
             :todo: Remember what `padding` was for, and either implement or
                 remove it completely. Related to plotting; see `plots`.
-            
+
             :keyword startTime: The first time (in microseconds by default),
                 `None` to start at the beginning of the session.
             :keyword endTime: The second time, or `None` to use the end of
                 the session.
-            :keyword times: If `True` (default), the results include the 
-                block's starting time. 
+            :keyword times: If `True` (default), the results include the
+                block's starting time.
             :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results. 
-            :return: A list of sets of three events (min, mean, and max, 
-                respectively).
+                unit conversion) will be applied to the results.
+            :return: A structured array of data block statistics (min, mean,
+                and max, respectively).
         """
-        return list(iterator(self.iterMinMeanMax(startTime, endTime, padding, times,
-                                                 display=display)))
-    
-    
+        return self.arrayMinMeanMax(startTime, endTime, padding, times,
+                                    display, iterator)
+
+
     def getRangeMinMeanMax(self, startTime=None, endTime=None, subchannel=None,
                            display=False, iterator=iter):
         """ Get the single minimum, mean, and maximum value for blocks within a
             specified interval. Note: Using this with a parent channel without
             specifying a subchannel number can produce meaningless data if the
             channels use different units or are on different scales.
-            
+
             :keyword startTime: The first time (in microseconds by default),
                 `None` to start at the beginning of the session.
             :keyword endTime: The second time, or `None` to use the end of
                 the session.
             :keyword subchannel: The subchannel ID to retrieve, if the
-                EventList's parent has subchannels.
+                EventArray's parent has subchannels.
             :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results. 
-            :return: A set of three events (min, mean, and max, respectively).
+                unit conversion) will be applied to the results.
+            :return: A namedtuple of aggregated event statistics (min, mean,
+                and max, respectively).
         """
-        mmm = np.array(self.getMinMeanMax(startTime, endTime, times=False, display=display, iterator=iterator))
-        if mmm.size == 0:
+        stats = self.arrayMinMeanMax(startTime, endTime, times=False,
+                                     display=display, iterator=iterator)
+
+        if stats.size == 0:
             return None
         if self.hasSubchannels and subchannel is not None:
-            return (mmm[:,0,subchannel].min(), 
-                    np.median(mmm[:,1,subchannel]).mean(), 
-                    mmm[:,2,subchannel].max())
-        return (mmm[:,0].min(), np.median(mmm[:,1]), mmm[:,2].max())
-        
-    
+            return (
+                stats[0, subchannel].min(),
+                np.median(stats[1, subchannel]),
+                stats[2, subchannel].max(),
+            )
+        else:
+            return (
+                stats[0].min(),
+                np.mean(np.median(stats[1], axis=-1)),
+                stats[2].max(),
+            )
+
+
     def _getBlockRange(self, startTime=None, endTime=None):
         """ Get blocks falling within a time range. Used internally.
         """
@@ -2070,111 +2282,49 @@ def _getBlockRange(self, startTime=None, endTime=None):
 
     def getMax(self, startTime=None, endTime=None, display=False, iterator=iter):
         """ Get the event with the maximum value, optionally within a specified
-            time range. For Channels, the maximum of all Subchannels is
-            returned.
-            
+            time range. For Channels, returns the maximum among all
+            Subchannels.
+
             :keyword startTime: The starting time. Defaults to the start.
             :keyword endTime: The ending time. Defaults to the end.
             :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results. 
+                unit conversion) will be applied to the results.
             :return: The event with the maximum value.
         """
-        # Optimization: actual functions are faster than building/using lambdas
-        def _blockChannelMax(x):
-            return max(x[1][-1][1:])
-
-        def _blockSubchannelMax(x):
-            return x[1][-1][-1]
-        
-        def _channelMax(x):
-            return max(x[1:])
+        maxs = self.arrayMinMeanMax(startTime, endTime, times=False,
+                                    display=display, iterator=iterator)[2].max(axis=0)
 
-        def _subChannelMax(x):
-            return x[-1]
+        blockIdx = maxs.argmax()  # TODO: is this bug-free? double-check
+        sampleIdxRange = self._data[blockIdx].indexRange
+        blockData = self.arraySlice(*sampleIdxRange, display=display)
+        subIdx = blockData[1:].max(axis=0).argmax()
 
-        if self.hasSubchannels:
-            blockKeyFun = _blockChannelMax
-            keyFun = _channelMax
-        else:
-            blockKeyFun = _blockSubchannelMax
-            keyFun = _subChannelMax
-            
-        blockIter = iterator(self.iterMinMeanMax(startTime, endTime, display=display))
-    
-        blockIdx = max(enumerate(blockIter),key=blockKeyFun)[0]
-        block = self._data[blockIdx]
-        return max(iterator(self.iterSlice(*block.indexRange, display=display)),
-                   key=keyFun)
+        return blockData[:, subIdx]
 
 
     def getMin(self, startTime=None, endTime=None, display=False, iterator=iter):
         """ Get the event with the minimum value, optionally within a specified
-            time range. For Channels, the minimum of all Subchannels is
-            returned.
-            
+            time range. For Channels, returns the minimum among all
+            Subchannels.
+
             :keyword startTime: The starting time. Defaults to the start.
             :keyword endTime: The ending time. Defaults to the end.
             :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results. 
+                unit conversion) will be applied to the results.
             :return: The event with the minimum value.
         """
-        # Optimization: actual functions are faster than building/using lambdas
-        def _blockChannelMin(x):
-            return min(x[1][0][1:])
-
-        def _blockSubchannelMin(x):
-            return x[1][0][-1]
-        
-        def _channelMin(x):
-            return min(x[1:])
-
-        def _subChannelMin(x):
-            return x[-1]
-        
         if not self.hasMinMeanMax:
             self._computeMinMeanMax()
-        
-        if self.hasSubchannels:
-            blockKeyFun = _blockChannelMin
-            keyFun = _channelMin
-        else:
-            blockKeyFun = _blockSubchannelMin
-            keyFun = _subChannelMin
-            
-        blockIter = iterator(self.iterMinMeanMax(startTime, endTime, display=display))
-    
-        blockIdx = min(enumerate(blockIter),key=blockKeyFun)[0]
-        block = self._data[blockIdx]
-        return min(iterator(self.iterSlice(*block.indexRange, display=display)),
-                   key=keyFun)
 
+        mins = self.arrayMinMeanMax(startTime, endTime, times=False,
+                                    display=display, iterator=iterator)[0].min(axis=0)
 
-    def _computeMinMeanMax(self):
-        """ Calculate the minimum, mean, and max for files without that data
-            recorded. Not recommended for large data sets.
-        """
-        if self.hasMinMeanMax or self._singleSample:
-            return
-        
-        if self.hasSubchannels:
-            parseBlock = self.parent.parseBlock
-        else:
-            parseBlock = self.parent.parent.parseBlock
-        
-        try:
-            for block in self._data:
-                if None in (block.min, block.mean, block.max):
-                    vals = np.array(parseBlock(block))
-                    block.min = tuple(vals.min(axis=0))
-                    block.mean = tuple(vals.mean(axis=0))
-                    block.max = tuple(vals.max(axis=0))
-            
-                self.hasMinMeanMax = True
+        blockIdx = mins.argmin()  # TODO: is this bug-free? double-check
+        sampleIdxRange = self._data[blockIdx].indexRange
+        blockData = self.arraySlice(*sampleIdxRange, display=display)
+        subIdx = blockData[1:].min(axis=0).argmin()
 
-        except struct.error:
-            logger.warning("_computeMinMeanMax struct error: %r" % (block.indexRange,))
-            if __DEBUG__:
-                raise
+        return blockData[:, subIdx]
 
 
     def _getBlockSampleTime(self, blockIdx=0):
@@ -2254,10 +2404,11 @@ def getSampleTime(self, idx=None):
             :return: The time between samples (us)
         """
         sr = self.parent.sampleRate
-        if idx is None and sr is not None:
-            return 1.0 / sr
-        else:
-            idx = 0
+        if idx is None:
+            if sr is not None:
+                return 1.0/sr
+            else:
+                idx = 0
         return self._getBlockSampleTime(self._getBlockIndexWithIndex(idx))
     
     
@@ -2316,19 +2467,74 @@ def getValueAt(self, at, outOfRange=False, display=False):
             v1 + (percent * (v2-v1))
             for v1, v2 in zip(startEvt[1:], endEvt[1:])
         )
+
+
+    def getMean(self, startTime=None, endTime=None, display=False, iterator=iter):
+        """ Get the mean value of all events, optionally within a specified
+            time range. For Channels, returns the mean among all
+            Subchannels.
+
+            :keyword startTime: The starting time. Defaults to the start.
+            :keyword endTime: The ending time. Defaults to the end.
+            :keyword display: If `True`, the final 'display' transform (e.g.
+                unit conversion) will be applied to the results.
+            :return: The event with the minimum value.
+        """
+        if not self.hasMinMeanMax:
+            self._computeMinMeanMax()
+
+        if startTime is None and endTime is None:
+            if self._mean is not None:
+                return self._mean
+
+        means = self.arrayMinMeanMax(startTime, endTime, times=False,
+                                     display=display, iterator=iterator)[1]
+
+        mean = np.mean(np.average(means, weights=[d.numSamples for d in self._data], axis=-1))
+
+        if startTime is None and endTime is None:
+            self._mean = mean
+
+        return mean
     
 
     def getMeanNear(self, t, outOfRange=False):
-        """ Retrieve the mean value near a given time. 
+        """ Retrieve the mean value near a given time.
         """
         b = self._getBlockIndexWithTime(t)
-        if outOfRange:
-            b = min(len(self._data)-1,b)
-        m = self._comboXform(t, self._getBlockRollingMean(b, force=True))[1]
+        _, m = self._comboXform(t, self._getBlockRollingMean(b, force=True))
         if self.hasSubchannels:
             return m
         return m[self.subchannelId]
+
+
+    def _computeMinMeanMax(self):
+        """ Calculate the minimum, mean, and max for files without that data
+            recorded. Not recommended for large data sets.
+        """
+        if self.hasMinMeanMax:
+            return
+        
+        if self.hasSubchannels:
+            parseBlock = self.parent.parseBlock
+        else:
+            parseBlock = self.parent.parent.parseBlock
         
+        for block in self._data:
+            try:
+                if (block.min is None or block.mean is None or block.max is None):
+                    vals = parseBlock(block)
+                    block.min = vals.min(axis=-1)
+                    block.mean = vals.mean(axis=-1)
+                    block.max = vals.max(axis=-1)
+            
+                self.hasMinMeanMax = True
+                
+            except struct.error:
+                logger.warning("_computeMinMeanMax struct error: %r" % (block.indexRange,))
+                if __DEBUG__:
+                    raise
+
 
     def iterResampledRange(self, startTime, stopTime, maxPoints, padding=0,
                            jitter=0, display=False):
@@ -2339,6 +2545,10 @@ def iterResampledRange(self, startTime, stopTime, maxPoints, padding=0,
             :todo: Optimize iterResampledRange(); not very efficient,
                 particularly not with single-sample blocks.
         """
+
+        warnings.warn(DeprecationWarning('iter methods should be expected to be '
+                                         'removed in future versions of idelib'))
+
         startIdx, stopIdx = self.getRangeIndices(startTime, stopTime)
         numPoints = (stopIdx - startIdx)
         startIdx = max(startIdx-padding, 0)
@@ -2351,6 +2561,28 @@ def iterResampledRange(self, startTime, stopTime, maxPoints, padding=0,
         return self.iterSlice(startIdx, stopIdx, step, display=display)
 
 
+    def arrayResampledRange(self, startTime, stopTime, maxPoints, padding=0,
+                            jitter=0, display=False):
+        """ Retrieve the events occurring within a given interval,
+            undersampled as to not exceed a given length (e.g. the size of
+            the data viewer's screen width).
+
+            :todo: Optimize iterResampledRange(); not very efficient,
+                particularly not with single-sample blocks.
+        """
+        from math import ceil
+
+        startIdx, stopIdx = self.getRangeIndices(startTime, stopTime)
+        startIdx = max(startIdx-padding, 0)
+        stopIdx = min(stopIdx+padding+1, len(self))
+        step = max(int(ceil((stopIdx - startIdx) / maxPoints)), 1)
+
+        if jitter != 0:
+            return self.arrayJitterySlice(startIdx, stopIdx, step, jitter,
+                                          display=display)
+        return self.arraySlice(startIdx, stopIdx, step, display=display)
+
+
     def exportCsv(self, stream, start=None, stop=None, step=1, subchannels=True,
                   callback=None, callbackInterval=0.01, timeScalar=1,
                   raiseExceptions=False, dataFormat="%.6f", delimiter=", ",
@@ -2440,11 +2672,19 @@ def exportCsv(self, stream, start=None, stop=None, step=1, subchannels=True,
         if headers:
             stream.write('"Time"%s%s\n' % 
                          (delimiter, delimiter.join(['"%s"' % n for n in names])))
+
+        data = _self.arraySlice(start, stop, step)
+        if useUtcTime and _self.session.utcStartTime:
+            if useIsoFormat:
+                times = data[0]
+                data = data.astype([('time', '<U19')] + [(str(i), np.float64) for i in range(1, 4)])
+
             
         num = 0
         try:
             for num, evt in enumerate(_self.iterSlice(start, stop, step, display=display)):
                 stream.write("%s\n" % formatter(evt))
+                # print(evt - np.array([float(x) for x in formatter(evt).split(', ')]))
                 if callback is not None:
                     if getattr(callback, 'cancelled', False):
                         callback(done=True)
@@ -2461,818 +2701,75 @@ def exportCsv(self, stream, start=None, stop=None, step=1, subchannels=True,
 
         return num+1, datetime.now() - t0
 
+    def fillCache(self):
+        with self.dataset._channelDataLock:
+            self._cacheArray = np.concatenate([d.payload for d in self._data])
+            self._cacheBytes = self._cacheArray.view(np.uint8)
 
-#===============================================================================
-#
-#===============================================================================
-
-def retryUntilReturn(func, max_tries, delay=0, on_fail=(lambda: None),
-                     default=None):
-    """ Repeats a function call until a non-None value is returned, and
-        returns that value.
-    """
-    for _ in range(max_tries):
-        value = func()
-        if value is not None:
-            return value
-        on_fail()
-        sleep(delay)
-    return default
-
-
-class EventArray(EventList):
-    """ A list-like object containing discrete time/value pairs. Data is 
-        dynamically read from the underlying EBML file. 
-    """
-
-    def __init__(self, parentChannel, session=None, parentList=None):
-        """ Constructor. This should almost always be done indirectly via
-            the `getSession()` method of `Channel` and `SubChannel` objects.
-        """
-        super(EventArray, self).__init__(parentChannel, session, parentList)
-
-        self._blockIndicesArray = np.array([], dtype=np.float64)
-        self._blockTimesArray = np.array([], dtype=np.float64)
-
-    #===========================================================================
-    # New utility methods
-    #===========================================================================
-
-    def _makeBlockEventsFactory(self, display):
-        """ Generates a function that makes numpy arrays of event data.
-            The generated function is optimized to be run repeatedly in a loop.
-        """
-
-        # OPTIMIZATION: making local variables for faster access
-        parent = self.parent
-        session = self.session
-        hasSubchannels = self.hasSubchannels
-        if not hasSubchannels:
-            subchannelId = parent.id
-        _getBlockRollingMean = self._getBlockRollingMean
-        removeMean = (self.allowMeanRemoval and self.removeMean
-#                       and self.hasMinMeanMax
-                      )
-
-        if not self.useAllTransforms:
-            xform = self._comboXform
-        elif display:
-            xform = self._displayXform or self._fullXform
-        else:
-            xform = self._fullXform
-
-        def _makeBlockEvents(times, values, block, blockIdx):
-            """ Creates a structured array of event data for a given set of
-                event times and values. (Used in event iteration methods.)
-            """
-            times, values = retryUntilReturn(
-                partial(xform, times, values, session=session,
-                        noBivariates=self.noBivariates),
-                max_tries=2, delay=0.001,
-                on_fail=partial(logger.info,
-                                "%s: bad transform @%s"
-                                % (parent.name, times)),
-            )
-            values = np.asarray(values)
-
-            # Note: _getBlockRollingMean returns None if removeMean==False
-            if removeMean:
-                offset = retryUntilReturn(
-                    partial(_getBlockRollingMean, blockIdx),
-                    max_tries=2, delay=0.001,
-                    on_fail=partial(logger.info,
-                                    "%s: bad offset (1) @%s"
-                                    % (parent.name, block.startTime)),
-                )
-                _, offset = retryUntilReturn(
-                    partial(xform, block.startTime, offset, session=session,
-                            noBivariates=self.noBivariates),
-                    max_tries=2, delay=0.001, default=(None, offset),
-                    on_fail=partial(logger.info,
-                                    "%s: bad offset (2) @%s"
-                                    % (parent.name, block.startTime)),
-                )
-
-                if offset is not None:
-                    values -= np.array(offset)[..., np.newaxis]
-                else:
-                    logger.info('%r event offset is None' % parent.name)
-
-            if not hasSubchannels:
-                values = values[[subchannelId]]
-
-            return (times, values)
-
-        return _makeBlockEvents
-
-    #===========================================================================
-    # Derived utility methods
-    #===========================================================================
-
-    def _getBlockIndexWithIndex(self, idx, start=0, stop=None):
-        """ Get the index of a raw data block that contains the given event
-            index.
-
-            :param idx: The event index to find
-            :keyword start: The first block index to search
-            :keyword stop: The last block index to search
-        """
-        # TODO: profile & determine if this change is beneficial
-        if len(self._blockIndicesArray) != len(self._blockIndices):
-            self._blockIndicesArray = np.array(self._blockIndices)
-
-        idxOffset = max(start, 1)
-        return idxOffset-1 + np.searchsorted(
-            self._blockIndicesArray[idxOffset:stop], idx, side='right'
-        )
-
-
-    def _getBlockIndexWithTime(self, t, start=0, stop=None):
-        """ Get the index of a raw data block in which the given time occurs.
-
-            :param t: The time to find
-            :keyword start: The first block index to search
-            :keyword stop: The last block index to search
-        """
-        # TODO: profile & determine if this change is beneficial
-        if len(self._blockTimesArray) != len(self._blockTimes):
-            self._blockTimesArray = np.array(self._blockTimes)
-
-        idxOffset = max(start, 1)
-        return idxOffset-1 + np.searchsorted(
-            self._blockTimesArray[idxOffset:stop], t, side='right'
-        )
-
-
-    def _getBlockRollingMean(self, blockIdx, force=False):
-        """ Get the mean of a block and its neighbors within a given time span.
-            Note: Values are taken pre-calibration, and all subchannels are
-            returned.
-            
-            :param blockIdx: The index of the block to check.
-            :return: An array containing the mean values of each subchannel. 
-        """
-        if isinstance(blockIdx, Sequence):
-            blockIdx = np.array(blockIdx)
-        elif not isinstance(blockIdx, np.ndarray):
-            return super(EventArray, self)._getBlockRollingMean(blockIdx, force)
-
-        uniqueBlockIndices, blocksPerm = np.unique(blockIdx, return_inverse=True)
-
-        uniqueBlockMeans = np.stack([
-            super(EventArray, self)._getBlockRollingMean(idx, force)
-            for idx in uniqueBlockIndices
-        ], axis=-1)
-        return uniqueBlockMeans[:, blocksPerm]
-
-
-    #===========================================================================
-    # Main API methods
-    #===========================================================================
-
-    def __getitem__(self, idx, display=False):
-        """ Get a specific data point by index.
-
-            :param idx: An integer index or a `slice`
-            :return: For single results, a tuple containing (time, value).
-                For multiple results, a structured array of (time, value)
-                pairs.
-        """
-        # TODO: Cache this; a Channel's SubChannels will often be used together.
-        if not self.useAllTransforms:
-            xform = self._comboXform
-        elif display:
-            xform = self._displayXform or self._fullXform
-        else:
-            xform = self._fullXform
-
-        if isinstance(idx, int):
-
-            if idx >= len(self):
-                raise IndexError("EventArray index out of range")
-
-            if idx < 0:
-                idx = max(0, len(self) + idx)
-
-            blockIdx = self._getBlockIndexWithIndex(idx)
-            subIdx = idx - self._getBlockIndexRange(blockIdx)[0]
-
-            block = self._data[blockIdx]
-
-            t = block.startTime + self._getBlockSampleTime(blockIdx)*subIdx
-            val = self.parent.parseBlock(block, start=subIdx, end=subIdx+1)[:, 0]
-
-            eventx = retryUntilReturn(
-                partial(xform, t, val, session=self.session,
-                        noBivariates=self.noBivariates),
-                max_tries=2, delay=0.001,
-                on_fail=partial(logger.info,
-                                "%s: bad transform %r %r"
-                                % (self.parent.name, t, val)),
-            )
-            if eventx is None:
-                return None
-            tx, valx = eventx
-
-            m = self._getBlockRollingMean(blockIdx)
-            if m is not None:
-                _, mx = retryUntilReturn(
-                    partial(xform, t, m, session=self.session,
-                            noBivariates=self.noBivariates),
-                    max_tries=2, delay=0.001,
-                    on_fail=partial(logger.info,
-                                    "%s: bad offset @%s"
-                                    % (self.parent.name, t)),
-                )
-                valx -= np.array(mx)
-
-            if not self.hasSubchannels:
-                # Doesn't quite work; transform dataset attribute not set?
-                return np.array([tx, valx[self.subchannelId]])
+            idx = 0
+            for d in self._data:
+                d._payload = self._cacheArray[idx:idx + len(d._payload)]
+                idx += len(d._payload)
+
+    def _accessCache(self, start, end, step):
+        """ Access cached data in a thread-safe way.  If data has not fully
+            loaded, instead allocate an appropriately large array, fill it with
+            data, then return that instead.
+        """
+
+        if isinstance(self.parent, SubChannel):
+            schId = self.subchannelId
+            ch = self.parent.parent
+            rawData = ch.getSession(self.session.sessionId)._accessCache(start, end, step)
+            schKey = rawData.dtype.names[schId]
+            return rawData[schKey]
+
+        with self.dataset._channelDataLock:
+            if self.dataset.loading:
+                return np.concatenate([d.payload for d in self._data])[start:end:step]
             else:
-                return np.append([tx], valx)
-
-        elif isinstance(idx, slice):
-            # vvv Main difference from `EventList.__getitem__` vvv
-            return self.arraySlice(idx)
+                return self._cacheArray[start:end:step]
 
-        raise TypeError("EventArray indices must be integers or slices,"
-                        " not %s (%r)" % (type(idx), idx))
+    def _inplaceTime(self, start, end, step, out=None):
+        if out is None:
+            out = np.empty((int(np.ceil((end - start)/step)),))
 
+        if self._singleSample:
+            out[:] = [d.startTime for d in self._data[start:end:step]]
+            return out
 
-    def itervalues(self, start=None, end=None, step=1, subchannels=True,
-                   display=False):
-        """ Iterate all values in the given index range (w/o times).
+        vals = np.empty((self._data[-1].indexRange[-1],))
 
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword subchannels: A list of subchannel IDs or Boolean. `True`
-                will return all subchannels in native order.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: an iterable of structured array value blocks in the
-                specified index range.
-        """
-        # TODO: Optimize; times don't need to be computed since they aren't used
-        iterBlockValues = (
-            np.stack(values)
-            for _, values in self._blockSlice(start, end, step, display)
-        )
-        if self.hasSubchannels and subchannels is not True:
-            chIdx = np.asarray(subchannels)
-            return (vals
-                    for blockVals in iterBlockValues
-                    for vals in blockVals[chIdx].T)
-        else:
-            return (vals
-                    for blockVals in iterBlockValues
-                    for vals in blockVals.T)
+        for d in self._data:
+            arrayStart = d.startTime
+            arrayEnd = d.endTime
+            startIdx, endIdx = d.indexRange
+            samplePeriod = (arrayEnd - arrayStart)/(d.numSamples - 1)
 
+            # out = samplePeriod*(step*out + start) + arrayStart
+            # out = out*(samplePeriod*step) + (samplePeriod*start + arrayStart)
+            vals[startIdx:endIdx] = np.arange(d.numSamples)
+            vals[startIdx:endIdx] *= samplePeriod
+            vals[startIdx:endIdx] += arrayStart
 
-    def arrayValues(self, start=None, end=None, step=1, subchannels=True,
-                    display=False):
-        """ Get all values in the given index range (w/o times).
+        out[:] = vals[start:end:step]
 
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword subchannels: A list of subchannel IDs or Boolean. `True`
-                will return all subchannels in native order.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: a structured array of values in the specified index range.
-        """
-        # TODO: Optimize; times don't need to be computed since they aren't used
-        # -> take directly from _blockSlice
-        arrayEvents = self.arraySlice(start, end, step, display)
+        return out
 
-        if self.hasSubchannels and subchannels is not True:
-            return arrayEvents[np.asarray(subchannels)+1]
-        else:
-            return arrayEvents[1:]
+    def _inplaceTimeFromIndices(self, indices, out=None):
+        if out is None:
+            out = indices.astype(np.float64)
 
+        out[:] = indices
 
-    def _blockSlice(self, start=None, end=None, step=1, display=False):
-        """ Create an iterator producing events packed into numpy arrays for a
-            range of indices.
+        arrayStart = float(self._data[0].startTime)
+        arrayEnd = float(self._data[-1].endTime)
+        nSamples = len(self)
+        samplePeriod = (arrayEnd - arrayStart)/(nSamples - 1)
 
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: an iterable of events in the specified index range.
-        """
-        # TODO: optimization: refactor calls of iterSlice() to pass slices?
-        if not isinstance(start, slice):
-            start = slice(start, end, step)
-        start, end, step = start.indices(len(self))
-
-        startBlockIdx = self._getBlockIndexWithIndex(start) if start > 0 else 0
-        endBlockIdx = self._getBlockIndexWithIndex(end-1, start=startBlockIdx)
-
-        blockStep = max(1, step / self._data[startBlockIdx].numSamples)
-        numBlocks = int((endBlockIdx - startBlockIdx) / blockStep)+1
-
-        subIdx = start - self._getBlockIndexRange(startBlockIdx)[0]
-        endSubIdx = end - self._getBlockIndexRange(endBlockIdx)[0]
-
-        # OPTIMIZATION: making local variables for faster access
-        _data = self._data
-        _getBlockSampleTime = self._getBlockSampleTime
-        parent_parseBlock = self.parent.parseBlock
-        makeBlockEvents = self._makeBlockEventsFactory(display)
-
-        # in each block, the next subIdx is (step+subIdx)%numSamples
-        for i in range(numBlocks):
-            blockIdx = int(startBlockIdx + (i * blockStep))
-            block = _data[blockIdx]
-            sampleTime = _getBlockSampleTime(blockIdx)
-            lastSubIdx = (endSubIdx if blockIdx == endBlockIdx
-                          else block.numSamples)
-
-            yield makeBlockEvents(
-                times=(block.startTime
-                       + sampleTime*np.arange(subIdx, lastSubIdx, step)),
-                values=parent_parseBlock(block, subIdx, lastSubIdx, step),
-                block=block, blockIdx=blockIdx,
-            )
-
-            subIdx = (lastSubIdx-1+step) % block.numSamples
-
-
-    def iterSlice(self, start=None, end=None, step=1, display=False):
-        """ Create an iterator producing events for a range of indices.
-
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: an iterable of events in the specified index range.
-        """
-        for times, values in self._blockSlice(start, end, step, display):
-            blockEvents = np.append(times[np.newaxis], values, axis=0)
-            for event in blockEvents.T:
-                yield event
-
-
-    def arraySlice(self, start=None, end=None, step=1, display=False):
-        """ Create an array of events within a range of indices.
-
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: a structured array of events in the specified index range.
-        """
-        raw_slice = [
-            [times[np.newaxis].T, values.T]
-            for times, values in self._blockSlice(start, end, step, display)
-        ]
-        if not raw_slice:
-            no_of_chs = (len(self.parent.types) if self.hasSubchannels else 1)
-            return np.empty((no_of_chs+1, 0), dtype=np.float)
-
-        return np.block(raw_slice).T
-
-
-    def _blockJitterySlice(self, start=None, end=None, step=1, jitter=0.5,
-                           display=False):
-        """ Create an iterator producing events for a range of indices.
-
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword jitter: The amount by which to vary the sample time, as a
-                normalized percentage of the regular time between samples.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: an iterable of events in the specified index range.
-        """
-        # TODO: optimization: refactor calls of iterJitterySlice() to pass slices?
-        if not isinstance(start, slice):
-            start = slice(start, end, step)
-        start, end, step = start.indices(len(self))
-
-        if jitter is True:
-            jitter = 0.5
-        scaledJitter = jitter * abs(step)
-
-        startBlockIdx = self._getBlockIndexWithIndex(start) if start > 0 else 0
-        endBlockIdx = self._getBlockIndexWithIndex(end-1, start=startBlockIdx)
-
-        blockStep = max(1, step / self._data[startBlockIdx].numSamples)
-        numBlocks = int((endBlockIdx - startBlockIdx) / blockStep)+1
-
-        subIdx = start - self._getBlockIndexRange(startBlockIdx)[0]
-        endSubIdx = end - self._getBlockIndexRange(endBlockIdx)[0]
-
-        # OPTIMIZATION: making local variables for faster access
-        _data = self._data
-        _getBlockSampleTime = self._getBlockSampleTime
-        parent_parseBlockByIndex = self.parent.parseBlockByIndex
-        makeBlockEvents = self._makeBlockEventsFactory(display)
-
-        # in each block, the next subIdx is (step+subIdx)%numSamples
-        for i in range(numBlocks):
-            blockIdx = int(startBlockIdx + (i * blockStep))
-            block = _data[blockIdx]
-            sampleTime = _getBlockSampleTime(blockIdx)
-            lastSubIdx = (endSubIdx if blockIdx == endBlockIdx
-                          else block.numSamples)
-
-            indices = np.arange(subIdx, lastSubIdx, step)
-            if scaledJitter > 0.5:
-                indices[1:-1] += np.rint(
-                    scaledJitter * np.random.uniform(-1, 1, max(0, len(indices)-2))
-                ).astype(indices.dtype)
-
-            yield makeBlockEvents(
-                times=(block.startTime + sampleTime * indices),
-                values=parent_parseBlockByIndex(block, indices),
-                block=block, blockIdx=blockIdx,
-            )
-
-            subIdx = (lastSubIdx-1+step) % block.numSamples
-
-
-    def iterJitterySlice(self, start=None, end=None, step=1, jitter=0.5,
-                         display=False):
-        """ Create an iterator producing events for a range of indices.
-
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword jitter: The amount by which to vary the sample time, as a
-                normalized percentage of the regular time between samples.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: an iterable of events in the specified index range.
-        """
-        self._computeMinMeanMax()
-        
-        for times, values in self._blockJitterySlice(start, end, step, jitter,
-                                                     display):
-            blockEvents = np.append(times[np.newaxis], values, axis=0)
-            for event in blockEvents.T:
-                yield event
-
-
-    def arrayJitterySlice(self, start=None, end=None, step=1, jitter=0.5,
-                          display=False):
-        """ Create an array of events within a range of indices.
-
-            :keyword start: The first index in the range, or a slice.
-            :keyword end: The last index in the range. Not used if `start` is
-                a slice.
-            :keyword step: The step increment. Not used if `start` is a slice.
-            :keyword jitter: The amount by which to vary the sample time, as a
-                normalized percentage of the regular time between samples.
-            :keyword display: If `True`, the `EventArray` transform (i.e. the
-                'display' transform) will be applied to the data.
-            :return: a structured array of events in the specified index range.
-        """
-        self._computeMinMeanMax()
-        
-        raw_slice = [
-            [times[np.newaxis].T, values.T]
-            for times, values in self._blockJitterySlice(
-                start, end, step, jitter, display
-            )
-        ]
-        if not raw_slice:
-            no_of_chs = (len(self.parent.types) if self.hasSubchannels else 1)
-            return np.empty((no_of_chs+1, 0), dtype=np.float)
-
-        return np.block(raw_slice).T
-
-
-    # EventList implementation suffices -> no overload required
-    # def iterRange(self, startTime=None, endTime=None, step=1, display=False):
-
-    def arrayRange(self, startTime=None, endTime=None, step=1, display=False):
-        """ Get a set of data occurring in a given time interval.
-
-            :keyword startTime: The first time (in microseconds by default),
-                `None` to start at the beginning of the session.
-            :keyword endTime: The second time, or `None` to use the end of
-                the session.
-            :return: a structured array of events in the specified time
-                interval.
-        """
-        self._computeMinMeanMax()
-        startIdx, endIdx = self.getRangeIndices(startTime, endTime)
-        return self.arraySlice(startIdx, endIdx, step, display=display)
-
-
-    def getRange(self, startTime=None, endTime=None, display=False):
-        """ Get a set of data occurring in a given time interval. (Currently
-            an alias of `arrayRange`.)
-
-            :keyword startTime: The first time (in microseconds by default),
-                `None` to start at the beginning of the session.
-            :keyword endTime: The second time, or `None` to use the end of
-                the session.
-            :return: a collection of events in the specified time interval.
-        """
-        return self.arrayRange(startTime, endTime, display=display)
-
-    '''
-    TODO: revisit overloading iterMinMeanMax for efficiency
-    def iterMinMeanMax(self, startTime=None, endTime=None, padding=0,
-                       times=True, display=False):
-        """ Get the minimum, mean, and maximum values for blocks within a
-            specified interval.
-
-            :todo: Remember what `padding` was for, and either implement or
-                remove it completely. Related to plotting; see `plots`.
-
-            :keyword startTime: The first time (in microseconds by default),
-                `None` to start at the beginning of the session.
-            :keyword endTime: The second time, or `None` to use the end of
-                the session.
-            :keyword times: If `True` (default), the results include the
-                block's starting time.
-            :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results.
-            :return: An iterator producing sets of three events (min, mean,
-                and max, respectively).
-        """
-        if not self.hasMinMeanMax:
-            self._computeMinMeanMax()
-
-        startBlockIdx, endBlockIdx = self._getBlockRange(startTime, endTime)
-
-        # OPTIMIZATION: Local variables for things used in inner loops
-        hasSubchannels = self.hasSubchannels
-        session = self.session
-        removeMean = self.removeMean and self.allowMeanRemoval
-        _getBlockRollingMean = self._getBlockRollingMean
-        if not hasSubchannels:
-            parent_id = self.subchannelId
-
-        if self.useAllTransforms:
-            xform = self._fullXform
-            if display:
-                xform = self._displayXform or xform
-        else:
-            xform = self._comboXform
-
-        for block in self._data[startBlockIdx:endBlockIdx]:
-            # NOTE: Without this, a file in which some blocks don't have
-            # min/mean/max will fail. Should not happen, though.
-#             if block.minMeanMax is None:
-#                 continue
-
-            t = block.startTime
-            if removeMean:
-                # HACK: Multithreaded loading can (very rarely) fail at start.
-                # The problem is almost instantly resolved, though. Find root cause.
-                offset = retryUntilReturn(
-                    partial(_getBlockRollingMean, block.blockIndex),
-                    max_tries=10, delay=0.01
-                )
-            else:
-                offset = _getBlockRollingMean(block.blockIndex)
-
-            if offset is not None:
-                _, mx = retryUntilReturn(
-                    partial(xform, t, offset, session,
-                            noBivariates=self.noBivariates),
-                    max_tries=2, delay=0.005, default=(t, offset)
-                )
-                offset = np.array(mx)
-
-            values = np.stack((block.min, block.mean, block.max)).T
-            tx, values = retryUntilReturn(
-                partial(xform, t, values, session,
-                        noBivariates=self.noBivariates),
-                max_tries=2, delay=0.005, default=(t, values)
-            )
-            values = np.array(values).T
-            if offset is not None:
-                values -= offset
-            # ^ shape -> (min/mean/max, channels)
-
-            # Transformation has negative coefficient for inverted z-axis data
-            # -> need to sort mins/maxes to compensate
-            values.sort(axis=0)
-
-            if not hasSubchannels:
-                values = values[:, [parent_id]]
-
-            if times:
-                tx = np.broadcast_to(tx, (values.shape[0], 1))
-                yield np.concatenate((tx, values), axis=-1)
-            else:
-                yield values
-    '''
-
-    def arrayMinMeanMax(self, startTime=None, endTime=None, padding=0,
-                        times=True, display=False, iterator=iter):
-        """ Get the minimum, mean, and maximum values for blocks within a
-            specified interval.
-
-            :todo: Remember what `padding` was for, and either implement or
-                remove it completely. Related to plotting; see `plots`.
-
-            :keyword startTime: The first time (in microseconds by default),
-                `None` to start at the beginning of the session.
-            :keyword endTime: The second time, or `None` to use the end of
-                the session.
-            :keyword times: If `True` (default), the results include the
-                block's starting time.
-            :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results.
-            :return: A structured array of data block statistics (min, mean,
-                and max, respectively).
-        """
-
-        return np.moveaxis([i for i in iterator(self.iterMinMeanMax(
-            startTime, endTime, padding, times, display
-        ))], 0, -1)
-
-
-    def getMinMeanMax(self, startTime=None, endTime=None, padding=0,
-                      times=True, display=False, iterator=iter):
-        """ Get the minimum, mean, and maximum values for blocks within a
-            specified interval. (Currently an alias of `arrayMinMeanMax`.)
-
-            :todo: Remember what `padding` was for, and either implement or
-                remove it completely. Related to plotting; see `plots`.
-
-            :keyword startTime: The first time (in microseconds by default),
-                `None` to start at the beginning of the session.
-            :keyword endTime: The second time, or `None` to use the end of
-                the session.
-            :keyword times: If `True` (default), the results include the
-                block's starting time.
-            :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results.
-            :return: A structured array of data block statistics (min, mean,
-                and max, respectively).
-        """
-        return self.arrayMinMeanMax(startTime, endTime, padding, times,
-                                    display, iterator)
-
-
-    def getRangeMinMeanMax(self, startTime=None, endTime=None, subchannel=None,
-                           display=False, iterator=iter):
-        """ Get the single minimum, mean, and maximum value for blocks within a
-            specified interval. Note: Using this with a parent channel without
-            specifying a subchannel number can produce meaningless data if the
-            channels use different units or are on different scales.
-
-            :keyword startTime: The first time (in microseconds by default),
-                `None` to start at the beginning of the session.
-            :keyword endTime: The second time, or `None` to use the end of
-                the session.
-            :keyword subchannel: The subchannel ID to retrieve, if the
-                EventArray's parent has subchannels.
-            :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results.
-            :return: A namedtuple of aggregated event statistics (min, mean,
-                and max, respectively).
-        """
-        stats = self.arrayMinMeanMax(startTime, endTime, times=False,
-                                     display=display, iterator=iterator)
-
-        if stats.size == 0:
-            return None
-        if self.hasSubchannels and subchannel is not None:
-            return (
-                stats[0, subchannel].min(),
-                np.median(stats[1, subchannel]),
-                stats[2, subchannel].max(),
-            )
-        else:
-            return (
-                stats[0].min(),
-                np.mean(np.median(stats[1], axis=-1)),
-                stats[2].max(),
-            )
-
-
-    def getMax(self, startTime=None, endTime=None, display=False, iterator=iter):
-        """ Get the event with the maximum value, optionally within a specified
-            time range. For Channels, returns the maximum among all
-            Subchannels.
-
-            :keyword startTime: The starting time. Defaults to the start.
-            :keyword endTime: The ending time. Defaults to the end.
-            :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results.
-            :return: The event with the maximum value.
-        """
-        maxs = self.arrayMinMeanMax(startTime, endTime, times=False,
-                                    display=display, iterator=iterator)[2].max(axis=0)
-
-        blockIdx = maxs.argmax()  # TODO: is this bug-free? double-check
-        sampleIdxRange = self._data[blockIdx].indexRange
-        blockData = self.arraySlice(*sampleIdxRange, display=display)
-        subIdx = blockData[1:].max(axis=0).argmax()
-
-        return blockData[:, subIdx]
-
-
-    def getMin(self, startTime=None, endTime=None, display=False, iterator=iter):
-        """ Get the event with the minimum value, optionally within a specified
-            time range. For Channels, returns the minimum among all
-            Subchannels.
-
-            :keyword startTime: The starting time. Defaults to the start.
-            :keyword endTime: The ending time. Defaults to the end.
-            :keyword display: If `True`, the final 'display' transform (e.g.
-                unit conversion) will be applied to the results.
-            :return: The event with the minimum value.
-        """
-        if not self.hasMinMeanMax:
-            self._computeMinMeanMax()
-
-        mins = self.arrayMinMeanMax(startTime, endTime, times=False,
-                                    display=display, iterator=iterator)[0].min(axis=0)
-
-        blockIdx = mins.argmin()  # TODO: is this bug-free? double-check
-        sampleIdxRange = self._data[blockIdx].indexRange
-        blockData = self.arraySlice(*sampleIdxRange, display=display)
-        subIdx = blockData[1:].min(axis=0).argmin()
-
-        return blockData[:, subIdx]
-
-
-    def getMeanNear(self, t, outOfRange=False):
-        """ Retrieve the mean value near a given time.
-        """
-        b = self._getBlockIndexWithTime(t)
-        _, m = self._comboXform(t, self._getBlockRollingMean(b, force=True))
-        if self.hasSubchannels:
-            return m
-        return m[self.subchannelId]
-
-
-    def _computeMinMeanMax(self):
-        """ Calculate the minimum, mean, and max for files without that data
-            recorded. Not recommended for large data sets.
-        """
-        if self.hasMinMeanMax:
-            return
-        
-        if self.hasSubchannels:
-            parseBlock = self.parent.parseBlock
-        else:
-            parseBlock = self.parent.parent.parseBlock
-        
-        for block in self._data:
-            try:
-                if (block.min is None or block.mean is None or block.max is None):
-                    vals = parseBlock(block)
-                    block.min = vals.min(axis=-1)
-                    block.mean = vals.mean(axis=-1)
-                    block.max = vals.max(axis=-1)
-            
-                self.hasMinMeanMax = True
-                
-            except struct.error:
-                logger.warning("_computeMinMeanMax struct error: %r" % (block.indexRange,))
-                if __DEBUG__:
-                    raise
-
-
-    # EventList implementation suffices -> no overload required
-    # def iterResampledRange(self, startTime, stopTime, maxPoints, padding=0,
-    #                        jitter=0, display=False):
-
-
-    def arrayResampledRange(self, startTime, stopTime, maxPoints, padding=0,
-                            jitter=0, display=False):
-        """ Retrieve the events occurring within a given interval,
-            undersampled as to not exceed a given length (e.g. the size of
-            the data viewer's screen width).
-
-            :todo: Optimize iterResampledRange(); not very efficient,
-                particularly not with single-sample blocks.
-        """
-        from math import ceil
-
-        startIdx, stopIdx = self.getRangeIndices(startTime, stopTime)
-        startIdx = max(startIdx-padding, 0)
-        stopIdx = min(stopIdx+padding+1, len(self))
-        step = max(int(ceil((stopIdx - startIdx) / maxPoints)), 1)
-
-        if jitter != 0:
-            return self.arrayJitterySlice(startIdx, stopIdx, step, jitter,
-                                          display=display)
-        return self.arraySlice(startIdx, stopIdx, step, display=display)
+        out *= samplePeriod
+        out += arrayStart
+        return out
 
 
 #===============================================================================
@@ -3515,6 +3012,5 @@ def getValueAt(self, at, sessionId=None, source=None):
 
 # HACK to work around the fact that the `register` method doesn't show up
 # in `dir()`, which creates an error display in PyLint/PyDev/etc. 
-getattr(Iterable, 'register')(EventList)
 getattr(Iterable, 'register')(EventArray)
 getattr(Iterable, 'register')(WarningRange)
diff --git a/idelib/importer.py b/idelib/importer.py
index c4c41d64..1857ce0f 100644
--- a/idelib/importer.py
+++ b/idelib/importer.py
@@ -2,11 +2,13 @@
 
 """
 
+from collections import Counter
 from datetime import datetime
 import os.path
 import sys
 from time import time as time_time
 from time import sleep
+import warnings
 
 import struct
 
@@ -14,6 +16,7 @@
 from .dataset import Dataset
 from . import parsers
 
+
 #===============================================================================
 # 
 #===============================================================================
@@ -21,7 +24,7 @@
 # from dataset import __DEBUG__
 
 import logging
-logger = logging.getLogger('idelib-archive')
+logger = logging.getLogger('idelib')
 logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
 
 
@@ -98,41 +101,13 @@
 }
 
 
-if False: #__DEBUG__:
-    logger.info("Adding low g channels to defaults")
-    DEFAULTS['sensors'][0x02] = {
-         'name': "Low-G Accelerometer"
-    }
-    DEFAULTS['channels'].update({
-        0x02: {'name': "Low-G Accelerometer XYZ",
-               'parser': struct.Struct(">hhh"),
-               "subchannels":{0: {"name": "Low-g Z", 
-                                  "units":('Acceleration','g'),
-                                  "sensorId": 2,
-                                 },
-                              1: {"name": "Low-g Y", 
-                                  "units":('Acceleration','g'),
-                                  "sensorId": 2,
-                                  },
-                              2: {"name": "Low-g X", 
-                                  "units":('Acceleration','g'),
-                                  "sensorId": 2,
-                                  },
-                            },
-               },
-#         0x43: {"name": "DEBUG Crystal Drift",
-#                "parser": struct.Struct(">II")},
-#         0x45: {"name": "DEBUG Gain/Offset",
-#                "parser": struct.Struct("<i")},
-    })
-
-
-def createDefaultSensors(doc, defaults=DEFAULTS):
+def createDefaultSensors(doc, defaults=None):
     """ Given a nested set of dictionaries containing the definition of one or
         more sensors, instantiate those sensors and add them to the dataset
         document.
     """
 #     logger.info("creating default sensors")
+    defaults = defaults or DEFAULTS
     sensors = defaults['sensors'].copy()
     channels = defaults['channels'].copy()
     warnings = defaults['warnings']
@@ -181,14 +156,15 @@ def createDefaultSensors(doc, defaults=DEFAULTS):
 # Parser importer. These are taken from the module by type. We may want to 
 # create the list of parser types 'manually' prior to release; it's marginally 
 # safer.
-elementParserTypes = parsers.getElementHandlers()
+ELEMENT_PARSER_TYPES = parsers.getElementHandlers()
 
 
-def instantiateParsers(doc, parserTypes=elementParserTypes):
+def instantiateParsers(doc, parserTypes=None):
     """ Create a dictionary of element parser objects keyed by the name of the
         element they handle. Handlers that handle multiple elements have
         individual keys for each element name.
     """
+    parserTypes = parserTypes or ELEMENT_PARSER_TYPES
     elementParsers = {}
     for t in parserTypes:
         p = t(doc)
@@ -204,14 +180,6 @@ def instantiateParsers(doc, parserTypes=elementParserTypes):
 # Updater callbacks
 #===============================================================================
 
-def nullUpdater(*args, **kwargs):
-    """ A progress updater stand-in that does nothing. """
-    if kwargs.get('error',None) is not None:
-        raise kwargs['error']
-nullUpdater.cancelled = False
-nullUpdater.paused = False
-
-
 class SimpleUpdater(object):
     """ A simple text-based progress updater.
         :ivar cancelled: If set to `True`, the job using the updater will abort. 
@@ -261,61 +229,99 @@ def __call__(self, count=0, total=None, percent=None, error=None,
             sys.stdout.flush()
     
 
+#===============================================================================
+#
+#===============================================================================
+
+def _getSize(stream, chunkSize=512 * 1024):
+    """
+    Get the length of a stream from its data.
+
+    :param stream: A file stream or file-like object (must implement `tell()`
+        and `seek()`).
+    :returns: The total length of the file.
+    """
+    if not (hasattr(stream, 'seek') and hasattr(stream, 'tell')):
+        raise TypeError('Cannot get size of non-stream {}'.format(type(stream)))
+
+    # If it's a real file, no problem!
+    if hasattr(stream, 'name'):
+        if os.path.isfile(stream.name):
+            return os.path.getsize(stream.name)
+
+    originalPos = stream.tell()
+
+    # Grab chunks until less is read than requested.
+    thisRead = chunkSize
+    while thisRead == chunkSize:
+        thisRead = len(stream.read(chunkSize))
+
+    eof = stream.tell()
+    stream.seek(originalPos)
+    return eof
+
+
 #===============================================================================
 # ACTUAL FILE READING HAPPENS BELOW
 #===============================================================================
 
-def importFile(filename='', updater=nullUpdater, numUpdates=500,
-               updateInterval=1.0, parserTypes=elementParserTypes, 
-               defaults=DEFAULTS, name=None, quiet=False):
+def importFile(filename='', startTime=None, endTime=None, channels=None,
+               updater=None, parserTypes=None, defaults=None, name=None,
+               quiet=False, **kwargs):
     """ Create a new Dataset object and import the data from a MIDE file. 
         Primarily for testing purposes. The GUI does the file creation and 
         data loading in two discrete steps, as it will need a reference to 
         the new document before the loading starts.
         :see: `readData()`
     """
+    # FUTURE: Remove `kwargs` and this conditional warning.
+    if kwargs:
+        warnings.warn(DeprecationWarning(
+                'Some importFile() updater-related arguments have been deprecated. '
+                'Ignored arguments: {}'.format(', '.join(repr(k) for k in kwargs))))
+
+    defaults = defaults or DEFAULTS
+
     stream = open(filename, "rb")
     doc = openFile(stream, updater=updater, name=name, parserTypes=parserTypes,
                    defaults=defaults, quiet=quiet)
-    readData(doc, updater=updater, numUpdates=numUpdates, 
-             updateInterval=updateInterval, parserTypes=parserTypes, 
-             defaults=defaults)
+    readData(doc, startTime=startTime, endTime=endTime, channels=channels,
+             updater=updater, parserTypes=parserTypes, defaults=defaults)
     return doc
 
 
-def openFile(stream, updater=nullUpdater, parserTypes=elementParserTypes,  
-             defaults=DEFAULTS, name=None, quiet=False, getExitCond=True):
+def openFile(stream, updater=None, parserTypes=None, defaults=None, name=None,
+             quiet=False):
     """ Create a `Dataset` instance and read the header data (i.e. non-sample-
         data). When called by a GUI, this function should be considered 'modal,' 
         in that it shouldn't run in a background thread, unlike `readData()`. 
         
         :param stream: The file or file-like object containing the EBML data.
-        :keyword updater: A function (or function-like object) to notify as 
-            work is done. It should take four keyword arguments: `count` (the 
-            current line number), `total` (the total number of samples), `error` 
-            (an unexpected exception, if raised during the import), and `done` 
-            (will be `True` when the export is complete). If the updater object 
-            has a `cancelled` attribute that is `True`, the import will be 
-            aborted. The default callback is `None` (nothing will be notified).
-        :keyword parserTypes: A collection of `parsers.ElementHandler` classes.
-        :keyword defaults: A nested dictionary containing a default set 
-            of sensors, channels, and subchannels. These will only be used if
+        :param updater: A function (or function-like object) to notify as
+            work is done. It should take four keyword arguments: `count`
+            (the current line number), `total` (the total number of samples),
+            `error` (an unexpected exception, if raised during the import),
+            and `done` (will be `True` when the export is complete). If the
+            updater object has a `cancelled` attribute that is `True`, the
+            import will be aborted. The default callback is `None` (nothing
+            will be notified).
+        :param parserTypes: A collection of `parsers.ElementHandler` classes.
+        :param defaults: A nested dictionary containing a default set of
+            sensors, channels, and subchannels. These will only be used if
             the dataset contains no sensor/channel/subchannel definitions. 
-        :keyword name: An optional name for the Dataset. Defaults to the
+        :param name: An optional name for the Dataset. Defaults to the
             base name of the file (if applicable).
-        :keyword quiet: If `True`, non-fatal errors (e.g. schema/file
-            version mismatches) are suppressed. 
+        :param quiet: If `True`, non-fatal errors (e.g. schema/file version
+            mismatches) are suppressed.
         :return: The opened (but still 'empty') `dataset.Dataset`
     """
+    defaults = defaults or DEFAULTS
+    parserTypes = parserTypes or ELEMENT_PARSER_TYPES
+
     if isinstance(stream, str):
         stream = open(stream, 'rb')
     
-    if getExitCond:
-        exitCond = getExitCondition(stream)
-    else:
-        exitCond = None
-   
-    doc = Dataset(stream, name=name, exitCondition=exitCond, quiet=quiet)
+    doc = Dataset(stream, name=name, quiet=quiet)
     doc.addSession()
 
     if doc._parsers is None:
@@ -340,7 +346,7 @@ def openFile(stream, updater=nullUpdater, parserTypes=elementParserTypes,
             # The EBML library raises an empty IOError if it hits EOF.
             # TODO: Handle other cases of empty IOError (lots in python-ebml)
             doc.fileDamaged = True
-        else:
+        elif updater:
             updater(error=e)
 
     except TypeError as e:
@@ -358,147 +364,235 @@ def openFile(stream, updater=nullUpdater, parserTypes=elementParserTypes,
     return doc
 
 
-def readData(doc, source=None, updater=nullUpdater, numUpdates=500, updateInterval=.1,
-             total=None, bytesRead=0, samplesRead=0, parserTypes=elementParserTypes,
-             sessionId=0, onlyChannel=None, maxPause=None, **kwargs):
+def filterTime(doc, startTime=0, endTime=None, channels=None):
+    """ Efficiently read 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.
+
+        :param doc: An opened (but not yet fully imported) `Dataset`.
+        :param startTime: The start of the extraction range, relative to the
+            recording's start.
+        :param endTime: The end of the extraction range, relative to the
+            recording's end.
+        :param channels: A list of channel IDs to process. If `None` (the
+            default), all channels are processed.
+        :yields: Elements from the `Dataset`'s EBML file, excluding
+            `ChannelDataBlock`s outside of the specified time range, or
+            `None`.
+    """
+    if startTime == endTime and startTime is not None:
+        raise ValueError('startTime and endTime must differ')
+
+    startTime, endTime = sorted((startTime or 0,
+                                 endTime or float('infinity')))
+
+    # Dictionaries (and similar) for tracking progress of each ChannelDataBlock
+    # element handled. All keyed by channel ID (ChannelIDRef).
+    lastBlocks = {}  # Previous element w/ its start and end times
+    channelsWritten = Counter()  # Number of elements extracted per channel
+    finished = {}  # Channels that have completed extraction
+
+    try:
+        timeScalars = doc._parsers['ChannelDataBlock'].timeScalars
+
+        for el in doc.ebmldoc:
+            if el.name == 'ChannelDataBlock':
+                # Get ChannelIDRef, StartTimeCodeAbs, and EndTimeCodeAbs;
+                # usually the 1st three, in order, but don't assume!
+                chId = blockStart = blockEnd = None
+                for subEl in el:
+                    if subEl.name == "ChannelIDRef":
+                        chId = subEl.value
+                    elif subEl.name == "StartTimeCodeAbs":
+                        blockStart = subEl.value
+                    elif subEl.name == "EndTimeCodeAbs":
+                        blockEnd = subEl.value
+
+                blockEnd = blockEnd or blockStart
+                if chId is None:
+                    logger.warning("Extractor: {} missing <ChannelIDRef> subelement, skipping.".format(el))
+                    continue
+                if blockStart is None:
+                    logger.warning("Extractor: {} missing <StartTimeCodeAbs> subelement, skipping.".format(el))
+                    continue
+
+                if finished.setdefault(chId, False):
+                    yield None
+                    continue
+
+                if channels and chId not in channels:
+                    yield None
+                    continue
+
+                # TODO: Modulus correction, if still needed.
+                scalar = timeScalars.get(chId, 1)
+                blockStart *= scalar
+                blockEnd *= scalar
+
+                writeCurrent = True
+                writePrev = False  # write previous block, if current one starts late
+
+                if blockEnd < startTime:
+                    # Entirely before extraction interval. Yield nothing.
+                    writeCurrent = False
+                elif blockStart <= startTime:
+                    # Block overlaps start of interval. Yield block.
+                    # Mark channel finished if block also includes end of interval.
+                    writePrev = False
+                    finished[chId] = blockEnd >= endTime
+                elif blockEnd <= endTime:
+                    # Block within interval. Write block (w/ prev. if needed).
+                    writePrev = channelsWritten[chId] == 0
+                else:
+                    # Block overlaps end of interval. Yield block (w/ prev. if needed).
+                    # Mark channel finished.
+                    finished[chId] = True
+                    writePrev = channelsWritten[chId] == 0
+
+                if writePrev:
+                    # Yield the previous block, which is before the extraction interval.
+                    # This is to ensure that initial data is not left out.
+                    prev = lastBlocks.get(chId, None)
+                    if prev:
+                        channelsWritten[chId] += 1
+                        yield prev[0]
+
+                lastBlocks[chId] = (el, blockStart, blockEnd)
+
+                if writeCurrent:
+                    channelsWritten[chId] += 1
+                    yield el
+                else:
+                    yield None
+
+            else:
+                # FUTURE: Omit `<Sync>` elements outside the interval and
+                #  'manually' create ones immediately before and after? Omit
+                #  certain time-specific `<Attribute>` elements as well
+                #  (if any)?
+                yield el
+
+    except (StopIteration, KeyboardInterrupt):
+        pass
+
+
+def readData(doc, source=None, startTime=None, endTime=None, channels=None,
+             updater=None, total=None, bytesRead=0, samplesRead=0,
+             parserTypes=None, **kwargs):
     """ Import the data from a file into a Dataset.
     
         :param doc: The Dataset document into which to import the data.
         :param source: An alternate Dataset to merge into the main one.
-        :keyword updater: A function (or function-like object) to notify as 
+        :param startTime: The start of the extraction range, relative to the
+            recording's start.
+        :param endTime: The end of the extraction range, relative to the
+            recording's end.
+        :param channels: A list of channel IDs to import. If `None` (the
+            default), all channels are imported.
+        :param updater: A function (or function-like object) to notify as
             work is done. It should take four keyword arguments: `count` (the 
             current line number), `total` (the total number of samples), `error` 
             (an unexpected exception, if raised during the import), and `done` 
             (will be `True` when the export is complete). If the updater object 
             has a `cancelled` attribute that is `True`, the import will be 
             aborted. The default callback is `None` (nothing will be notified).
-        :keyword numUpdates: The minimum number of calls to the updater to be
-            made. More updates will be made if the updates take longer than
-            than the specified `updateInterval`. 
-        :keyword updateInterval: The maximum number of seconds between calls to 
-            the updater. More updates will be made if indicated by the specified
-            `numUpdates`.
-        :keyword total: The total number of bytes in the file(s) being imported.
+        :param total: The total number of bytes in the file(s) being imported.
             Defaults to the size of the current file, but can be used to
             display an overall progress when merging multiple recordings. For
             display purposes.
-        :keyword bytesRead: The number of bytes already imported. Mainly for
+        :param bytesRead: The number of bytes already imported. Mainly for
             merging multiple recordings. For display purposes.
-        :keyword samplesRead: The total number of samples imported. Mainly for
+        :param samplesRead: The total number of samples imported. Mainly for
             merging multiple recordings.
-        :keyword parserTypes: A collection of `parsers.ElementHandler` classes.
-        :keyword sessionId: The Session number to import. For future use; it
-            currently does nothing, and SSX files currently do not contain
-            multiple sessions.
-        :keyword onlyChannel: If supplied, only import data from one channel.
-        :keyword maxPause: If the updater's `paused` attribute is `True`, the
-            import will pause. This is the maximum pause length.
+        :param parserTypes: A collection of `parsers.ElementHandler` classes.
         :return: The total number of samples read.
     """
-    
+    kwargs.pop('sessionId', None)  # Unused; for Classic compatibility.
+
+    # FUTURE: Remove `kwargs` and this conditional warning.
+    if kwargs:
+        warnings.warn(DeprecationWarning(
+                'Some readData() updater-related arguments have been deprecated. '
+                'Ignored arguments: {}'.format(', '.join(repr(k) for k in kwargs))))
+
+    parserTypes = parserTypes or ELEMENT_PARSER_TYPES
     if doc._parsers is None:
+        # Possibly redundant; is `doc._parsers` ever `None` at this point?
         doc._parsers = instantiateParsers(doc, parserTypes)
     
     elementParsers = doc._parsers
 
     elementCount = 0
-    eventsRead = 0
-    
-    # Progress display stuff
-    if total is None:
-        total = doc.ebmldoc.size + bytesRead
-        
-    dataSize = total
-    
-    if numUpdates > 0:
-        ticSize = total / numUpdates 
-    else:
-        # An unreachable file position effectively disables the updates.
-        ticSize = total+1
-    
-    if updateInterval > 0:
-        nextUpdateTime = time_time() + updateInterval
-    else:
-        # An update time 24 hours in the future should mean no updates.
-        nextUpdateTime = time_time() + 5184000
-    
-    firstDataPos = 0
-    nextUpdatePos = bytesRead + ticSize
-    
+    numSamples = 0  # Number of samples imported from this file
+
+    # Progress display setup
+    if updater and total is None:
+        total = _getSize(doc.ebmldoc.stream) + bytesRead
+
+    increment = 50  # Number of elements per updater. FUTURE: Base this on total size?
     timeOffset = 0
-    maxPause = getattr(updater, "maxPause", maxPause)
-    
+
     # Actual importing ---------------------------------------------------------
     if source is None:
         source = doc
-    try:    
-        # This just skips 'header' elements. It could be more efficient, but
-        # the size of the header isn't significantly large; savings are minimal.
-        for r in source.ebmldoc:
-            
-            r_name = r.name
-            if r_name == "ChannelDataBlock":
-                r_name = "ChannelDataArrayBlock"
-            
-            doc.loadCancelled = getattr(updater, "cancelled", False)
-            if doc.loadCancelled:
-                break
-            
-            if updater.paused:
-                # Pause or throttle import.
-                pauseTime = time_time()
-                while updater.paused:
-                    sleep(0.125)
-                    if maxPause and time_time() - pauseTime > maxPause:
-                        break
-            
-            if r_name not in elementParsers:
-                # Unknown block type, but probably okay.
-                logger.info("unknown block %r (ID 0x%02x) @%d" % \
-                            (r_name, r.id, r.offset))
+
+    try:
+        if startTime is not None or endTime is not None or channels:
+            iterator = filterTime(doc, startTime, endTime, channels=channels)
+        else:
+            iterator = iter(source.ebmldoc)
+
+        for n, el in enumerate(iterator):
+            # Progress display stuff -------------------------------------
+            if updater:
+                loadCancelled = getattr(updater, "cancelled", False)
+                if loadCancelled:
+                    doc.loadCancelled = loadCancelled
+                    break
+
+                thisOffset = el.offset + bytesRead
+                if n % increment == 0:
+                    updater(count=numSamples + samplesRead,
+                            percent=thisOffset / total)
+
+            if el is None:
                 continue
-            
-            # HACK: Not the best implementation. Should be moved somewhere.
-            if onlyChannel is not None and (r_name == "ChannelDataArrayBlock"):
-                if r.value[0].value != onlyChannel:
-                    continue 
-            
-            if source != doc and r_name == "TimeBaseUTC":
-                timeOffset = (r.value - doc.lastSession.utcStartTime) * 1000000.0
+
+            el_name = el.name
+
+            if el_name not in elementParsers:
+                # Unknown block type; probably okay to skip.
+                logger.info("unknown block {!r} (ID 0x{:02x}) @{}".format(
+                        el_name, el.id, el.offset))
+                continue
+
+            if source != doc and el_name == "TimeBaseUTC":
+                timeOffset = (el.value - doc.lastSession.utcStartTime) * 1000000.0
                 continue
                 
             try:
-                parser = elementParsers[r_name]
-                if not parser.isHeader or r_name == "Attribute":
-                    added = parser.parse(r, timeOffset=timeOffset)
+                parser = elementParsers[el_name]
+
+                # "Header" elements were loaded by `openFile()`; don't duplicate.
+                if not parser.isHeader or el_name == "Attribute":
+                    added = parser.parse(el, timeOffset=timeOffset)
                     if isinstance(added, int):
-                        eventsRead += added
+                        numSamples += added
                     
             except parsers.ParsingError as err:
-                # TODO: Error messages
+                # TODO: Error messages?
                 logger.error("Parsing error during import: %s" % err)
                 continue
 
             elementCount += 1
-            
-            # More progress display stuff -------------------------------------
-            # FUTURE: Possibly do the update check every nth elements; that
-            # would have slightly less work per cycle.
-            thisOffset = r.offset + bytesRead
-            thisTime = time_time()
-            if thisTime > nextUpdateTime or thisOffset > nextUpdatePos:
-                # Update progress bar
-                updater(count=eventsRead+samplesRead,
-                        percent=(thisOffset-firstDataPos+0.0)/dataSize)
-                nextUpdatePos = thisOffset + ticSize
-                nextUpdateTime = thisTime + updateInterval
-            
+
     except IOError as e:
         if e.errno is None:
             # The EBML library raises an empty IOError if it hits EOF.
-            # TODO: Handle other cases of empty IOError (lots in python-ebml)
+            # TODO: Verify that this still does anything (leftover from old EBML library)
             doc.fileDamaged = True
-        else:
+        elif updater:
             updater(error=e, done=True)
         
     except TypeError:
@@ -506,104 +600,11 @@ def readData(doc, source=None, updater=nullUpdater, numUpdates=500, updateInterv
         # (typically the last)
         doc.fileDamaged = True
 
+    doc.fillCaches()
     doc.loading = False
-    updater(done=True)
-    return eventsRead
-
 
-#===============================================================================
-# 
-#===============================================================================
+    if updater:
+        updater(done=True)
 
-def estimateLength(filename, numSamples=50000, parserTypes=elementParserTypes, 
-                   defaults=DEFAULTS):
-    """ Open and read enough of a file to get a rough estimate of its complete
-        time range. 
-        
-        :param filename: The IDE file to open.
-        :keyword numSamples: The number of samples to read before generating
-            an estimated size.
-        :keyword parserTypes: A collection of `parsers.ElementHandler` classes.
-        :keyword defaults: Default Slam Stick description data, for use with
-            old SSX recordings.
-        :return: A 3-element tuple: 
-    """
-    
-    # Fake updater that just quits after some number of samples.
-    class DummyUpdater(object):
-        cancelled = False
-        paused = False
-        def __init__(self, n):
-            self.numSamples = n
-        def __call__(self, count=0, **kwargs):
-            self.cancelled = count > self.numSamples
-                
-    updater = DummyUpdater(numSamples)
-    
-    with open(filename, "rb") as stream:
-        doc = openFile(stream, parserTypes=parserTypes,
-                       defaults=defaults, quiet=True)
-        dataStart = stream.tell()
-        totalSize = os.path.getsize(filename) - dataStart
-        
-        # read a portion of the recording
-        readData(doc, updater=updater, parserTypes=parserTypes, 
-                 defaults=defaults)
-        chunkSize = stream.tell() - dataStart
-        
-        start = sys.maxsize
-        end = -1
-        numEvents = 0.0
-        for ch in doc.channels.values():
-            events = ch.getSession()
-            if len(events) > 0:
-                start = min(start, events[0][0])
-                end = max(end, events[-1][0])
-                numEvents += (len(events) * len(ch.subchannels))
-        
-        chunkTime = end - start
-        
-    return start, start + (totalSize / chunkSize * chunkTime), numEvents/chunkTime
-    
-
-#===============================================================================
-# 
-#===============================================================================
+    return numSamples
 
-    
-def getExitCondition(recording, bytesRead=1000):
-    """ Get the ``ExitCond`` Attribute from the end of a recording, if present.
-        The result will be an integer:
-        
-        * 1: Button press
-        * 2: USB connection
-        * 3: Recording time limit reached
-        * 4: Low battery
-        * 5: File size limit reached
-        * 128: I/O error (can occur if disk is full or 4GB FAT32 size limit
-          reached.
-        
-        :param recording: The IDE file, either a filename or a file-like object.
-        :keyword bytesRead: The number of bytes to read from the end of the
-            recording file.
-    """
-    result = None
-    
-    if isinstance(recording, str):
-        with open(recording, "rb") as fs:
-            return getExitCondition(fs)
-
-    filename = recording.name
-    offset = recording.tell()
-   
-    recording.seek(os.path.getsize(filename) - bytesRead)
-    data = recording.read()
-    try:
-        idx = data.index(b"ExitCond") + 11
-        if idx <= len(data):
-            result = data[idx]
-    except (IOError, IndexError, ValueError) as e:
-        logger.warning(e)
-
-    recording.seek(offset)
-    return result        
diff --git a/idelib/matfile.py b/idelib/matfile.py
index 7073b16f..0e4edbb3 100644
--- a/idelib/matfile.py
+++ b/idelib/matfile.py
@@ -9,7 +9,7 @@
 import struct
 
 import logging
-logger = logging.getLogger('idelib-archive')
+logger = logging.getLogger('idelib')
 logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
 
 # NOTE: 64 bit Scipy is unstable; avoid using it for now (v0.13.2, 12/2014).
@@ -185,7 +185,7 @@ def __init__(self, filename, doc=None, msg=None, serialize=True,
         """ Constructor. Create a new .MAT file.
         
             :param filename: The name of the new file, or `None`.
-            :keyword doc: The `idelib-archive.dataset.Dataset` from which to export.
+            :keyword doc: The `idelib.dataset.Dataset` from which to export.
             :keyword msg: The message string to appear at the start of the
                 MAT file. The first 4 bytes must be non-zero.
             :keyword serialize: If `True`, start the first file with a 
diff --git a/idelib/multi_importer.py b/idelib/multi_importer.py
index 7fe86cd8..18881aab 100644
--- a/idelib/multi_importer.py
+++ b/idelib/multi_importer.py
@@ -1,15 +1,14 @@
-'''
-Functions for opening multiple IDE files as one. 
+"""
+Functions for opening multiple IDE files as one.
 
 :todo: Consider moving the rest of this into the normal `importer` module.
 
-'''
+"""
 
 import fnmatch
 import os.path
 
 from .importer import openFile, readData
-from .importer import nullUpdater
 
 
 #===============================================================================
@@ -19,7 +18,7 @@
 from .dataset import __DEBUG__
 
 import logging
-logger = logging.getLogger('idelib-archive')
+logger = logging.getLogger('idelib')
 logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
 
 if __DEBUG__:
@@ -28,7 +27,6 @@
     logger.setLevel(logging.ERROR)
 
 
-
 #===============================================================================
 # 
 #===============================================================================
@@ -72,12 +70,19 @@ def crawlFiles(sources, pattern="*.ide", maxDepth=-1):
     return [x for x in results if os.path.isfile(x)]
 
 
-def multiOpen(streams, updater=nullUpdater, **kwargs):
+def multiOpen(streams, updater=None, **kwargs):
     """ Create a `Dataset` instance and read the header data (i.e. non-sample-
         data). When called by a GUI, this function should be considered 'modal,' 
         in that it shouldn't run in a background thread, unlike `readData()`. 
         
         :param streams: A set of file-like streams containing EBML recordings.
+        :param updater: A function (or function-like object) to notify as
+            work is done. It should take four keyword arguments: `count` (the
+            current line number), `total` (the total number of samples), `error`
+            (an unexpected exception, if raised during the import), and `done`
+            (will be `True` when the export is complete). If the updater object
+            has a `cancelled` attribute that is `True`, the import will be
+            aborted. The default callback is `None` (nothing will be notified).
         :keyword parserTypes: A collection of `parsers.ElementHandler` classes.
         :keyword defaultSensors: A nested dictionary containing a default set 
             of sensors, channels, and subchannels. These will only be used if
@@ -87,7 +92,8 @@ def multiOpen(streams, updater=nullUpdater, **kwargs):
         :keyword quiet: If `True`, non-fatal errors (e.g. schema/file
             version mismatches) are suppressed. 
     """
-    updater(0)
+    if updater:
+        updater(0)
     
     docs = [openFile(f, **kwargs) for f in streams]
     docs.sort(key=lambda x: x.lastSession.utcStartTime)
@@ -97,13 +103,13 @@ def multiOpen(streams, updater=nullUpdater, **kwargs):
     return mainDoc
 
 
-def multiRead(doc, updater=nullUpdater, **kwargs):
+def multiRead(doc, updater=None, **kwargs):
     """ Import the data from a file into a Dataset, including the data from 
         its subsets.
     
         :param doc: The Dataset document into which to import the data. It
             should have `subset` Datasets.
-        :keyword updater: A function (or function-like object) to notify as 
+        :param updater: A function (or function-like object) to notify as
             work is done. It should take four keyword arguments: `count` (the 
             current line number), `total` (the total number of samples), `error` 
             (an unexpected exception, if raised during the import), and `done` 
@@ -133,10 +139,12 @@ def multiRead(doc, updater=nullUpdater, **kwargs):
                                     bytesRead=bytesRead, samplesRead=samplesRead, 
                                     **kwargs)
             bytesRead += f.ebmldoc.size
-            updater(count=bytesRead, total=totalSize, 
-                    percent=bytesRead/(totalSize+0.0))
-        
-    updater(done=True, total=samplesRead)
+            if updater:
+                updater(count=bytesRead, total=totalSize,
+                        percent=bytesRead/(totalSize+0.0))
+
+    if updater:
+        updater(done=True, total=samplesRead)
     return doc
     
 
diff --git a/idelib/parsers.py b/idelib/parsers.py
index 1944e469..8df03758 100644
--- a/idelib/parsers.py
+++ b/idelib/parsers.py
@@ -46,10 +46,10 @@
 import numpy as np  
 
 from . import transforms
-from .util import decode_attributes
+from .attributes import decode_attributes
 
 import logging
-logger = logging.getLogger('idelib-archive')
+logger = logging.getLogger('idelib')
 logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
 
 #===============================================================================
@@ -262,7 +262,7 @@ def getElementHandlers(module=None, subElements=False):
         are identified by being subclasses of `ElementHandler`.
     
         :keyword module: The module from which to get the handlers. Defaults to
-            the current module (i.e. `idelib-archive.parsers`).
+            the current module (i.e. `idelib.parsers`).
         :keyword subElements: `True` if the set of handlers should also
             include non-root elements (e.g. the sub-elements of a
             `RecordingProperties` or `ChannelDataBlock`).
@@ -510,78 +510,6 @@ def __repr__(self):
         return "<%s Channel: %d>" % (self.__class__.__name__, self.getHeader()[1])
 
 
-    def parseWith(self, parser, start=None, end=None, step=1, subchannel=None):
-        """ Parse an element's payload. Use this instead of directly using
-            `parser.parse()` for consistency's sake.
-            
-            :param parser: The DataParser to use
-            :keyword start: First subsample index to parse 
-            :keyword end: Last subsample index to parse
-            :keyword step: The number of samples to skip, if the start and end
-                cover more than one sample.
-            :keyword subchannel: The subchannel to get, if specified.
-        """
-        # SimpleChannelDataBlock payloads contain header info; skip it.
-        data = self.payload
-        start, end, step = slice(start, end, step).indices(self.payloadSize // parser.size)
-
-        start = (start*parser.size)
-        end = (end*parser.size)
-        
-        parser_unpack_from = parser.unpack_from
-        if subchannel is not None:
-            for i in range(start,end,parser.size*step):
-                yield parser_unpack_from(data, i)[subchannel]
-        else:
-            for i in range(start,end,parser.size*step):
-                yield parser_unpack_from(data, i)
-
-
-    def parseByIndexWith(self, parser, indices, subchannel=None):
-        """ Parse an element's payload and get a specific set of samples. Used
-            primarily for resampling tricks.
-            
-            :param parser: The DataParser to use
-            :param indices: A list of indices into the block's data. 
-            :keyword subchannel: The subchannel to get, if specified.
-        """
-        # SimpleChannelDataBlock payloads contain header info; skip it.
-        data = self.payload
-        parser_unpack_from = parser.unpack_from
-        parser_size = parser.size
-        for i in indices:
-            if i >= self.numSamples:
-                continue
-            idx = (i*parser_size)
-            if subchannel is not None:
-                yield parser_unpack_from(data, idx)[subchannel]
-            else:
-                yield parser_unpack_from(data, idx)
-
-
-    def parseMinMeanMax(self, parser):
-        
-
-        if self.minMeanMax is None:
-            return None
-        try:
-            # NOTE: Because the SSX Z axis is inverted, the min/max need correction
-            # This may create a performance hit. Optimize?
-            self.min = parser.unpack_from(self.minMeanMax)
-            self.mean = parser.unpack_from(self.minMeanMax, parser.size)
-            self.max = parser.unpack_from(self.minMeanMax, parser.size*2)
-
-            self.min, self.max = list(zip(
-                *(sorted(minmax) for minmax in zip(self.min, self.max))
-            ))
-
-            return self.min, self.mean, self.max
-        except struct.error:
-            # Bad min/mean/max data: too short. Ignore it.
-            self.minMeanMax = None
-            return None
-
-
     def getNumSamples(self, parser):
         """ Compute the number of subsamples in an element's payload, as
             parsed by the given data parser.
@@ -850,35 +778,7 @@ def __init__(self, element):
         # the end timestamp; if it's missing, duplicate the starting time.
         if self.endTime is None:
             self.endTime = self.startTime
-    
-    
-    @property
-    def payload(self):
-        return self._payloadEl.value
-    
-    
-    @property
-    def minMeanMax(self):
-        if self._minMeanMaxEl is None:
-            return self._minMeanMax
-        return self._minMeanMaxEl.value
-    
-    
-    @minMeanMax.setter
-    def minMeanMax(self, v):
-        # Explicitly set (done when block contains a single sample)
-        self._minMeanMax = v
-    
-    
-    def getHeader(self):
-        """ Extract the block's header info (timestamp and channel ID).
-        """
-        return self._timestamp, self.channel
 
-
-class ChannelDataArrayBlock(ChannelDataBlock):
-    def __init__(self, element):
-        super(ChannelDataArrayBlock, self).__init__(element)
         self._payload = None
 
         self._parser = None
@@ -920,133 +820,37 @@ def payload(self):
         # 'P': '',
     }
 
-    def parseWith(self, parser, start=None, end=None, step=1, subchannel=None):
-        """ Parse an element's payload. Use this instead of directly using
-            `parser.parse()` for consistency's sake.
-
-            :param parser: The DataParser to use
-            :keyword start: First subsample index to parse
-            :keyword end: Last subsample index to parse
-            :keyword step: The number of samples to skip, if the start and end
-                cover more than one sample.
-            :keyword subchannel: The subchannel to get, if specified.
-        """
-
-        # All numpy-compatible data streams use `struct.Struct` parsers
-        isNumpyCompatibleFormat = isinstance(parser, struct.Struct)
-
-        if not isNumpyCompatibleFormat:
-            # No parser format -> assume that data is from an old .ide file
-            #   & should be handled safely using the parser object
-            blocks = list(ChannelDataBlock.parseWith(
-                self, parser, start, end, step, subchannel
-            ))
-            return np.array(blocks, dtype=np.float64).T
-
-        if parser is self._parser:
-            streamDtype = self._streamDtype
-            commonDtype = self._commonDtype
-        else:
-            parser_format = parser.format
-            if isinstance(parser_format, (bytes, bytearray)):
-                parser_format = parser_format.decode()
-            if parser_format[0] in ['<', '>']:
-                endian = parser_format[0]
-                parser_format = parser_format[1:]
-            else:
-                endian = '>'
-
-            streamDtype = np.dtype(','.join([
-                endian + self.TO_NP_TYPESTR[typeId] for typeId in parser_format
-            ]))
-
-            isHomogeneous = len(set(parser_format)) == 1
-            if isHomogeneous:
-                commonDtype = np.dtype(endian + self.TO_NP_TYPESTR[parser_format[0]])
-            else:
-                commonDtype = None
-
-            self._parser = parser
-            self._streamDtype = streamDtype
-            self._commonDtype = commonDtype
-
-        if commonDtype is not None:
-            rawData = np.frombuffer(self.payload, dtype=commonDtype)
-            rawData = rawData.reshape(-1, len(streamDtype) or 1)[start:end:step].T
-
-            if len(streamDtype) > 0 and subchannel is not None:
-                return rawData[[subchannel]]
-            return rawData
-
-        rawData = np.frombuffer(self.payload, dtype=streamDtype)[start:end:step]
-
-        # Special cases for single-channel outputs
-        if subchannel is not None:
-            return rawData[streamDtype.names[subchannel]][np.newaxis]
-
-        data = np.empty((len(streamDtype),) + rawData.shape,
-                        dtype=np.float64)
-        for i, chName in enumerate(streamDtype.names):
-            data[i] = rawData[chName]
-
-        return data
-
-    def parseByIndexWith(self, parser, indices, subchannel=None):
-        """ Parse an element's payload and get a specific set of samples. Used
-            primarily for resampling tricks.
-
-            :param parser: The DataParser to use
-            :param indices: A list of indices into the block's data.
-            :keyword subchannel: The subchannel to get, if specified.
+    @property
+    def minMeanMax(self):
+        if self._minMeanMaxEl is None:
+            return self._minMeanMax
+        return self._minMeanMaxEl.value
+    
+    
+    @minMeanMax.setter
+    def minMeanMax(self, v):
+        # Explicitly set (done when block contains a single sample)
+        self._minMeanMax = v
+    
+    
+    def getHeader(self):
+        """ Extract the block's header info (timestamp and channel ID).
         """
-        # SimpleChannelDataBlock payloads contain header info; skip it.
-        return self.parseWith(parser, subchannel=subchannel)[:, indices]
-
-    def parseMinMeanMax(self, parser):
-        if self.minMeanMax is None:
-            return None
-        try:
-            stats = np.array([
-                parser.unpack_from(self.minMeanMax, parser.size*i)
-                for i in range(3)
-            ])
-            # NOTE: Because the SSX Z axis is inverted, the min/max need correction
-            # This may create a performance hit. Optimize?
-            idx = (stats[0] > stats[2])
-            stats[[0, 2]][..., idx] = stats[[2, 0]][..., idx]
-
-            self.min, self.mean, self.max = [i.copy() for i in stats]
-            return stats
-        except struct.error:
-            # Bad min/mean/max data: too short. Ignore it.
-            self.minMeanMax = None
-            return None
+        return self._timestamp, self.channel
 
 
 class ChannelDataBlockParser(SimpleChannelDataBlockParser):
-    """ 'Factory' for ChannelDataBlock elements. Instantiated once per 
-        session (or maybe channel, depending). It handles the modulus
-        correction of the block's timestamps.
-        
-        :cvar elementName: The name of the element parsed by this parser
-        :cvar product: The class of object generated by the parser
-    """
-    product = ChannelDataBlock
-    elementName = product.__name__
-
-    timeScalar = 1000000.0 / 2**15
-
-
-class ChannelDataArrayBlockParser(ChannelDataBlockParser):
-    """ Factory for ChannelDataArrayBlock elements.  Instantiated once per
+    """ Factory for ChannelDataBlock elements.  Instantiated once per
         session/channel, handles modulus correction for the blocks' timestamps.
         Unlike the ChannelDataBlockParser, this returns blocks which store
         (cache?) data as numpy arrays.
 
         :cvar product: The class of object generated by the parser
     """
-    product = ChannelDataArrayBlock
-    elementName = product.__name__ # product.__name__
+    product = ChannelDataBlock
+    elementName = product.__name__
+
+    timeScalar = 1e6 / 2**15
 
 
 ################################################################################
diff --git a/idelib/transforms.py b/idelib/transforms.py
index 61d65d10..6e603f4e 100644
--- a/idelib/transforms.py
+++ b/idelib/transforms.py
@@ -9,12 +9,17 @@
 __all__ = ['Transform', 'Univariate', 'Bivariate', 'CombinedPoly', 'PolyPoly',
            'AccelTransform']
 
+import weakref
 from collections import OrderedDict
 import math
 from time import sleep
+import warnings
 
 import logging
-logger = logging.getLogger('idelib-archive')
+
+import numpy as np
+
+logger = logging.getLogger('idelib')
 logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
 
 #===============================================================================
@@ -37,8 +42,10 @@ def __init__(self, *args, **kwargs):
         self._str = "x"
         self._source = "lambda x: x"
         self._function = eval(self._source, {'math': math})
+        self._variables = ("x",)
         self._lastSession = None
         self._timeOffset = 0
+        self._watchers = weakref.WeakSet()
         
         # Custom attributes, e.g. Attribute elements in the EBML.
         self.attributes = kwargs.pop('attributes', None)
@@ -108,16 +115,113 @@ def isValid(self, session=None, noBivariates=False):
         except AttributeError:
             return False
 
+
+    def inplace(self, values, y=None, timestamp=None, session=None, noBivariates=False, out=None):
+        """ In-place Transform call for the Transform base class.
+            All subclasses are required to implement this method, so it will
+            raise an error if called.
+        """
+        raise NotImplementedError()
+
+
+    @property
+    def useMean(self):
+        """ Property to determine if a transform will use the mean of the
+            secondary channel.  The base class and all univariate polynomials
+            do not have a secondary channel, so they will always return the
+            default `True` and generate a warning.
+        """
+        warnings.warn(UserWarning('{} does not support useMean'.format(type(self))))
+        return True
+
+
+    @useMean.setter
+    def useMean(self, value):
+        warnings.warn(UserWarning('{} does not support useMean'.format(type(self))))
+
+
+    def addWatcher(self, watcher):
+        """ Adds `watcher` to the list (a `WeakSet`) of watchers.  The watchers
+            are other polynomials, such as a `CombinedPoly`, which reference
+            this polynomial.  This is used to propogate changes to all
+            polynomials which reference this one.
+        """
+        self._watchers.add(watcher)
+
     
     #===========================================================================
     # 
     #===========================================================================
-    
+
     @classmethod
     def null(cls, *args, **kwargs):
         return args[0]
 
 
+class ComplexTransform(Transform):
+    """ An mixin class for arbitrarily complex functions that don't follow a set form.
+
+        Instances are function-like objects that take one argument: a sensor
+        reading.
+    """
+
+    def inplace(self, values, y=None, timestamp=None, session=None, noBivariates=False, out=None):
+        """ In-place transform for the `ComplexTransform` transform.  These functions
+            can't be easily reduced and combined like polynomials, so this instead
+            wraps the `function` method.
+        """
+
+        scalar = np.isscalar(values)
+
+        if scalar:
+            values = float(values)
+        elif out is None:
+            out = np.zeros_like(values, dtype=np.float64)
+
+        if len(self._variables) == 1:
+
+            if scalar:
+                out = self.function(values)
+            else:
+                out[:] = self.function(values)
+        else:
+
+            session = self.dataset.lastSession if session is None else session
+            sessionId = None if session is None else session.sessionId
+
+            try:
+                if self._eventlist is None or self._sessionId != sessionId:
+                    channel = self.dataset.channels[self.channelId][self.subchannelId]
+                    self._eventlist = channel.getSession(session.sessionId)
+                    self._sessionId = session.sessionId
+
+            except IndexError as err:
+                # In multithreaded environments, there's a rare race condition
+                # in which the main channel can be accessed before the calibration
+                # channel has loaded. This should fix it.
+                logger.warning("%s occurred in Bivariate polynomial %r" %
+                               err.__class__.__name__, self.id)
+                return None
+
+            if noBivariates:
+                y = 0
+            elif self.useMean:
+                y = self._eventlist.getMean()
+            elif y is None and timestamp is None:
+                y = self._eventlist.getMean()
+            elif y is None and timestamp is not None:
+                y = np.fromiter((self._eventlist[self._eventlist.getEventIndexNear(t)][0] for t in
+                                 timestamp), dtype=np.float64)
+
+            if scalar:
+                out = self.function(values, y)
+            else:
+                out[:] = self.function(values, y)
+
+        return out
+
+
+
 #===============================================================================
 # Simple Transforms. These could be represented by Univariate polynomials,
 # but are hard-coded for efficiency's sake.
@@ -146,6 +250,7 @@ def __init__(self, amin=-100, amax=100, calId=0, dataset=None):
         self.coefficients = ((amax / 32767.0), amax)
 
         self.dataset = None
+        self._watchers = weakref.WeakSet()
 
 
     def copy(self):
@@ -166,6 +271,12 @@ class Univariate(Transform):
         
         Instances are function-like objects that take one argument: a sensor
         reading.
+
+        TODO: in-place changes force this to be in the form of either
+              `f(x) = coeffs[0]*x + coeffs[1]`
+              or
+              `f(x) = coeffs[0]`
+              This behavior will need to be updated later.
     """
     modifiesValue = True
     
@@ -219,23 +330,36 @@ def __init__(self, coeffs, calId=None, dataset=None, reference=0,
             :keyword attributes: A dictionary of generic attributes, e.g.
                 ones parsed from `Attribute` EBML elements, or `None`.
         """
+        if len(coeffs) > 2 or len(coeffs) == 0:
+            raise ValueError('Coefficients of length {} are not supported.  '
+                             'Coefficients must be length 1 (constant) or 2 '
+                             '(linear).'.format(len(coeffs)))
+        coeffs = tuple(float(x) for x in coeffs)
         self.id = calId
         self.dataset = dataset
         self._coeffs = tuple(coeffs)
+        self._fastCoeffs = tuple(coeffs)
         self._variables = (varName,)
         self._references = (reference,)
         self._session = None
         self._lastSession = None
         self._timeOffset = 0
+        self._watchers = weakref.WeakSet()
         
         self.attributes = attributes
         
         self._build()
+
+
+    def __hash__(self):
+        return hash((self._coeffs, self._references, self.id))
         
 
     def __eq__(self, other):
         try:
-            return self._coeffs == other._coeffs and self._references == other._references
+            return self._coeffs == other._coeffs and \
+                   self._references == other._references and \
+                   self.id == other.id
         except AttributeError:
             return False
 
@@ -259,7 +383,17 @@ def _build(self):
         if reference != 0:
             varName = "(%s-%s)" % (varName, reference)
             srcVarName = "(%s-%s)" % (srcVarName, reference)
-        
+
+        if len(coeffs) == 1:
+            self._fastCoeffs = tuple(self._coeffs)
+            self._str = str(self._fastCoeffs[0])
+        elif len(coeffs) == 2:
+            # a0*(x - ref) + a1 = a0*x + (a1 - a0*ref)
+            self._fastCoeffs = (self._coeffs[0], self._coeffs[1] - self._coeffs[0]*reference)
+        else:
+            raise ValueError('Invalid list of coefficients.')
+
+
         # f is used to build the lambda
         # strF is used to build the string version
         coeffs = list(reversed(coeffs))
@@ -280,6 +414,10 @@ def _build(self):
     
             f.append("(%s%s)" % (q, x))
             strF.append("(%s%s)" % (q, strX))
+
+        if tuple(coeffs) == (0,):
+            f = [0]
+            strF = [0]
     
         self._str = "+".join(map(str, reversed(strF)))
         self._str = self._fixSums(self._str)
@@ -287,6 +425,53 @@ def _build(self):
         self._source = "lambda x: %s" % ("+".join(map(str, reversed(f))))
         self._source = self._fixSums(self._source)
         self._function = eval(self._source, {'math': math})
+
+
+    def inplace(self, values, y=None, timestamp=None, session=None, noBivariates=False, out=None):
+        """ In-place transform for the `Univariate` transform.  It reduces the
+            number of array allocations/operations compared to the normal
+            `__call__` method.  The user can supply an `out` argument to save
+            the results to that array, or leave `out` equal to `None`.  If `out`
+            is `None`, then this method will allocate an array in the shape of
+            `values`.
+
+            The method uses the following algebra, calculated in the `_build`
+            method, to reduce the equation to fewer operations.  For brevity:
+                `x` = `values`
+                `an` = `coeffs[n]`
+                `bn` = `_fastCoeffs[n]`
+
+            `f(x) = a0*(x - ref) + a1`
+             =>
+            `f(x) = x*(a0) + (a1 - a0*ref)`
+             =>
+            `f(x) = b0*x + b1`
+
+        """
+
+        scalar = np.isscalar(values)
+
+        if scalar:
+            values = float(values)
+        elif out is None:
+            out = np.zeros_like(values, dtype=np.float64)
+
+        if len(self._fastCoeffs) == 1:
+            if scalar:
+                out = self._fastCoeffs[0]
+            else:
+                out[:] = self._fastCoeffs[0]
+        elif len(self._fastCoeffs) == 2:
+            if scalar:
+                out = values
+            else:
+                out[:] = values
+            out *= self._fastCoeffs[0]
+            out += self._fastCoeffs[1]
+        else:
+            raise
+
+        return out
     
     
     @property
@@ -312,7 +497,7 @@ def variables(self, val):
         self._variables = val
         self._build()
     
-    
+
     @property
     def references(self):
         """ The constant offset(s). """
@@ -364,6 +549,11 @@ def __init__(self, coeffs, calId=None, dataset=None, reference=0,
             :keyword attributes: A dictionary of generic attributes, e.g.
                 ones parsed from `Attribute` EBML elements, or `None`.
         """
+        if len(coeffs) not in [1, 4]:
+            raise ValueError('Coefficients of length {} are not supported.  '
+                             'Coefficients must be length 1 (constant) or 4 '
+                             '(linear).'.format(len(coeffs)))
+        coeffs = tuple(float(x) for x in coeffs)
         self.dataset = dataset
         self._eventlist = None
         self._sessionId = None
@@ -372,25 +562,26 @@ def __init__(self, coeffs, calId=None, dataset=None, reference=0,
             raise ValueError("Bivariate polynomial requires channel and "
                     "subchannel IDs; got %r, %d" % (channelId, subchannelId))
         
-        if len(coeffs) != 4:
-            raise ValueError("Bivariate polynomial must have exactly 4 "
-                             "coefficients; %d were supplied" % len(coeffs))
-        if len(varNames) != 2:
-            raise ValueError("Bivariate polynomial must have two variable "
-                             "names; %d were supplied" % len(varNames))
-        
         self.id = calId
         
         self._references = (float(reference), float(reference2))
         self._coeffs = tuple(map(float, coeffs))
+        self._fastCoeffs = tuple(self._coeffs)
         self._variables = tuple(map(str, varNames))
         
         self._session = None
         self._timeOffset = 0
         
         self.attributes = attributes
+
+        self._useMean = True
+        self._watchers = weakref.WeakSet()
         
         self._build()
+
+
+    def __hash__(self):
+        return hash(self._str)
         
         
     def __eq__(self, other):
@@ -441,7 +632,26 @@ def _build(self):
         coeffs = self._coeffs
         reference, reference2 = self._references
         varNames = self._variables
-        
+
+        if len(coeffs) not in [1, 4]:
+            raise ValueError('Coefficients of length {} are not supported.  '
+                             'Coefficients must be length 1 (constant) or 4 '
+                             '(linear).'.format(len(coeffs)))
+
+        if len(coeffs) == 1:
+            self._fastCoeffs = tuple(self._coeffs)
+        if len(coeffs) == 4:
+            # a0*(x - refx)*(y - refy) + a1*(x - refx) + a2*(y - refy) + a3 =
+            # = a0*x*y + x*(a1 - a0*refy) + y*(a2 - a0*refx) +
+            #   + (a0*refx*refy + a3 - a1*refx - a2*refy)
+            self._fastCoeffs = (
+                self._coeffs[0],
+                self._coeffs[1] - self._coeffs[0]*reference2,
+                self._coeffs[2] - self._coeffs[0]*reference,
+                self._coeffs[3] + self._coeffs[0]*reference*reference2 -
+                self._coeffs[1]*reference - self._coeffs[2]*reference2,
+                )
+
         # Construct the polynomial expression string
         strF = "(%s*x*y)+(%s*x)+(%s*y)+%s"
         src = strF % tuple(map(self._floatOrInt, coeffs))
@@ -481,7 +691,108 @@ def _build(self):
         
         # Optimization: it is possible that the polynomial could exclude Y
         # completely. If that's the case, use a dummy value to speed things up.
-        self._noY = (0, 1) if 'y' not in src else False 
+        self._noY = (0, 1) if 'y' not in src else False
+
+
+    def inplace(self, values, y=None, timestamp=None, session=None, noBivariates=False, out=None):
+        """ In-place transform for the `Bivariate` transform.  It reduces the
+            number of array allocations/operations compared to the normal
+            `__call__` method.  The user can supply an `out` argument to save
+            the results to that array, or leave `out` equal to `None`.  If `out`
+            is `None`, then this method will allocate an array in the shape of
+            `values`.
+
+            When calculating the `y` value, several things need to be taken into
+            account in the following priority:
+            1. If `noBivariates` is `True`, then `y` must be equal to `0`.
+            2. If `self.useMean` is `True`, then `y` is equal to the mean of the
+               full length of the secondary channel.
+            3. If `y` is provided, then use the provided value.
+            4. If `timestamp` is provided, then get the values for the given `timestamp`.
+            5. Otherwise, default to the mean of the secondary channel.
+
+            Depending on if `y` is scalar, one of two methods will be used, detailed below.
+
+            The method uses the following algebra, calculated in the `_build`
+            method, to reduce the equation to fewer operations.  For brevity:
+                `x` = `values`
+                `xref`, `yref` = `references`
+                `an` = `coeffs[n]`
+                `bn` = `_fastCoeffs[n]`
+
+            `y` is not scalar:
+
+              `f(x) = a0*(x - xref)*(y - yref) + a1*(x - xref) + a2*(y - yref) + a3`
+               =>
+              `f(x) = x*y*(a0) + x*(a1 - a0*yref) + y*(a2 - a0*xref) + (a3 + a0*xref*yref - a1*xref - a2*yref)`
+               =>
+              `f(x) = b0*x*y + b1*x + b2*y + b3`
+
+            `y` is scalar:
+
+              `f(x) = b0*x*y + b1*x + b2*y + b3`
+               =>
+              `f(x) = x*(b0*y + b1) + (b2*y + b3)`
+        """
+
+        scalar = np.isscalar(values)
+
+        if scalar:
+            values = float(values)
+        elif out is None:
+            out = np.zeros_like(values, dtype=np.float64)
+
+        session = self.dataset.lastSession if session is None else session
+        sessionId = None if session is None else session.sessionId
+
+        try:
+            if self._eventlist is None or self._sessionId != sessionId:
+                channel = self.dataset.channels[self.channelId][self.subchannelId]
+                self._eventlist = channel.getSession(session.sessionId)
+                self._sessionId = session.sessionId
+
+        except IndexError as err:
+            # In multithreaded environments, there's a rare race condition
+            # in which the main channel can be accessed before the calibration
+            # channel has loaded. This should fix it.
+            logger.warning("%s occurred in Bivariate polynomial %r" %
+                           err.__class__.__name__, self.id)
+            return None
+
+        if noBivariates:
+            y = 0
+        elif self.useMean:
+            y = self._eventlist.getMean()
+        elif y is None and timestamp is None:
+            y = self._eventlist.getMean()
+        elif y is None and timestamp is not None:
+            y = np.fromiter((self._eventlist[self._eventlist.getEventIndexNear(t)][0] for t in timestamp), dtype=np.float64)
+
+        if len(self._fastCoeffs) == 1:
+            if scalar:
+                out = self._fastCoeffs[0]
+            else:
+                out[:] = self._fastCoeffs[0]
+        elif len(self._fastCoeffs) == 4:
+            if np.isscalar(y):
+                # a0*x*y + a1*x + a2*y + a3 =
+                # x*(a0*y + a1) + (a2*y + a3)
+                if scalar:
+                    out = values
+                else:
+                    out[:] = values
+                out *= self._fastCoeffs[0]*y + self._fastCoeffs[1]
+                out += self._fastCoeffs[2]*y + self._fastCoeffs[3]
+            else:
+                out += values*y*self._fastCoeffs[0]
+                out += values*self._fastCoeffs[1]
+                out += y*self._fastCoeffs[2]
+                out += self._fastCoeffs[3]
+
+        else:
+            raise
+
+        return out
 
 
     def __call__(self, timestamp, value, session=None, noBivariates=False):
@@ -508,7 +819,7 @@ def __call__(self, timestamp, value, session=None, noBivariates=False):
             if noBivariates:
                 y = (0, 1)
             else:
-                y = self._noY or self._eventlist.getMeanNear(timestamp)
+                y = self._noY or self._eventlist.getMean()
             return timestamp, self._function(value, y)
         
         except (IndexError, ZeroDivisionError) as err:
@@ -519,6 +830,14 @@ def __call__(self, timestamp, value, session=None, noBivariates=False):
                            err.__class__.__name__, self.id)
             return None
 
+        except (IndexError, ZeroDivisionError) as err:
+            # In multithreaded environments, there's a rare race condition
+            # in which the main channel can be accessed before the calibration
+            # channel has loaded. This should fix it.
+            logger.warning("%s occurred in Bivariate polynomial %r" %
+                           err.__class__.__name__, self.id)
+            return None
+
 
     def asDict(self):
         """ Dump the polynomial as a dictionary. Intended for use when
@@ -564,6 +883,18 @@ def isValid(self, session=None, noBivariates=False, _retries=3):
             return self.isValid(session, noBivariates, _retries-1)
 
 
+    @property
+    def useMean(self):
+        return self._useMean
+
+
+    @useMean.setter
+    def useMean(self, value):
+        self._useMean = value
+        for w in self._watchers:
+            w.useMean = self.useMean
+
+
 #===============================================================================
 # 
 #===============================================================================
@@ -586,6 +917,7 @@ def __init__(self, poly, subchannel=None, calId=None, dataset=None,
         self.id = calId
         self.poly = poly
         self.subpolys = kwargs
+        self._fastCoeffs = None
         
         if subchannel is not None:
             self.poly = self.poly[subchannel]
@@ -598,27 +930,29 @@ def __init__(self, poly, subchannel=None, calId=None, dataset=None,
                     
         self.dataset = self.dataset or dataset
         self._subchannel = subchannel
+        self._watchers = weakref.WeakSet()
         self._build()
+
+        self._useMean = True
     
     
     def _build(self):
         if self.poly is None:
+            self.poly = Univariate((1, 0))
             if len(self.subpolys) == 1:
                 p = list(self.subpolys.values())[0]
                 if p is not None:
                     for attr in ('_str', '_source', '_function', '_noY'):
                         setattr(self, attr, getattr(p, attr, None))
-                    return
-            phead, src = "lambda x", "x"
-        else:
-            phead, src = self.poly.source.split(": ")
-            
+        phead, _, src = self.poly.source.partition(": ")
+
         for k, v in self.subpolys.items():
             if v is None:
                 ssrc = "lambda x: x"
             else:
-                ssrc = v.source 
-            s = "(%s)" % ssrc.split(": ")[-1]
+                ssrc = v.source
+            start, _, end = ssrc.rpartition(": ")
+            s = "(%s)" % (end or start)
             src = self._reduce(src.replace(k, s))
 
         if self._subchannel is not None:
@@ -632,11 +966,263 @@ def _build(self):
         for p in self.subpolys.values():
             if p is not None:
                 evalGlobals.update(p._function.__globals__)
+
+        if isinstance(self.poly, Bivariate):
+            # a0*(b0*x + b1)*(c0*y + c1) + a1*(b0*x + b1) + a2*(c0*y + c1) + a3 =
+            # x*y*(a0*b0*c0) + x*(a0*b0*c1 + a1*b0) + y*(a0*b1*c0 + a2*c0) + (a0*b1*c1 + a1*b1 + a2*c1 + a3)
+            a = self.poly._fastCoeffs
+            if self.poly.variables[0] in self.subpolys:
+                if self.subpolys[self.variables[0]] is not None and \
+                        not isinstance(self.subpolys[self.variables[0]], ComplexTransform):
+                    b = self.subpolys[self.variables[0]]._fastCoeffs
+                else:
+                    b = (1, 0)
+            else:
+                b = (1, 0)
+            if self.poly.variables[1] in self.subpolys:
+                if self.subpolys[self.variables[1]] is not None and \
+                        not isinstance(self.subpolys[self.variables[1]], ComplexTransform):
+                    c = self.subpolys[self.variables[1]]._fastCoeffs
+                else:
+                    c = (1, 0)
+            else:
+                c = (1, 0)
+            self._fastCoeffs = (
+                a[0]*b[0]*c[0],
+                a[0]*b[0]*c[1] + a[1]*b[0],
+                a[0]*b[1]*c[0] + a[2]*c[0],
+                a[0]*b[1]*c[1] + a[1]*b[1] + a[2]*c[1] + a[3],
+                )
+        elif isinstance(self.poly, Univariate):
+            # a0*(b0*x + b1) + a1 = a0*b0*x + (a1 + a0*b1)
+            a = self.poly._fastCoeffs
+            b = self.subpolys[self.poly.variables[0]]
+            if b is None:
+                b = (1., 0.)
+            else:
+                b = b._fastCoeffs
+            self._fastCoeffs = (a[0]*b[0], a[1] + a[0]*b[1])
         
         self._str = src
         self._source = "%s: %s" % (phead, src)
         self._function = eval(self._source, evalGlobals)
         self._noY = (0, 1) if 'y' not in src else False
+
+        for x in [self.poly] + list(self.subpolys.values()):
+            if x is not None:
+                x.addWatcher(self)
+
+    def inplace(self, values, y=None, timestamp=None, session=None, noBivariates=False, out=None):
+        """ In-place transform for the `CombinedPoly` transform.  It reduces the
+            number of array allocations/operations compared to the normal
+            `__call__` method.  The user can supply an `out` argument to save
+            the results to that array, or leave `out` equal to `None`.  If `out`
+            is `None`, then this method will allocate an array in the shape of
+            `values`.
+
+            If any component polynomials are a `ComplexTransform`, those will be
+            calculated first.
+
+            If the main polynomial is a `Complex Transform`, then all values
+            will be calculated separately.
+
+            If the component polynomials of this `CombinedPoly` are all
+            `Univariate`, then the following equation will be used.  Otherwise,
+            the rest of the documentation will detail how a `CombinedPoly` based
+            on a `Bivariate` behaves.
+
+            The method uses the following algebra, calculated in the `_build`
+            method, to reduce the equation to fewer operations.  For brevity:
+                `x` = `values`
+                `xref`, `yref` = `references`
+                `an` = `poly.coeffs[n]`
+                `bn` = `subpoly['x']._fastCoeffs[n]`
+                `cn` = `_fastCoeffs[n]`
+
+            `f(x) = a0*(b0*x + b1 - xref) + a1`
+             =>
+            `f(x) = x*(a0*b0) + (a0*(b1 - xref) + a1)`
+             =>
+            `f(x) = c0*x + c1`
+
+            When calculating the `y` value, several things need to be taken into
+            account in the following priority:
+            1. If `noBivariates` is `True`, then `y` must be equal to `0`.
+            2. If `self.useMean` is `True`, then `y` is equal to the mean of the
+               full length of the secondary channel.
+            3. If `y` is provided, then use the provided value.
+            4. If `timestamp` is provided, then get the values for the given `timestamp`.
+            5. Otherwise, default to the mean of the secondary channel.
+
+            Depending on if `y` is scalar, one of two methods will be used, detailed below.
+
+            The method uses the following algebra, calculated in the `_build`
+            method, to reduce the equation to fewer operations.  For brevity:
+                `x` = `values`
+                `xref`, `yref` = `references`
+                `an` = `poly.coeffs[n]`
+                `bn` = `subpoly['x']._fastCoeffs[n]`
+                `cn` = `subpoly['y']._fastCoeffs[n]`
+                `dn` = `_fastCoeffs[n]`
+
+            `y` is not scalar:
+
+              `f(x) = a0*(b0*x + b1 - xref)*(c0*y + c1 - yref) + a1*(b0*x + b1 - xref) +
+                    + a2*(c0*y + c1 - yref) + a3`
+               =>
+              `f(x) = x*y*(a0*b0*c0) + x*(a0*b0*(c1 - yref) + a1*b0) +
+                    + y*(a0*(b1 - xref)*c0 + a2*c0) +
+                    + (a0*(b1 - xref)*(c1 - yref) + a1*(b1 - xref) + a2*(c1 - yref) + a3)`
+               =>
+              `f(x) = d0*x*y + d1*x + d2*y + d3`
+
+            `y` is scalar:
+
+              `f(x) = d0*x*y + d1*x + d2*y + d3`
+               =>
+              `f(x) = x*(d0*y + d1) + (d2*y + d3)`
+        """
+
+        scalar = np.isscalar(values)
+
+        # Catches the case where the first subpoly is a `ComplexTransform`
+        if self.variables[0] in self.subpolys and isinstance(self.subpolys[self.variables[0]], ComplexTransform):
+            values = self.subpolys[self.variables[0]].inplace(
+                    values,
+                    timestamp=timestamp,
+                    session=session,
+                    noBivariates=noBivariates,
+                    )
+
+        if scalar:
+            values = float(values)
+        elif out is None:
+            out = np.zeros_like(values, dtype=np.float64)
+
+
+        try:
+            if len(self.variables) == 1:
+                if isinstance(self.poly, ComplexTransform):
+
+                    if self.variables[0] in self.subpolys and not isinstance(self.subpolys[self.variables[0]], ComplexTransform):
+                        values = self.subpolys[self.variables[0]].inplace(
+                                values,
+                                timestamp=timestamp,
+                                session=session,
+                                noBivariates=noBivariates,
+                                )
+
+                    if scalar:
+                        out = self.poly.function(values)
+                    else:
+                        out[:] = self.poly.function(values)
+                else:
+                    if scalar:
+                        out = values
+                    else:
+                        out[:] = values
+
+                    out *= self._fastCoeffs[0]
+                    out += self._fastCoeffs[1]
+            else:
+
+                session = self.dataset.lastSession if session is None else session
+                sessionId = None if session is None else session.sessionId
+
+                if self._eventlist is None or self._sessionId != sessionId:
+                    channel = self.dataset.channels[self.channelId][self.subchannelId]
+                    self._eventlist = channel.getSession(session.sessionId)
+                    self._sessionId = session.sessionId
+
+                if noBivariates:
+                    y = 0
+                elif self.useMean:
+                    y = self._eventlist.getMean()
+                elif y is None and timestamp is None:
+                    y = self._eventlist.getMean()
+                elif y is None and timestamp is not None:
+                    y = np.fromiter(
+                            (self._eventlist[self._eventlist.getEventIndexNear(t)][0] for t in
+                             timestamp), dtype=np.float64)
+
+                # Catches the case where the secondary subpoly is a `ComplexTransform`
+                if self.variables[1] in self.subpolys and isinstance(self.subpolys[self.variables[1]], ComplexTransform):
+                    y = self.subpolys[self.variables[1]].inplace(
+                            y,
+                            timestamp=timestamp,
+                            session=session,
+                            noBivariates=noBivariates,
+                            )
+
+                # in this instance, basically give up on efficiency
+                if isinstance(self.poly, ComplexTransform):
+
+                    if self.variables[0] in self.subpolys and not isinstance(self.subpolys[self.variables[0]], ComplexTransform):
+                        values = self.subpolys[self.variables[0]].inplace(
+                                values,
+                                timestamp=timestamp,
+                                session=session,
+                                noBivariates=noBivariates,
+                                )
+
+                    if self.variables[1] in self.subpolys and not isinstance(self.subpolys[self.variables[1]], ComplexTransform):
+                        y = self.subpolys[self.variables[1]].inplace(
+                                y,
+                                timestamp=timestamp,
+                                session=session,
+                                noBivariates=noBivariates,
+                                )
+
+                    if scalar:
+                        out = self.poly.inplace(
+                                values,
+                                y=y,
+                                timestamp=timestamp,
+                                session=session,
+                                noBivariates=noBivariates,
+                                )
+                    else:
+
+                        out[:] = self.poly.inplace(
+                                values,
+                                y=y,
+                                timestamp=timestamp,
+                                session=session,
+                                noBivariates=noBivariates,
+                                )
+
+                else:
+
+                    if np.isscalar(y):
+                        # a2*x*y + b2*x + c2*y + d2 =
+                        # x*(a2*y + b) + (c2*y + d2) =
+                        # x*a3 + b3
+                        if scalar:
+                            out = values
+                        else:
+                            out[:] = values
+                        a3 = self._fastCoeffs[0]*y + self._fastCoeffs[1]
+                        b3 = self._fastCoeffs[2]*y + self._fastCoeffs[3]
+                        out *= a3
+                        out += b3
+                    else:
+                        if np.scalar:
+                            out = values*y*self._fastCoeffs[0]
+                        else:
+                            out[:] = values*y*self._fastCoeffs[0]
+                        out += values*self._fastCoeffs[1]
+                        out += y*self._fastCoeffs[2]
+                        out += self._fastCoeffs[3]
+
+            return out
+
+        except (IndexError, ZeroDivisionError) as err:
+            # In multithreaded environments, there's a rare race condition
+            # in which the main channel can be accessed before the calibration
+            # channel has loaded. This should fix it.
+            logger.warning("%s occurred in Bivariate polynomial %r" %
+                           err.__class__.__name__, self.id)
+            return None
         
         
     def asDict(self):
@@ -677,8 +1263,11 @@ def __init__(self, polys, calId=None, dataset=None):
         
         self.dataset = self.dataset or dataset
         self._eventlist = None
+        self._watchers = weakref.WeakSet()
         self._build()
 
+        self._useMean = True
+
 
     def _build(self):
         params = []
@@ -688,7 +1277,8 @@ def _build(self):
             if p is None:
                 body.append('x%d' % n)
             else:
-                body.append(p.source.split(':')[-1].replace('x', 'x%d' % n))
+                start, _, end = p.source.rpartition(':')
+                body.append((end or start).replace('x', 'x%d' % n))
         
         # ends with a comma to ensure a tuple is returned
         src = "(%s,)" % (', '.join(body))
@@ -706,6 +1296,7 @@ def _build(self):
         for p in self.polys:
             if p is not None:
                 evalGlobals.update(p._function.__globals__)
+                p.addWatcher(self)
         
         self._source = "lambda %s: %s" % (','.join(params), src)
         self._function = eval(self._source, evalGlobals)
@@ -736,12 +1327,12 @@ def __call__(self, timestamp, values, session=None, noBivariates=False):
                 # XXX: Hack! EventList length can be 0 if a thread is running.
                 # This almost immediately gets fixed. Find real cause.
                 try:    
-                    y = self._eventlist.getMeanNear(timestamp, outOfRange=True)
+                    y = self._eventlist.getMean()
                 except IndexError:
                     sleep(0.001)
                     if len(self._eventlist) == 0:
                         return None
-                    y = self._eventlist.getMeanNear(timestamp, outOfRange=True)
+                    y = self._eventlist.getMean()
                     
                 return timestamp, self._function(y, *values)
             
@@ -759,6 +1350,66 @@ def __call__(self, timestamp, values, session=None, noBivariates=False):
             raise
 
 
+    def inplace(self, values, y=None, timestamp=None, session=None, noBivariates=False, out=None):
+        """ In-place transform for the `PolyPoly` transform.  It reduces the
+            number of array allocations/operations compared to the normal
+            `__call__` method.  The user can supply an `out` argument to save
+            the results to that array, or leave `out` equal to `None`.  If `out`
+            is `None`, then this method will allocate an array in the shape of
+            `values`.
+
+            This method does not do any calculations, and instead leaves that to
+            its member polynomials.
+        """
+        if out is None:
+            out = np.zeros_like(values, dtype=np.float64)
+
+        try:
+            # Optimization: don't check the other channel if Y is unused
+            if self._noY is False:
+                if noBivariates:
+                    for i, poly in enumerate(self.polys):
+                        poly.inplace(values[i], y=0, out=out[i])
+                    return out
+
+                session = self.dataset.lastSession if session is None else session
+                sessionId = None if session is None else session.sessionId
+
+                if self._eventlist is None or self._sessionId != sessionId:
+                    channel = self.dataset.channels[self.channelId][self.subchannelId]
+                    self._eventlist = channel.getSession(session.sessionId)
+                    self._sessionId = session.sessionId
+
+                # XXX: Hack! EventList length can be 0 if a thread is running.
+                # This almost immediately gets fixed. Find real cause.
+                try:
+                    y = self._eventlist.getMean()
+                except IndexError:
+                    sleep(0.001)
+                    if len(self._eventlist) == 0:
+                        return None
+                    y = self._eventlist.getMean()
+
+                for i, poly in enumerate(self.polys):
+                    poly.inplace(values[i], y=y, out=out[i])
+                return out
+
+            else:
+                for i, poly in enumerate(self.polys):
+                    poly.inplace(values[i], out=out[i])
+                return out
+
+        except (TypeError, IndexError, ZeroDivisionError) as err:
+            # In multithreaded environments, there's a rare race condition
+            # in which the main channel can be accessed before the calibration
+            # channel has loaded. This should fix it.
+            if getattr(self.dataset, 'loading', False):
+                logger.warning("%s occurred in combined polynomial %r"%
+                               (err.__class__.__name__, self))
+                return None
+            raise
+
+
     def isValid(self, session=None, noBivariates=False):
         """ Check the validity of the Transform.
         """
diff --git a/idelib/unit_conversion.py b/idelib/unit_conversion.py
index f056c2e9..c2ad9ceb 100644
--- a/idelib/unit_conversion.py
+++ b/idelib/unit_conversion.py
@@ -9,10 +9,11 @@
 :author: dstokes
 """
 import math
+import weakref
 
 import numpy as np
 
-from .transforms import Univariate
+from .transforms import ComplexTransform, Transform, Univariate
 from .dataset import Channel
 
 # ==============================================================================
@@ -34,17 +35,21 @@ def registerConverter(cls):
 # 
 # ==============================================================================
 
-class UnitConverter(Univariate):
+class UnitConverter(Transform):
     """ Mix-in class for unit conversion transforms.
     """
     modifiesValue = True
     modifiesTime = False
     parameters = None
-    
+
+    def convert(self, v):
+        """ Convert a value to new units.  Aliases self.function() """
+        return self.function(v)
+
     @classmethod
     def isApplicable(cls, obj):
         """ Is this converter applicable to the given object?
-         
+
             :param obj: A `Channel` or `EventList` (or a subclass of either).
                 Can also be a list or tuple of said objects. In the latter case,
                 the applicability of each item is tested, and `True` returned
@@ -67,16 +72,16 @@ def isApplicable(cls, obj):
             return False
         units = (fromUnits[0] or sourceUnits[0], fromUnits[1] or sourceUnits[1])
         return units == sourceUnits
-    
-    def convert(self, v):
-        """ Convert a value to new units.  Aliases self.function() """
-        return self.function(v)
+
+
+class LinearUnitConverter(Univariate, UnitConverter):
+    """ Mix-in class for linear unit conversion transforms.
+    """
     
     def revert(self, v):
         """ Convert a value back to the original units. Primarily for display
             purposes.
         """
-        # TODO: Make this work on Univariates with more than 2 coefficients.
         # May never be needed.
         a, b = self.coefficients
         ref = self.references[0]
@@ -88,7 +93,7 @@ def revert(self, v):
 
 
 @registerConverter
-class Celsius2Fahrenheit(UnitConverter):
+class Celsius2Fahrenheit(LinearUnitConverter):
     """ Convert degrees Celsius to Fahrenheit. 
     """
     convertsFrom = ('Temperature', '\xb0C')
@@ -102,7 +107,7 @@ def __init__(self, calId=None, dataset=None, varName="x"):
     
 
 @registerConverter
-class Celsius2Kelvin(UnitConverter):
+class Celsius2Kelvin(LinearUnitConverter):
     """ Convert degrees Celsius to Kelvin. 
     """
     convertsFrom = ('Temperature', '\xb0C')
@@ -116,7 +121,7 @@ def __init__(self, calId=None, dataset=None, varName="x"):
         
 
 @registerConverter
-class Gravity2MPerSec2(UnitConverter):
+class Gravity2MPerSec2(LinearUnitConverter):
     """ Convert acceleration from g to m/s^2.
     """
     convertsFrom = ('Acceleration', 'g')
@@ -128,7 +133,7 @@ def __init__(self, calId=None, dataset=None, varName='x'):
 
 
 @registerConverter
-class Meters2Feet(UnitConverter):
+class Meters2Feet(LinearUnitConverter):
     """ Convert meters to feet.
     """
     convertsFrom = (None, 'm')
@@ -140,7 +145,7 @@ def __init__(self, calId=None, dataset=None, varName='x'):
 
 
 @registerConverter
-class Pa2PSI(UnitConverter):
+class Pa2PSI(LinearUnitConverter):
     """ Convert air pressure from Pascals to pounds per square inch.
     """
     convertsFrom = ('Pressure', 'Pa')
@@ -152,7 +157,7 @@ def __init__(self, calId=None, dataset=None, varName='x'):
 
 
 @registerConverter
-class Pa2atm(UnitConverter):
+class Pa2atm(LinearUnitConverter):
     """ Convert air pressure from Pascals to atmospheres.
     """
     convertsFrom = ('Pressure', 'Pa')
@@ -169,7 +174,7 @@ def __init__(self, calId=None, dataset=None, varName='x'):
 
 
 @registerConverter
-class Pressure2Meters(UnitConverter):
+class Pressure2Meters(UnitConverter, ComplexTransform):
     """ Convert pressure in Pascals to an altitude in meters.
     """
     convertsFrom = ('Pressure', 'Pa')
@@ -198,6 +203,7 @@ def __init__(self, calId=None, dataset=None, temp=15.0, sealevel=101325.0):
         self.id = calId
         self._lastSession = None
         self._timeOffset = 0
+        self._watchers = weakref.WeakSet()
         self._build()
 
     def __hash__(self):
diff --git a/idelib/util.py b/idelib/util.py
index 5e87e1e1..dd661965 100644
--- a/idelib/util.py
+++ b/idelib/util.py
@@ -1,31 +1,33 @@
 """
 Utility functions for doing low-level, general-purpose EBML reading and writing.
+"""
 
-Note: Most of the functionality that was here is now part of ebmlite. This
-    module now contains little besides the 'Attribute' handling functions.
+from io import IOBase
+import logging
+import os.path
+from pathlib import Path
 
-Created on Dec 10, 2013
+from ebmlite import loadSchema
 
-:author: dstokes
+from .importer import filterTime, openFile, _getSize
+from .dataset import Dataset
 
-"""
-
-from collections import OrderedDict
-import datetime
-import time
+# ==============================================================================
+#
+# ==============================================================================
 
-from ebmlite import loadSchema
+logger = logging.getLogger('idelib')
 
 
 # ==============================================================================
-# 
+#
 # ==============================================================================
 
 def verify(data, schema=None):
     """ Basic sanity-check of data validity. If the data is bad an exception
         will be raised. The specific exception varies depending on the problem
         in the data.
-        
+
         :keyword schema: The full module name of the EBML schema.
         :return: `True`. Any problems will raise exceptions.
     """
@@ -35,80 +37,282 @@ def verify(data, schema=None):
 
 
 # ==============================================================================
-# 
+#
+# ==============================================================================
+
+def extractTime(doc, out, startTime=0, endTime=None, channels=None,
+                updater=None):
+    """ 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.
+
+        :param doc: A loaded `Dataset` or the name of an IDE file.
+        :param out: A filename or stream to which to save the extracted data.
+        :param startTime: The start of the extraction range, relative to the
+            recording's start.
+        :param endTime: The end of the extraction range, relative to the
+            recording's end.
+        :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.
+        :param updater: A function (or function-like object) to notify as
+            work is done. It should take four keyword arguments: `count` (the
+            current line number), `total` (the total number of samples), `error`
+            (an unexpected exception, if raised during the import), and `done`
+            (will be `True` when the split is complete). If the updater object
+            has a `cancelled` attribute that is `True`, the import will be
+            aborted. The default callback is `None` (nothing will be notified).
+        :return: The total number of bytes written, and total number of
+            ChannelDataBlock elements copied.
+    """
+    if isinstance(doc, (str, Path)):
+        doc = openFile(doc)
+
+    if endTime is None:
+        endTime = float('infinity')
+
+    copiedBytes = 0
+
+    # Updater stuff
+    totalSize = 1
+    increment = 50
+    if updater:
+        totalSize = _getSize(doc.ebmldoc)
+        # FUTURE: Set `increment` based on `totalSize`?
+
+    if isinstance(out, (str, Path)):
+        fs = open(out, 'wb')
+    elif isinstance(out, IOBase) or hasattr(out, 'seek'):
+        fs = out
+    else:
+        raise TypeError("unsupported type for output; expected filename "
+                        "or stream, got {} ".format(type(out)))
+
+    try:
+        for n, el in enumerate(filterTime(doc, startTime, endTime, channels=channels), 1):
+            if updater:
+                if updater.cancelled:
+                    break
+
+                if n % increment == 0:
+                    updater(percent=el.offset/totalSize)
+
+            if el is not None:
+                data = el.getRaw()
+                fs.write(data)
+                copiedBytes += len(data)
+
+    except (StopIteration, KeyboardInterrupt):
+        pass
+
+    finally:
+        if isinstance(out, (str, Path)):
+            fs.close()
+
+    if updater:
+        updater(done=True)
+
+    return copiedBytes
+
+
+# ==============================================================================
+#
 # ==============================================================================
 
-def decode_attributes(data, withTypes=False):
-    """ Convert a set of Attributes (as a list of dictionaries containing an
-        `AttributeName` and one of the Attribute value elements (`IntAttribute`,
-        `FloatAttribute`, etc.) to a proper dictionary. Attributes are tagged
-        as 'multiple,' so they become lists when the EBML is parsed.
+CHUNK_SIZE = 512 * 1024  # Size of chunks when looking for last sync
+SYNC = b'\xfa\x84ZZZZ'  # The raw EBML of a Sync element
+
+
+def _getLastSync(stream, length=None):
+    """
+    Retrieve the offset of the last `Sync` element with data after it in
+    an IDE.
+
+    :param stream: A file stream or file-like object (must implement `tell()`
+        and `seek()`).
+    :param length: The length of the file, if already known. The length
+        will be calculated if `None`.
+    :return: The offset of the last EBML `Sync` element in the file
+    """
+
+    if not (hasattr(stream, 'seek') and hasattr(stream, 'tell')):
+        raise TypeError("IDE file had bad stream type ({})".format(type(stream)))
+
+    originalPos = stream.tell()
+    offset = length or _getSize(stream)
+
+    while True:
+        offset = max(0, offset - CHUNK_SIZE)
+        stream.seek(offset)
+        chunk = stream.read(CHUNK_SIZE + len(SYNC))
+        sync_idx = chunk.find(SYNC)
+        if sync_idx > -1:
+            # Make sure there's data after it by checking for ChannelDataBlock
+            # IDs. This is somewhat brittle, since the IDs are 1 byte, but it
+            # is not likely for anything but a ChannelDataBlock would have the
+            # IDs for ChannelDataBlock and ChannelIDRef in order. Even if
+            # they are in the payload, it still implies a ChannelDataBlock.
+            block_idx = chunk.find(b'\xa1', sync_idx)
+            if -1 < block_idx < chunk.rfind(b'\xb0', block_idx):
+                offset += sync_idx
+                break
+        if offset <= 0:
+            break
+
+    stream.seek(originalPos)
+    return max(offset, 0)
+
+
+def _getBlockTime(doc, el):
     """
-    result = OrderedDict()
-    for atts in data:
-        k = atts.pop('AttributeName', None)
-        if k is None:
+    Internal utility function to quickly get the ID and start and end times
+    of an IDE `ChannelDataBlock`. Works directly with the EBML.
+
+    :param doc: The opened IDE `idelib.dataset.Dataset`
+    :param el: The `ChannelDataBlock EBML element to process
+    :return: A tuple containing channel ID, start time, and end time.
+    """
+    start = end = None
+    chId = blockStart = blockEnd = None
+    for subEl in el:
+        if subEl.name == "ChannelIDRef":
+            chId = subEl.value
+        elif subEl.name == "StartTimeCodeAbs":
+            blockStart = subEl.value
+        elif subEl.name == "EndTimeCodeAbs":
+            blockEnd = subEl.value
+
+    # TODO: Modulus correction, if still needed (i.e., very old files)
+    blockEnd = blockEnd or blockStart
+    scalar = doc._parsers['ChannelDataBlock'].timeScalars.get(chId, 1)
+    if blockStart is not None:
+        # logger.warning("getLength: {} missing <EndTimeCodeAbs> subelement, skipping.".format(el))
+        start = blockStart // (1.0 / scalar)
+    if blockEnd is not None:
+        # logger.warning("getLength: {} missing <EndTimeCodeAbs> subelement, skipping.".format(el))
+        end = blockEnd // (1.0 / scalar)
+
+    return chId, start, end
+
+
+def _getLength(doc):
+    """
+    Retrieve the start and end times of an `idelib.dataset.Dataset` (e.g. an
+    open/imported IDE file). Used by `getLength()`.
+
+    :param doc: The opened IDE file
+    :return: The timestamps of the first and last samples in the file, in
+        microseconds, relative to the start of the recording.
+    """
+    start = float('infinity')
+    end = 0
+
+    # Get starting time, reading several blocks since different channels
+    # may not be in chronological order.
+    count = 0
+    for el in doc.ebmldoc:
+        if el.name != "ChannelDataBlock":
             continue
-        t, v = list(atts.items())[0]
-        if withTypes:
-            att = (v, t)
-        else:
-            att = v
-        result.setdefault(k, []).append(att)
-    return result
+        _chId, blockStart, _blockEnd = _getBlockTime(doc, el)
+        start = min(blockStart, start) if blockStart is not None else blockStart
+
+        count += 1
+        if count > 10:  # Note: number is somewhat arbitrary
+            break
+
+    # Get ending time by jumping to a Sync near the end and reading blocks
+    # from there to the end of file.
+    stream = doc.ebmldoc.stream
+
+    # Use the cached EBML document size if the IDE was already fully imported,
+    # otherwise use `None` so `_getLastSync()` will calculate it (faster than
+    # getting uncached `doc.ebmldoc.size`)
+    streamLength = None if doc.loading else doc.ebmldoc.size
+    stream.seek(_getLastSync(stream, streamLength))
+    temp_ebml = doc.ebmldoc.schema.load(stream)
+
+    for el in temp_ebml:
+        if el.name != "ChannelDataBlock":
+            continue
+
+        _chId, _blockStart, blockEnd = _getBlockTime(doc, el)
+        if blockEnd:
+            end = max(blockEnd, end)
+
+    return start, end
+
 
-    
-def encode_attributes(data):
-    """ Construct a set of `Attribute` dicts from a dictionary or list of
-        tuples/lists. Each value should be either a simple data type or a
-        tuple containing the value and the specific value element name
-        (IntAttribute, FloatAttribute, etc.). The value element type will 
-        otherwise be inferred.
+def getLength(doc):
     """
-    datetimeTypes = (datetime.datetime, datetime.date)
-    attTypes = ((str, 'UnicodeAttribute'),
-                (bytes, 'StringAttribute'),
-                (float, 'FloatAttribute'),
-                (int, 'IntAttribute'),
-                (time.struct_time, 'DateAttribute'),
-                (datetimeTypes, 'DateAttribute'))
-    
-    if isinstance(data, dict):
-        data = list(data.items())
-    
-    result = []
-    elementType = None
-
-    for d in data:
-        if isinstance(d[1], (tuple, list)) and not isinstance(d[1], time.struct_time):
-            k = d[0]
-            v, elementType = d[1]
-        else:
-            k, v = d
-            for t, elementType in attTypes:
-                if isinstance(v, t):
-                    break
-                elementType = 'BinaryAttribute'
-        
-        if elementType == 'DateAttribute':
-            if isinstance(v, (int, float)):
-                v = int(v)
-            elif isinstance(data, datetimeTypes):
-                v = int(time.mktime(data.timetuple()))
-            elif isinstance(data, time.struct_time):
-                v = int(time.mktime(v))
-                
-        result.append({'AttributeName': k, elementType: v})
+    Efficiently retrieve the start and end times of an IDE file, without it
+    having to be fully imported.
 
-    return result
-    
-
-def build_attributes(data):
-    """ Construct `Attribute` EBML from dictionary or list of key/value pairs. 
-        Each value should be either a simple data type or a tuple containing 
-        the value and the specific value element name (`IntAttribute`, 
-        `FloatAttribute`, etc.). The value element type will otherwise be 
-        inferred.
+    :param doc: The IDE filename, an `idelib.dataset.Dataset`, or stream
+        containing IDE data.
+    :return: The timestamps of the first and last samples in the file, in
+        microseconds, relative to the start of the recording.
     """
-    mideSchema = loadSchema('mide_ide.xml')
-    return mideSchema['Attribute'].encode(encode_attributes(data))
+    # This really just wraps `_getLength()` in order to handle different
+    # source `doc` types
+    if isinstance(doc, str):
+        with open(doc, 'rb') as f:
+            return _getLength(openFile(f))
+    elif isinstance(doc, Dataset):
+        return _getLength(doc)
+    elif isinstance(doc, IOBase):
+        return _getLength(openFile(doc))
+
+    raise TypeError("getLength() needs a filename, stream, or Dataset; got {}".format(type(doc)))
+
+
+# ==============================================================================
+#
+# ==============================================================================
+
+
+def getExitCondition(recording):
+    """ Get the ``ExitCond`` Attribute from the end of a recording, if present.
+        The result will be an integer:
+
+        * 1: Button press
+        * 2: USB connection
+        * 3: Recording time limit reached
+        * 4: Low battery
+        * 5: File size limit reached
+        * 128: I/O error (can occur if disk is full or 4GB FAT32 size limit
+          reached.
+
+    :param recording: The IDE filename, an `idelib.dataset.Dataset`, an
+        `ebmlite.core.Dataset`, or stream containing IDE data.
+    """
+    result = None
+
+    if hasattr(recording, 'filename'):
+        # A `Dataset` or `ebmlite.Document`
+        recording = recording.filename
+
+    if isinstance(recording, str):
+        with open(recording, "rb") as fs:
+            return getExitCondition(fs)
+
+    if not (hasattr(recording, 'seek') and hasattr(recording, 'tell')):
+        raise TypeError("IDE file had bad stream type ({})".format(type(recording)))
+
+    offset = recording.tell()
+
+    recording.seek(_getSize(recording) - CHUNK_SIZE)
+    data = recording.read()
+    try:
+        # Seek out the exit condition Attribute by the
+        # string of its name, then offset to where the
+        # data is expected to be.
+        idx = data.index(b"ExitCond") + 11
+        if idx <= len(data):
+            result = data[idx]
+    except (IOError, IndexError, ValueError) as e:
+        logger.warning(e)
+
+    recording.seek(offset)
+    return result
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..4082d940
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,60 @@
+[tool]
+  [tool.pylint]
+    [tool.pylint.master]
+      fail-under=10
+    [tool.pylint.messages_control]
+      disable="all"
+      enable=[
+        "async",
+        "format",
+        "invalid-name",  # basic
+        "imports",
+        "miscellaneous",
+        "refactoring",
+        "similarities",
+        "stdlib",
+        "string",
+        ]
+    [tool.pylint.reports]
+    [tool.pylint.format]
+      indent-string="    "
+      indent-after-paren=8
+    [tool.pylint.logging]
+    [tool.pylint.miscelaneous]
+      notes=[
+        "TODO",
+        "HACK",
+        "XXX",
+      ]
+    [tool.pylint.spelling]
+    [tool.pylint.string]
+    [tool.pylint.typecheck]
+    [tool.pylint.basic]
+      good-names=[
+        "i",
+        "j",
+        "k",
+        "t",
+        "x",
+        "y",
+        "z",
+        "ex",
+        "Run",
+        "_",
+        "__"
+      ]
+      # Naming Conventions
+      argument_naming-style="camelCase"
+      attr-naming-style="camelCase"
+      class-naming-style="PascalCase"
+      class-attribute-naming-style="UPPER_CASE"
+      const-naming-style="UPPER_CASE"
+      function-naming-style="camelCase"
+      inlinevar-naming-style="camelCase"
+      method-naming-style="camelCase"
+      module-naming-style="snake_case"
+      variable-naming-style="camelCase"
+  [tool.pytest]
+    [tool.pytest.ini_options]
+      required_plugins=["pytest-cov"]
+      testpaths=[""]
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 3b219b9e..fa5a4a4a 100644
--- a/setup.py
+++ b/setup.py
@@ -12,6 +12,7 @@
     'pytest>=4.6',
     'mock',
     'pytest-cov',
+    'sphinx',
     ]
 
 EXAMPLE_REQUIRES = [
@@ -20,7 +21,7 @@
 
 setuptools.setup(
         name='idelib',
-        version='3.1.0',
+        version='3.2.0',
         author='Mide Technology',
         author_email='help@mide.com',
         description='Python API for accessing IDE data recordings',


Test patch:
diff --git a/testing/file_streams.py b/testing/file_streams.py
index 86a246f2..6741eff3 100644
--- a/testing/file_streams.py
+++ b/testing/file_streams.py
@@ -1,6 +1,7 @@
 from io import BytesIO, StringIO
 
 FILES = [('./testing/SSX70065.IDE', 'rb'),
+         ('./testing/SSX66115.IDE', 'rb'),
          ('./test.ide', 'rb')]
 FILE_DICT = {}
 
@@ -8,6 +9,7 @@
     with open(fName, mode) as f:
         FILE_DICT[fName] = (f.read(), mode)
 
+
 def makeStreamLike(fName):
     dat, mode = FILE_DICT[fName]
     if 'b' in mode:
diff --git a/testing/test_attributes.py b/testing/test_attributes.py
new file mode 100644
index 00000000..a928dd8f
--- /dev/null
+++ b/testing/test_attributes.py
@@ -0,0 +1,47 @@
+import datetime
+import time
+
+import pytest
+
+import idelib.attributes
+
+NOW_DATETIME = datetime.datetime.now()
+NOW_STRUCT_TIME = time.localtime()
+NOW_UTC = time.time()
+
+ATTRIBUTES = [{},
+              {'string': 'this is a unicode string'},
+              {'bytes': b'this is a bytes string'},
+              {'float': 0.1},
+              {'int': 10},
+              {'datetimeTypes': NOW_DATETIME},
+              {'time.struct_time': NOW_STRUCT_TIME},
+              [['int', (2, 'IntAttribute')]],
+              [['int', ([2, 3, 4, 5], 'IntAttribute')]],
+              [('datetime datetime', NOW_DATETIME)],
+              [('datetime struct', NOW_STRUCT_TIME)],
+              [('datetime int', (NOW_UTC, 'DateAttribute'))],
+             ]
+
+ENCODED_ATTRIBUTES = [[],
+                      [{'AttributeName': 'string', 'UnicodeAttribute': 'this is a unicode string'}],
+                      [{'AttributeName': 'bytes', 'StringAttribute': b'this is a bytes string'}],
+                      [{'AttributeName': 'float', 'FloatAttribute': 0.1}],
+                      [{'AttributeName': 'int', 'IntAttribute': 10}],
+                      [{'AttributeName': 'datetimeTypes', 'DateAttribute': NOW_DATETIME}],
+                      [{'AttributeName': 'time.struct_time', 'DateAttribute': NOW_STRUCT_TIME}],
+                      [{'AttributeName': 'int', 'IntAttribute': 2}],
+                      [{'AttributeName': 'int', 'IntAttribute': [2, 3, 4, 5]}],
+                      [{'AttributeName': 'datetime datetime', 'DateAttribute': NOW_DATETIME}],
+                      [{'AttributeName': 'datetime struct', 'DateAttribute': NOW_STRUCT_TIME}],
+                      [{'AttributeName': 'datetime int', 'DateAttribute': int(NOW_UTC)}],
+                    ]
+
+
+@pytest.mark.parametrize(
+        "test_input, expected",
+        zip(ATTRIBUTES, ENCODED_ATTRIBUTES)
+        )
+def test_encode_attributes(test_input, expected):
+
+    assert idelib.attributes.encode_attributes(test_input) == expected
diff --git a/testing/test_dataset.py b/testing/test_dataset.py
index 6639e408..c66b7f7f 100644
--- a/testing/test_dataset.py
+++ b/testing/test_dataset.py
@@ -8,17 +8,20 @@
     `calibration.AccelTransform`. These classes may be refactored out in the
     future.
 """
-
+import struct
 from io import StringIO, BytesIO
 import sys
 import unittest
 import mock
 
+import pytest
+
+import numpy as np  # type: ignore
+
 from idelib.dataset import (Cascading,
                             Channel,
                             Dataset,
                             EventArray,
-                            EventList,
                             Plot,
                             Sensor,
                             Session,
@@ -26,16 +29,53 @@
                             Transformable,
                             WarningRange,
                             )
-
 from idelib.transforms import Transform, CombinedPoly, PolyPoly
-from idelib.transforms import AccelTransform
+from idelib.transforms import AccelTransform, Univariate
 from idelib import importer
 from idelib import parsers
 
-import numpy as np  # type: ignore
+from testing.utils import nullcontext
 
 from .file_streams import makeStreamLike
 
+
+# ==============================================================================
+# Fixtures
+# ==============================================================================
+
+_fileStrings = {}
+
+
+def _load_file(filePath):
+    if filePath not in _fileStrings:
+        with open(filePath, 'rb') as f:
+            _fileStrings[filePath] = f.read()
+    out = BytesIO(_fileStrings[filePath])
+    out.name = filePath
+    return out
+
+
+@pytest.fixture
+def testIDE():
+    doc = importer.openFile(_load_file('./test.ide'))
+    importer.readData(doc)
+    return doc
+
+
+@pytest.fixture
+def SSX70065IDE():
+    doc = importer.openFile(_load_file('./testing/SSX70065.IDE'))
+    importer.readData(doc)
+    return doc
+
+
+@pytest.fixture
+def SSX_DataIDE():
+    doc = importer.openFile(_load_file('./testing/SSX_Data.IDE'))
+    importer.readData(doc)
+    return doc
+
+
 #===============================================================================
 # 
 #===============================================================================
@@ -54,20 +94,20 @@ def __init__(self):
         self.startTime = 0
         self.sampleTime = 0
         self.numSamples = 1
-        
-        
+
+
     def __getitem__(self, index):
         return self.data[index]
-    
-    
+
+
     def __len__(self):
         return len(self.data)
-        
-        
+
+
     def updateTransforms(self):
         self.isUpdated = True
-        
-        
+
+
     def parseWith(self, x, start, end, step, subchannel):
         return (x, start, end, step, subchannel)
 
@@ -80,95 +120,95 @@ def parseByIndexWith(self, parser, indices, subchannel):
 # 
 #===============================================================================
 
-class CascadingTestCase(unittest.TestCase):
+class TestCascading(unittest.TestCase):
     """ Test case for methods in the Cascading class. """
-    
+
     def setUp(self):
         self.casc1 = Cascading()
         self.casc1.name = 'parent'
         self.casc2 = Cascading()
         self.casc2.name = 'child'
         self.casc2.parent = self.casc1
-        
-        
+
+
     def tearDown(self):
         self.casc1 = None
         self.casc2 = None
-        
-        
+
+
     def testHierarchy(self):
         """ Test for hierarchy method. """
         self.assertEqual(self.casc2.hierarchy(), [self.casc1, self.casc2])
-        
-        
+
+
     def testPath(self):
         """ Test for path method. """
         self.assertEqual(self.casc1.path(), 'parent')
         self.assertEqual(self.casc2.path(), 'parent:child')
         self.casc1.path = lambda : None
         self.assertEqual(self.casc2.path(), 'child')
-     
-     
+
+
     def testRepr(self):
         """ Test that casting to a string creates the correct string. """
         self.assertIn("<Cascading %r at" % 'parent', repr(self.casc1))
-        
+
 
 #===============================================================================
 # 
 #===============================================================================
 
-class TransformableTestCase(unittest.TestCase):
+class TestTransformable(unittest.TestCase):
     """ Test case for methods in the Transformable class. """
     def setUp(self):
-        
+
         # create objects to be used during testing
         self.xform1 = Transformable()
         self.genericObject = GenericObject()
-        
+
         # configure above objects
         fileStream = makeStreamLike('./testing/SSX70065.IDE')
         self.xform1.dataset = Dataset(fileStream)
         self.xform1.dataset.transforms = {1: "123", 2: "456"}
         self.xform1.children = [self.genericObject]
-        
-        
+
+
     def tearDown(self):
         self.xform1.dataset.close()
         self.xform1 = None
-        
-        
+
+
     def testSetTransform(self):
         """ Test the setTransform method without updating. """
         self.xform1.setTransform(1, False)
         self.assertEqual(self.xform1.transformId, 1)
         self.assertEqual(self.xform1._transform, 1)
-        
+
         self.genericObject.id = "12345"
         self.xform1.setTransform(self.genericObject, False)
         self.assertEqual(self.xform1.transformId, "12345")
         self.assertEqual(self.xform1._transform, self.genericObject)
-        
+
         self.xform1.setTransform(None, False)
         self.assertEqual(self.xform1._transform, Transform.null)
-        
-        
+
+
     def testUpdateTransforms(self):
         """ Test the updateTransforms and _updateXformIds methods by calling
             setTransform with updating.
-        """        
+        """
         self.xform1.setTransform(1)
         self.assertEqual(self.xform1.transform, "123")
         self.assertTrue(self.genericObject.isUpdated)
-        
+
         self.genericObject.isUpdated = False
         aPlaceholderTransform = Transform()
         aPlaceholderTransform.id = 2
         self.xform1.setTransform(aPlaceholderTransform)
         self.assertEqual(self.xform1.transform, "456")
         self.assertTrue(self.genericObject.isUpdated)
-        
-        
+
+
     def testGetTransforms(self):
         """ Test that the list of tansforms is being returned properly """
         # TODO: check that this is getting returned properly,
@@ -176,14 +216,14 @@ def testGetTransforms(self):
         self.xform1.setTransform(1, False)
         self.assertEqual(self.xform1.getTransforms(), [1])
         self.assertEqual(self.xform1.getTransforms(_tlist=[0]), [1, 0])
-        
+
         parentXform = Transformable()
         parentXform.id = 5
         parentXform.children = [self.xform1]
         parentXform.setTransform(self.xform1, False)
         self.xform1.id = 3
         self.xform1.parent = parentXform
-        
+
         self.assertEqual(parentXform.getTransforms(), [self.xform1])
 
 
@@ -191,16 +231,16 @@ def testGetTransforms(self):
 # 
 #===============================================================================
 
-class DatasetTestCase(unittest.TestCase):
+class TestDataset(unittest.TestCase):
     """ Test case for methods in the Dataset class. """
-    
+
     def setUp(self):
         """ Open a file for testing in a new dataset. """
         self.fileStream = makeStreamLike('./testing/SSX70065.IDE')
         self.dataset = Dataset(self.fileStream)
 
         self.channelCheck = {}
-        
+
         # Copied from importer.py:181
         self.channels = DEFAULTS['channels'].copy()
         for chId, chInfo in self.channels.items():
@@ -214,24 +254,24 @@ def setUp(self):
             for subChId, subChInfo in subchannels.items():
                 channel.addSubChannel(subChId, **subChInfo)
                 self.channelCheck[chId].addSubChannel(subChId, **subChInfo)
-    
-    
+
+
     def tearDown(self):
         """ Close and dispose of the file. """
         self.dataset.close()
         self.dataset = None
-    
-    
+
+
     def testConstructor(self):
         """ Exhaustively check that all the members that get initialized in the
             constructor are being initialized to the correct value.
         """
         self.assertEqual(self.dataset._channels, self.channelCheck)
         self.assertEqual(self.dataset._parsers, None)
-        
+
         self.assertEqual(self.dataset.currentSession, None)
 #         self.assertEqual(
-#             self.dataset.ebmldoc, 
+#             self.dataset.ebmldoc,
 #             loadSchema(SCHEMA_FILE).load(self.fileStream, 'MideDocument'))
         self.assertEqual(self.dataset.fileDamaged, False)
         self.assertEqual(
@@ -250,27 +290,27 @@ def testConstructor(self):
         self.assertEqual(self.dataset.subsets, [])
         self.assertEqual(self.dataset.transforms, {})
         self.assertEqual(self.dataset.warningRanges, {})
-        
-        
+
+
     def testChannels(self):
         """ Test the channels property. """
         self.assertEqual(self.dataset.channels[0], self.channelCheck[0])
-        
-        
+
+
     def testClose(self):
         """ Test the close method. """
         self.assertFalse(self.fileStream.closed)
         self.dataset.close()
         self.assertTrue(self.fileStream.closed)
-        
-        
+
+
     def testClosed(self):
         """ Test the closed property. """
         self.assertFalse(self.dataset.closed)
         self.dataset.close()
         self.assertTrue(self.dataset.closed)
-    
-    
+
+
     def testAddSession(self):
         """ Test that adding sessions properly appends a new session and
             replaces the old currentSession with the new session and that
@@ -280,61 +320,61 @@ def testAddSession(self):
                            utcStartTime=0)
         session2 = Session(self.dataset, sessionId=1, startTime=3, endTime=4,
                            utcStartTime=0)
-        
+
         # Add a new session, assert that it's the current session
         self.dataset.addSession(1, 2)
         self.assertEqual(self.dataset.sessions[0], session1)
         self.assertEqual(self.dataset.currentSession, session1)
-        
+
         # Add a new session, assert that it's replaced the previous session
         self.dataset.addSession(3, 4)
         self.assertEqual(self.dataset.sessions[1], session2)
         self.assertEqual(self.dataset.currentSession, session2)
         self.assertEqual(self.dataset.sessions[1], self.dataset.lastSession)
-        
-        
+
+
     def testEndSession(self):
         """ Test that ending the current session ends the current session. """
         self.dataset.addSession(1, 2)
         self.dataset.endSession()
         self.assertFalse(self.dataset.currentSession)
-        
-        
+
+
     def testAddSensor(self):
         """ Test that the sensors are being added correctly. """
         sensor1 = Sensor(self.dataset, 0)
         sensor2 = Sensor(self.dataset, 'q')
-        
+
         # test that numeric ids work
         self.dataset.addSensor(0)
         self.assertEqual(sensor1, self.dataset.sensors[0])
-    
+
         # test that string ids work
         self.dataset.addSensor('q')
         self.assertEqual(sensor2, self.dataset.sensors['q'])
-        
-        
+
+
     def testAddChannel(self):
         """ Test that each channel is being added to the dataset correctly, and
             that when refering to channel, a dict is returned containing each 
             channel.
         """
         parser = self.channels[0]['parser']
-        
+
         # assert errors
         self.assertRaises(TypeError, self.dataset.addChannel)
         self.assertRaises(TypeError, self.dataset.addChannel, 1)
-        
+
         # assert that the correct channel is returned if it already exists
         self.assertEqual(
-            self.dataset._channels[0], 
+            self.dataset._channels[0],
             self.dataset.addChannel(0, parser=parser))
-        
+
         # assert that a new channel is made when it does not already exist
         self.assertEqual(
             self.dataset.addChannel(2, parser), Channel(self.dataset, 2, parser))
-    
-    
+
+
     def testAddTransform(self):
         """ Test that transforms are being added correctly.
             Using Transformables to test this because they're a simple object
@@ -347,42 +387,42 @@ def testAddTransform(self):
         xform2.id = 'q'
         xform3 = Transformable()
         xform3.id = None
-        
+
         # assert that transforms are being added correctly
         self.dataset.addTransform(xform1)
         self.dataset.addTransform(xform2)
         self.assertEqual(self.dataset.transforms[1], xform1)
         self.assertEqual(self.dataset.transforms['q'], xform2)
-        
+
         # assert that transforms without an id will raise errors
         self.assertRaises(ValueError, self.dataset.addTransform, xform3)
-        
-        
+
+
     def testAddWarning(self):
         """ Test that adding warnings is successfully adding warnings. """
         warning1 = WarningRange(self.dataset, warningId=1, channelId=0,
-                                subchannelId=0, high=10)        
+                                subchannelId=0, high=10)
         self.dataset.addWarning(1, 0, 0, None, 10)
 
         self.assertEqual(self.dataset.warningRanges[1], warning1)
-        
-        
+
+
     def testPath(self):
         """ Test that the path is being assembled correctly. """
         self.assertEqual(self.dataset.name, self.dataset.path())
-        
-        
+
+
     def testLastSession(self):
         """ Test the lastSession property. """
         self.dataset.addSession(0, 1, 2)
         self.assertEqual(
-            self.dataset.lastSession, 
-            Session(self.dataset, 
-                    sessionId=0, 
-                    startTime=0, 
-                    endTime=1, 
+            self.dataset.lastSession,
+            Session(self.dataset,
+                    sessionId=0,
+                    startTime=0,
+                    endTime=1,
                     utcStartTime=2))
-        
+
         self.dataset.addSession(4, 5, 6)
         self.assertEqual(
             self.dataset.lastSession,
@@ -391,8 +431,8 @@ def testLastSession(self):
                     startTime=4,
                     endTime=5,
                     utcStartTime=6))
-        
-        
+
+
     def testHasSession(self):
         """ Test the hasSession method. """
         self.dataset.addSession(0, 1, 2)
@@ -406,20 +446,20 @@ def testGetPlots(self):
         subs = self.dataset._channels[0].subchannels
         subs = subs + self.dataset._channels[1].subchannels
         self.assertEqual(subs, self.dataset.getPlots(sort=False))
-        
+
         subs.sort(key=lambda x: x.displayName)
         self.assertEqual(subs, self.dataset.getPlots())
-        
-        
+
+
     def testUpdateTransforms(self):
         """ Test updateTransforms method. """
-        
+
         # mock the updateTransforms method for the channels in the dataset
         # In this case, just count the number of times it was called
         self.transformsUpdated = 0
         def mockUpdateTransforms():
             self.transformsUpdated += 1
-        
+
         for x in self.dataset.channels.values():
             x.updateTransforms = mockUpdateTransforms
         self.dataset.updateTransforms()
@@ -430,26 +470,26 @@ def mockUpdateTransforms():
 # 
 #===============================================================================
 
-class SessionTestCase(unittest.TestCase):
+class TestSession(unittest.TestCase):
     """ Test case for methods in the Session class. """
-    
+
     def testInitAndEQ(self):
         self.dataset = importer.importFile('./testing/SSX70065.IDE')
         session1 = Session(
             self.dataset, sessionId=1, startTime=2, endTime=3, utcStartTime=4)
         session2 = Session(
             self.dataset, sessionId=1, startTime=2, endTime=3, utcStartTime=4)
-        
+
         self.assertEqual(session1, session2)
         self.assertNotEqual(session1, GenericObject())
-        
+
         self.assertEqual(session1.dataset, self.dataset)
         self.assertEqual(session1.endTime, 3)
         self.assertEqual(session1.sessionId, 1)
         self.assertEqual(session1.startTime, 2)
         self.assertEqual(session1.utcStartTime, 4)
-        
-        
+
+
     def testRepr(self):
         """ Test that __repr__ is creating the correct string. """
         fileStream = makeStreamLike('./testing/SSX70065.IDE')
@@ -457,70 +497,70 @@ def testRepr(self):
         session1 = Session(
             dataset, sessionId=1, startTime=2, endTime=3, utcStartTime=4)
         self.assertIn("<Session (id=1) at", repr(session1))
-    
+
 
 #===============================================================================
 # 
 #===============================================================================
 
-class SensorTestCase(unittest.TestCase):
+class TestSensor(unittest.TestCase):
     """ Test case for methods in the Sensor class. """
-    
+
     def setUp(self):
         """ Open a file for testing in a new dataset. """
         self.dataset = importer.importFile('./testing/SSX70065.IDE')
-        
+
         self.sensor1 = Sensor(self.dataset, 1)
         self.sensor2 = Sensor(self.dataset, 2, "3", 4, 5, 6, 7)
-    
-    
+
+
     def tearDown(self):
         """ Close and dispose of the file. """
         self.dataset.close()
         self.dataset = None
         self.sensor1 = None
         self.sensor2 = None
-    
-    
+
+
     def testInitAndEQ(self):
         """ Test __init__ and __eq__ in Sensor. """
-        self.assertNotEqual(self.sensor1, self.sensor2) 
+        self.assertNotEqual(self.sensor1, self.sensor2)
         self.assertEqual(self.sensor1, Sensor(self.dataset, 1))
-        
+
         sensor3 = Sensor(self.dataset, 3, name=None)
         self.assertEqual(sensor3.name, "Sensor%02d")
-        
-        
+
+
     def testGetItem(self):
         """ Test for the __getitem__ method. """
         self.sensor1.channels = {'a': 2, 'b': 3, 'e': 4, 'test': 5}
         for x in self.sensor1.channels:
             self.assertEqual(self.sensor1[x], self.sensor1.channels[x])
-        
-        
+
+
     def testChildren(self):
         """ Test the children property. """
         self.sensor1.channels = {1: "1"}
         self.assertEqual(self.sensor1.children, ["1"])
         self.assertEqual(self.sensor2.children, [])
-    
-    
+
+
     def testBandwidthCutoff(self):
         """ Test the bandwidthCutoff property. """
         self.sensor1._bandwidthCutoff = 5
         self.assertEqual(self.sensor1.bandwidthCutoff, 5)
-        
+
         self.sensor2.dataset.bandwidthLimits = [0,1,2,3,4,5,6,
                                                 {"LowerCutoff":1,
                                                  "UpperCutoff":2}]
         self.assertEqual(self.sensor2.bandwidthCutoff, (1, 2))
-    
-    
+
+
     def testBandwidthRolloff(self):
         """ Test the bandwidthRolloff property. """
         self.sensor1._bandwidthRolloff = 5
         self.assertEqual(self.sensor1.bandwidthRolloff, 5)
-        
+
         self.sensor2.dataset.bandwidthLimits = [0,1,2,3,4,5,6,
                                                 {"LowerRolloff":1,
                                                  "UpperRolloff":2}]
@@ -532,27 +572,27 @@ def testBandwidthRolloff(self):
 # 
 #===============================================================================
 
-class ChannelTestCase(unittest.TestCase):
+class TestChannel(unittest.TestCase):
     """ Test case for methods in the Channel class. """
-    
+
     def setUp(self):
         """ Open a file for testing in a new dataset. """
         self.dataset = importer.importFile('./testing/SSX70065.IDE')
         self.dataset.addSensor(0)
-        
+
         self.fakeParser = GenericObject()
         self.fakeParser.types = [0]
         self.fakeParser.format = []
-        
+
         self.channel1 = Channel(
             self.dataset, channelId=0, name="channel1", parser=self.fakeParser,
             displayRange=[0])
         self.channel2 = Channel(
             self.dataset, channelId=2, parser=self.fakeParser, sensor=0, name=5,
-            units=6, transform=7, displayRange=[8], sampleRate=9, cache=10, 
+            units=6, transform=7, displayRange=[8], sampleRate=9, cache=10,
             singleSample=11, attributes=12)
-    
-    
+
+
     def tearDown(self):
         """ Close and dispose of the file. """
         self.dataset.close()
@@ -560,12 +600,12 @@ def tearDown(self):
         self.channel1 = None
         self.channel2 = None
         self.fakeParser = None
-        
-        
+
+
     def testInit(self):
         """ Exhaustively test parameters for __init__ """
         self.assertNotEqual(self.channel1, self.channel2)
-        
+
         self.assertEqual(self.channel1.id, 0)
         self.assertEqual(self.channel1.sensor, None)
         self.assertEqual(self.channel1.parser, self.fakeParser)
@@ -586,7 +626,7 @@ def testInit(self):
         self.assertEqual(self.channel1.subsampleCount, [0,sys.maxsize])
         self.assertEqual(self.channel1._lastParsed, (None, None))
         self.assertTrue(self.channel1.allowMeanRemoval)
-        
+
         self.assertEqual(self.channel2.id, 2)
         self.assertEqual(self.channel2.sensor, 0)
         self.assertEqual(self.channel2.parser, self.fakeParser)
@@ -607,67 +647,67 @@ def testInit(self):
         self.assertEqual(self.channel2.subsampleCount, [0,sys.maxsize])
         self.assertEqual(self.channel2._lastParsed, (None, None))
         self.assertFalse(self.channel2.allowMeanRemoval)
-        
-        
+
+
     def testChildren(self):
         """ Test the children property. """
         self.assertEqual(self.channel1.children, list(iter(self.channel1)))
-        
-        
+
+
     def testRepr(self):
         """ Test the repr special method. """
-        self.assertIn("<Channel 0: %r at" % 'channel1', repr(self.channel1))
-        
-        
+        self.assertIn("<Channel 0 %r" % 'channel1', repr(self.channel1))
+
+
     def testGetitem(self):
         """ Test the getitem special method. """
         self.assertEqual(self.channel1[0], SubChannel(self.channel1, 0))
         # self.assertEqual(self.channel1[1], SubChannel(self.channel1, 1))
         # TODO: should the above work or not?
-        
-        
+
+
     def testLen(self):
         """ Test the len override. """
         self.assertEqual(len(self.channel1),len(self.channel1.subchannels))
-        
-        
+
+
     def testIter(self):
         """ Test the iter special method. """
         self.channel1.subchannels = [None]*5
         self.channel1.types = self.channel1.displayRange = [1, 2, 3, 4, 5]
-        
+
         idx = 0
         for x in self.channel1:
             self.assertEqual(x, SubChannel(self.channel1, idx))
             idx += 1
-        
-        
+
+
     def testAddSubChannel(self):
         """ Test the addSubChannel method. """
         self.assertRaises(TypeError, self.channel1.addSubChannel, None)
         self.assertRaises(IndexError, self.channel1.addSubChannel, 5)
         self.assertEqual(self.channel1.addSubChannel(subchannelId=0),
                          SubChannel(self.channel1, 0))
-        
-        
+
+
     def testGetSubChannel(self):
         """ Test the getSubChannel method. """
-        self.assertEqual(self.channel1.getSubChannel(0), 
+        self.assertEqual(self.channel1.getSubChannel(0),
                          SubChannel(self.channel1, 0))
-        self.assertEqual(self.channel1.getSubChannel(0).singleSample, 
+        self.assertEqual(self.channel1.getSubChannel(0).singleSample,
                          self.channel1.singleSample)
-        
-        
+
+
     def testGetSession(self):
         """ Test the getSession method. """
-        self.dataset.addSession(0, 1, 2) 
+        self.dataset.addSession(0, 1, 2)
         eventArray = EventArray(self.channel1, self.dataset.lastSession)
         self.assertEqual(self.channel1.getSession(), eventArray)
         self.assertEqual(self.channel1.getSession(),
                          self.channel1.getSession(1))
         self.assertRaises(KeyError, self.channel1.getSession, 5)
-        
-        
+
+
     def testParseBlock(self):
         """ Test the parseBlock method. """
         fakeBlock = GenericObject()
@@ -677,61 +717,114 @@ def testParseBlock(self):
                          (fakeBlock, None, None, 1, None))
         self.assertEqual(self.channel1._lastParsed[1],
                          [self.channel1.parser, None, None, 1, None])
-        
-        
+
+
     def testParseBlockByIndex(self):
         """ Test the parseBlockByIndex method. """
         fakeBlock = GenericObject()
-        self.assertEqual(self.channel1.parseBlockByIndex(fakeBlock, 1), 
+        self.assertEqual(self.channel1.parseBlockByIndex(fakeBlock, 1),
                          [self.channel1.parser, 1, None])
-        
-        
+
+
     def testUpdateTransforms(self):
         """ Test the updateTransforms method in this and the superclass. """
         # mock up a few things to isolate the channel
         genericObject = GenericObject()
         genericObject.isUpdated = False
         self.channel1.subchannels = [genericObject]
-        
+
         self.channel1.updateTransforms()
-        
+
         self.assertTrue(genericObject.isUpdated)
 
 
 #===============================================================================
 # 
 #===============================================================================
- 
-class SubChannelTestCase(unittest.TestCase):
+
+class TestSubChannel:
     """ Test case for methods in the SubChannel class. """
-    
+
+    @pytest.fixture
+    def dataset(self, SSX70065IDE):
+        SSX70065IDE.addSensor(0)
+        return SSX70065IDE
+
+    @pytest.fixture
+    def fakeParser(self):
+        return struct.Struct(b'<hh')
+
+    @pytest.fixture
+    def sensor1(self, dataset):
+        return Sensor(dataset, 2, '3', 4, 5, 6, 7)
+
+    @pytest.fixture
+    def channel1(self, dataset, fakeParser):
+        return Channel(
+                dataset,
+                channelId=0,
+                name="channel1",
+                parser=fakeParser,
+                displayRange=[0],
+                )
+
+    @pytest.fixture
+    def channel2(self, dataset, fakeParser, sensor1):
+        return Channel(
+                dataset,
+                channelId=2,
+                parser=fakeParser,
+                sensor=sensor1,
+                name="channel2",
+                units=6,
+                displayRange=[8],
+                sampleRate=9,
+                cache=10,
+                singleSample=11,
+                attributes=12,
+                )
+
+    @pytest.fixture
+    def subChannel1(self, channel2):
+        return SubChannel(
+                channel2,
+                0,
+                name=None,
+                units=('a', 'b'),
+                transform=3,
+                displayRange=[4],
+                sensorId=5,
+                warningId=6,
+                axisName=7,
+                attributes=8,
+                )
+
     def setUp(self):
         """ Open a file for testing in a new dataset. """
         self.dataset = importer.importFile('./testing/SSX70065.IDE')
         self.dataset.addSensor(0)
-        
+
         self.fakeParser = GenericObject()
         self.fakeParser.types = [0]
         self.fakeParser.format = []
-        
+
         self.sensor1 = Sensor(self.dataset, 2, "3", 4, 5, 6, 7)
-        
+
         self.channel1 = Channel(
             self.dataset, channelId=0, name="channel1", parser=self.fakeParser,
             displayRange=[0])
         self.channel2 = Channel(
-            self.dataset, channelId=2, parser=self.fakeParser, 
+            self.dataset, channelId=2, parser=self.fakeParser,
             sensor=self.sensor1, name="channel2", units=6,
             displayRange=[8], sampleRate=9, cache=10, singleSample=11,
             attributes=12)
-        
-        self.subChannel1 = SubChannel(self.channel2, 0, name=None, 
+
+        self.subChannel1 = SubChannel(self.channel2, 0, name=None,
                                       units=('a', 'b'), transform=3,
-                                      displayRange=[4], sensorId=5, warningId=6, 
+                                      displayRange=[4], sensorId=5, warningId=6,
                                       axisName=7, attributes=8)
-        parentList = self.dataset.channels[32].getSession()
-    
-    
+
+
     def tearDown(self):
         """ Close and dispose of the file. """
         self.dataset.close()
@@ -739,147 +832,147 @@ def tearDown(self):
         self.channel1 = None
         self.channel2 = None
         self.fakeParser = None
-    
-    
-    def testInit(self):
+
+    def testInit(self, dataset, channel1, channel2, subChannel1):
         """ Test the constructor for SubChannel. """
-        self.assertEqual(self.subChannel1.id, 0)
-        self.assertEqual(self.subChannel1.parent, self.channel2)
-        self.assertEqual(self.subChannel1.warningId, 6)
-        self.assertEqual(self.subChannel1.cache, self.channel2.cache)
-        self.assertEqual(self.subChannel1.dataset, self.dataset)
-        self.assertEqual(self.subChannel1.axisName, 7)
-        self.assertEqual(self.subChannel1.attributes, 8)
-        self.assertEqual(self.subChannel1.name, "channel2:00")
-        self.assertEqual(self.subChannel1.units, ('a', 'b'))
-        self.assertEqual(self.subChannel1.displayName, 'a')
-        self.assertEqual(self.subChannel1.sensor, self.channel1.sensor)
-        self.assertEqual(self.subChannel1.types, (self.channel1.types[0], ))
-        self.assertEqual(self.subChannel1.displayRange, [4])
-        self.assertTrue(self.subChannel1.hasDisplayRange)
-        self.assertEqual(self.subChannel1.allowMeanRemoval, 
-                         self.channel2.allowMeanRemoval)
-        self.assertFalse(self.subChannel1.removeMean)
-        self.assertEqual(self.subChannel1.singleSample, 
-                         self.channel2.singleSample)
-        
-        
-    def testChildren(self):
+        assert subChannel1.id == 0
+        assert subChannel1.parent == channel2
+        assert subChannel1.warningId == 6
+        assert subChannel1.cache == channel2.cache
+        assert subChannel1.dataset == dataset
+        assert subChannel1.axisName == 7
+        assert subChannel1.attributes == 8
+        assert subChannel1.name == "channel2:00"
+        assert subChannel1.units == ('a', 'b')
+        assert subChannel1.displayName == 'a'
+        assert subChannel1.sensor == channel1.sensor
+        assert subChannel1.types == (channel1.types[0], )
+        assert subChannel1.displayRange == [4]
+        assert subChannel1.hasDisplayRange is True
+        assert subChannel1.allowMeanRemoval == channel2.allowMeanRemoval
+        assert subChannel1.removeMean is False
+        assert subChannel1.singleSample == channel2.singleSample
+
+    def testChildren(self, subChannel1):
         """ Test the children property. """
-        self.assertEqual(self.subChannel1.children, [])
-        
-        
-    def testSampleRate(self):
+        assert subChannel1.children == []
+
+    def testSampleRate(self, subChannel1, channel2):
         """ Test the sampleRate property. """
-        self.assertEqual(self.subChannel1.sampleRate, self.channel2.sampleRate)
-        
-        
-    def testRepr(self):
+        assert subChannel1.sampleRate == channel2.sampleRate
+
+    def testRepr(self, subChannel1):
         """ Test the repr special method. """
-        self.assertIn(
-            "<SubChannel 2.0: %r at" % 'SSX70065:3:channel2:channel2:00',
-            repr(self.subChannel1))
-        
-        
-    def testLen(self):
+        assert "<SubChannel 2.0 'SSX70065:3:channel2:channel2:00'" in repr(subChannel1)
+
+    def testLen(self, subChannel1):
         """ Test the len special method. """
-        self.assertRaises(AttributeError, self.subChannel1.__len__)
-        
-        
-    def testParser(self):
+        with pytest.raises(AttributeError):
+            len(subChannel1)
+
+    def testParser(self, subChannel1, channel2):
         """ Test the parser property. """
-        self.assertEqual(self.subChannel1.parser, self.channel2.parser)
-        
-        
-    def testSessions(self):
+        assert subChannel1.parser == channel2.parser
+
+    def testSessions(self, subChannel1):
         """ Test the sessions property. """
-        self.assertEqual(self.subChannel1.sessions,{})
-        
-        self.subChannel1._sessions = [1, 2]
-        
-        self.assertEqual(self.subChannel1.sessions, [1, 2])
-        
-        
-    def testParseBlock(self):
+
+        assert subChannel1.sessions == {}
+
+        subChannel1._sessions = [1, 2]
+
+        assert subChannel1.sessions == [1, 2]
+
+    def testParseBlock(self, channel2, subChannel1):
         """ Test the parseBlock method.
             Run the same test as for Channel.
         """
         fakeBlock = GenericObject()
-        self.assertEqual(self.channel2.parseBlock(fakeBlock),
-                         self.subChannel1.parseBlock(fakeBlock))
-        
-        
-    def testParseBlockByIndex(self):
+
+        assert channel2.parseBlock(fakeBlock) == subChannel1.parseBlock(fakeBlock)
+
+    def testParseBlockByIndex(self, channel2, subChannel1):
         """ Test the parseBlockByIndex method.
             Run the same test as for Channel.
         """
         fakeBlock = GenericObject()
-        self.assertEqual(self.channel2.parseBlockByIndex(fakeBlock, 1),
-                         self.subChannel1.parseBlockByIndex(fakeBlock, 1))
-        
-        
-    def testGetSession(self):
+
+        assert channel2.parseBlockByIndex(fakeBlock, 1) == \
+               subChannel1.parseBlockByIndex(fakeBlock, 1)
+
+    def testGetSession(self, dataset, channel2, subChannel1):
         """ Test the getSession method. """
         # set up test
-        self.subChannel1.dataset.addSession(0, 1, 2)
-        self.channel2.subchannels = [GenericObject()]
-        parentList = self.dataset.channels[32].getSession()
+        subChannel1.dataset.addSession(0, 1, 2)
+        channel2.subchannels = [GenericObject()]
+        parentList = dataset.channels[32].getSession()
         parentList.dataset.addSession(0, 1, 2)
         eventArray = EventArray(
-            self.subChannel1,
-            session=self.dataset.lastSession,
-            parentList=self.subChannel1.parent.getSession())
-        
+            subChannel1,
+            session=dataset.lastSession,
+            parentList=subChannel1.parent.getSession())
+
         # check the session was added
-        self.assertEqual(self.subChannel1.getSession(), eventArray)
-        self.assertEqual(self.subChannel1._sessions[2], eventArray)
-        self.assertEqual(self.subChannel1.getSession(2), eventArray)
-        
-        
-    def testAddSubChannel(self):
+        assert subChannel1.getSession() == eventArray
+        assert subChannel1._sessions[2] == eventArray
+        assert subChannel1.getSession(2) == eventArray
+
+    def testAddSubChannel(self, subChannel1):
         """ Test addSubChannel method.  This will throw an error. """
-        self.assertRaises(AttributeError, self.subChannel1.addSubChannel)
-        
-        
-    def testGetSubchannel(self):
+        with pytest.raises(AttributeError):
+            subChannel1.addSubChannel()
+
+    def testGetSubchannel(self, subChannel1):
         """ Test getSubChannel method.  This will always throw an error. """
-        self.assertRaises(AttributeError, self.subChannel1.getSubChannel)
+        with pytest.raises(AttributeError):
+            subChannel1.getSubChannel()
+
 
-    
 #===============================================================================
-# 
+#
 #===============================================================================
 
-class EventListTestCase(unittest.TestCase):
-    """ Test case for methods in the EventList class. """
-    
-    def setUp(self):
-        self.dataset = importer.importFile('./testing/SSX70065.IDE')
-        self.dataset.addSession(0, 1, 2)
-        self.dataset.addSensor(0)
-        
-        self.fakeParser = GenericObject()
-        self.fakeParser.types = [0]
-        self.fakeParser.format = []
-        
-        self.channel1 = Channel(
-            self.dataset, channelId=0, name="channel1", parser=self.fakeParser,
-            displayRange=[0])
-        self.eventList1 = EventList(self.channel1, session=self.dataset.sessions[0])
-        
-        self.channel1.addSubChannel(subchannelId=0)
-        
-        self.subChannel1 = SubChannel(self.channel1, 0)
-        
-        
-    def tearDown(self):
-        self.dataset = None
-        self.fakeParser = None
-        self.channel1 = None
-        self.eventList1 = None
-    
-    
-    def mockData(self):
+class TestEventArray:
+    """ Test case for methods in the EventArray class. """
+
+    @pytest.fixture
+    def dataset(self, SSX70065IDE):
+        SSX70065IDE.addSession(0, 1, 2)
+        SSX70065IDE.addSensor(0)
+
+        return SSX70065IDE
+
+    @pytest.fixture
+    def fakeParser(self):
+        parser = GenericObject()
+        parser.types = [0]
+        parser.format = []
+
+        return parser
+
+    @pytest.fixture
+    def _channel1(self, dataset, fakeParser):
+        return Channel(
+            dataset, channelId=0, name="channel1", parser=fakeParser,
+            displayRange=[0]
+        )
+
+    @pytest.fixture
+    def eventArray1(self, _channel1, dataset):
+        return EventArray(_channel1, session=dataset.sessions[0])
+
+    @pytest.fixture
+    def channel1(self, _channel1):
+        _channel1.addSubChannel(subchannelId=0)
+
+        return _channel1
+
+    @pytest.fixture
+    def subchannel1(self, channel1):
+        return SubChannel(channel1, 0)
+
+    @pytest.fixture
+    def mockData(self, eventArray1):
         """ mock up a bit of fake data so I don't have to worry that external
             classes are working during testing.
         """
@@ -888,932 +981,140 @@ def mockData(self):
         fakeData.indexRange = [0, 4]
         fakeData.sampleTime = 1
         fakeData.numSamples = 1
-        self.eventList1._data = [fakeData]
-    
-    
-    def mockIterslice(self,  *args, **kwargs):
+        eventArray1._data = [fakeData]
+
+    @pytest.fixture
+    def channel8(self, testIDE):
+        return testIDE.channels[8]
+
+    @pytest.fixture
+    def eventArray(self, channel8):
+        return channel8.getSession()
+
+    def mockIterSlice(self,  *args, **kwargs):
+        """ Mock up iterslice so it doesn't get called while testing
+            other methods.
+        """
+        self.iterArgs = args
+        self.kwArgs = kwargs
+        return iter(np.array([(0, 1), (0, 2), (0, 3), (0, 4)], dtype=np.float64))
+
+    def mockArraySlice(self,  *args, **kwargs):
         """ Mock up iterslice so it doesn't get called while testing
             other methods.
         """
         self.iterArgs = args
         self.kwArgs = kwargs
-        return [(0, 1), (0, 2), (0, 3), (0, 4)]
-    
-    
+        return np.array([(0, 1), (0, 2), (0, 3), (0, 4)], dtype=np.float64)
+
     def mockXform(self, times, session=None, noBivariates=None):
         self.xformArgs = (times, session, noBivariates)
-        
+
         self.mockXformReturn[2] += 1
 
         return list(self.mockXformReturn)
-    
-    
-    def testConstructor(self):
+
+    # --------------------------------------------------------------------------
+    # Base Method Tests
+    # --------------------------------------------------------------------------
+
+    def testConstructor(self, eventArray1, channel1, dataset):
         """ Test the __init__ method. """
-        self.assertEqual(self.eventList1._blockIndices, [])
-        self.assertEqual(self.eventList1._blockTimes, [])
-        self.assertEqual(self.eventList1._childLists, [])
-        self.assertEqual(self.eventList1._data, [])
-        self.assertEqual(self.eventList1._firstTime, None)
-        self.assertFalse(self.eventList1._hasSubsamples)
-        self.assertEqual(self.eventList1._lastTime, None)
-        self.assertEqual(self.eventList1._length, 0)
-        self.assertEqual(self.eventList1._parentList, None)
-        self.assertEqual(
-            self.eventList1._singleSample, self.channel1.singleSample)
-        
-        self.assertEqual(self.eventList1.channelId, self.channel1.id)
-        self.assertEqual(self.eventList1.dataset, self.channel1.dataset)
-        self.assertEqual(
-            self.eventList1.displayRange, self.channel1.displayRange)
-        self.assertTrue(self.eventList1.hasMinMeanMax)
-        self.assertEqual(
-            self.eventList1.hasDisplayRange, self.channel1.hasDisplayRange)
-        self.assertTrue(self.eventList1.hasSubchannels)
-        self.assertFalse(self.eventList1.noBivariates)
-        self.assertEqual(self.eventList1.parent, self.channel1)
-        self.assertFalse(self.eventList1.removeMean)
-        self.assertEqual(
-            self.eventList1.rollingMeanSpan, EventList.DEFAULT_MEAN_SPAN)
-        self.assertEqual(self.eventList1.session, self.dataset.sessions[0])
-        self.assertEqual(self.eventList1.subchannelId, None)        
-            
-            
-    def testUpdateTransforms(self):
+        assert eventArray1._blockIndices == []
+        assert eventArray1._blockTimes == []
+        assert eventArray1._childLists == []
+        assert eventArray1._data == []
+        assert eventArray1._firstTime is None
+        assert eventArray1._hasSubsamples is False
+        assert eventArray1._lastTime is None
+        assert eventArray1._length == 0
+        assert eventArray1._parentList is None
+        assert eventArray1._singleSample == channel1.singleSample
+
+        assert eventArray1.channelId == channel1.id
+        assert eventArray1.dataset == channel1.dataset
+        assert eventArray1.displayRange == channel1.displayRange
+        assert eventArray1.hasMinMeanMax is True
+        assert eventArray1.hasDisplayRange == channel1.hasDisplayRange
+        assert eventArray1.hasSubchannels is True
+        assert eventArray1.noBivariates is False
+        assert eventArray1.parent == channel1
+        assert eventArray1.removeMean is False
+        assert eventArray1.rollingMeanSpan == EventArray.DEFAULT_MEAN_SPAN
+        assert eventArray1.session == dataset.sessions[0]
+        assert eventArray1.subchannelId is None
+
+        assert eventArray1._blockIndicesArray.size == 0
+        assert eventArray1._blockTimesArray.size == 0
+
+    def testUpdateTransformsNoRecursion(self, eventArray1, channel1, dataset):
         """ Test the updateTransforms method. """
         # update transforms without recursion
-        self.eventList1.updateTransforms(False)
-        self.assertEqual(
-            self.eventList1._comboXform, 
-            PolyPoly([self.channel1.transform]*len(self.channel1.types)))
-        xs = [c.transform if c is not None else None for c in self.channel1.subchannels]
-        xs = [CombinedPoly(t, x=self.channel1.transform, dataset=self.dataset) for t in xs]
-        self.assertEqual(self.eventList1._fullXform, PolyPoly(xs,dataset=self.dataset))
-        self.tearDown()
-        
+        eventArray1.updateTransforms(False)
+        assert eventArray1._comboXform == PolyPoly([channel1.transform]*len(channel1.types))
+
+        xs = [c.transform if c is not None else None
+              for c in channel1.subchannels]
+        xs = [CombinedPoly(t, x=channel1.transform, dataset=dataset)
+              for t in xs]
+        assert eventArray1._fullXform == PolyPoly(xs, dataset=dataset)
+
+    def testUpdateTransformsYesRecursion(self, eventArray1, channel1, dataset):
+
         # update transforms with recursion
-        self.setUp()
-        self.eventList1.updateTransforms(True)
-        self.assertEqual(
-            self.eventList1._displayXform, 
-            PolyPoly(xs, dataset=self.dataset))
-        self.tearDown()
-        
-        # test for when there's a subchannel with a corresponding session
-        self.setUp()
-        self.eventList1.session.sessionId = 'session0'
-        self.eventList1.parent.subchannels[0]._sessions = {'session0': self.eventList1}
-        self.eventList1.updateTransforms()
-        xs = [c.transform if c is not None else None for c in self.eventList1.parent.subchannels]
-        self.assertEqual(
-            self.eventList1._displayXform, 
-            PolyPoly(
-                [CombinedPoly(self.eventList1.transform, x=xs[0], dataset=self.dataset)], 
-                dataset=self.dataset))
-        
-        
-    def testUnits(self):
-        """ Test the units property. """
-        self.assertEqual(self.eventList1.units, ('', ''))
-        
-        
-    def testPath(self):
-        """ Test the path method. """
-        self.assertEqual(self.eventList1.path(), "channel1, 0")
-        
-        
-    def testCopy(self):
-        """ Test the copy method. Since this is a shallow copy, don't use the 
-            build in equality check.
-        """
-        eventListCopy = self.eventList1.copy()
-        self.assertEqual(self.eventList1.parent, eventListCopy.parent)
-        self.assertEqual(self.eventList1.session, eventListCopy.session)
-        self.assertEqual(self.eventList1.dataset, eventListCopy.dataset)
-        self.assertEqual(self.eventList1.hasSubchannels, eventListCopy.hasSubchannels)
-        self.assertEqual(self.eventList1.noBivariates, eventListCopy.noBivariates)
-        self.assertEqual(self.eventList1.channelId, eventListCopy.channelId)
-        self.assertEqual(self.eventList1.subchannelId, eventListCopy.subchannelId)
-        self.assertEqual(self.eventList1.channelId, eventListCopy.channelId)
-        self.assertEqual(self.eventList1.hasDisplayRange, eventListCopy.hasDisplayRange)
-        self.assertEqual(self.eventList1.displayRange, eventListCopy.displayRange)
-        self.assertEqual(self.eventList1.removeMean, eventListCopy.removeMean)
-        self.assertEqual(self.eventList1.hasMinMeanMax, eventListCopy.hasMinMeanMax)
-        self.assertEqual(self.eventList1.rollingMeanSpan, eventListCopy.rollingMeanSpan)
-        self.assertEqual(self.eventList1.transform, eventListCopy.transform)
-        self.assertEqual(self.eventList1.useAllTransforms, eventListCopy.useAllTransforms)
-        self.assertEqual(self.eventList1.allowMeanRemoval, eventListCopy.allowMeanRemoval)
-
-
-    @unittest.skip('failing, poorly formed')
-    def testAppend(self):
-        """ Test the append method. """
-        fakeData = GenericObject()
-        fakeData.numSamples = 1
-        fakeData.startTime = 2
-        fakeData.endTime = 4
-        fakeData.minMeanMax = (5, 6, 7)
-        
-        fakeData.parseMinMeanMax = lambda x: x
-        
-        # append boring basic fakeData
-        self.eventList1.append(fakeData)
-        
-        self.assertEqual(fakeData.blockIndex, 0)
-        self.assertFalse(fakeData.cache)
-        self.assertEqual(fakeData.indexRange, (0, 1))
-        self.assertEqual(self.eventList1._blockIndices,[0])
-        self.assertEqual(self.eventList1._blockTimes, [2])
-        self.assertEqual(self.eventList1._firstTime, 2)
-        self.assertEqual(self.eventList1._lastTime, 4)
-        self.assertEqual(self.eventList1._length, 1)
-        self.assertTrue(self.eventList1._singleSample)
-        
-        # append single sample fakeData
-        self.eventList1._singleSample = True
-        
-        self.eventList1.append(fakeData)
-        
-        self.assertEqual(fakeData.blockIndex, 1)
-        self.assertFalse(fakeData.cache)
-        self.assertEqual(fakeData.indexRange, (1, 2))
-        self.assertEqual(self.eventList1._blockIndices,[0, 1])
-        self.assertEqual(self.eventList1._blockTimes, [2,2])
-        self.assertEqual(self.eventList1._firstTime, 2)
-        self.assertEqual(self.eventList1._lastTime, 4)
-        self.assertEqual(self.eventList1._length, 2)
-        self.assertTrue(self.eventList1._singleSample)
-        
-        # append with times stripped out
-        self.eventList1.session.firstTime = None
-        self.eventList1.session.lastTime = None
-        self.eventList1._firstTime = None
-        
-        self.eventList1.append(fakeData)
-        
-        self.assertEqual(fakeData.blockIndex, 2)
-        self.assertFalse(fakeData.cache)
-        self.assertEqual(fakeData.indexRange, (2, 3))
-        self.assertEqual(self.eventList1._blockIndices,[0, 1, 2])
-        self.assertEqual(self.eventList1._blockTimes, [2, 2, 2])
-        self.assertEqual(self.eventList1._firstTime, 2)
-        self.assertEqual(self.eventList1._lastTime, 4)
-        self.assertEqual(self.eventList1._length, 3)
-        self.assertTrue(self.eventList1._singleSample)
-
-
-    def testGetInterval(self):
-        """ Test the getInterval method. """
-        fakeObject = GenericObject()
-        fakeObject.startTime = 3
-        fakeObject.endTime = 1
-        accel = self.dataset.channels[32].getSession()
-        
-        # without _data, return None
-        self.assertEqual(accel.getInterval(), None)
-        self.assertEqual(accel._lastTime, None)
-        
-        # with mocked data
-        accel._data = [fakeObject]
-        self.assertEqual(accel.getInterval(), (3, 1))
-        self.assertEqual(accel._lastTime, 1)
-        
-        # with mocked data and a mocked dataset
-        accel.dataset = GenericObject()
-        accel.dataset.loading = True
-        self.assertEqual(accel.getInterval(), (3, 1))
-        
-        
-    def mockForGetItem(self, section):
-        """ Mock different things for testGetItem. """
-        
-        # mock with xforms
-        if section == 0:
-            
-            mockBlockIndex = [0, 1, 2, 3]
-            
-            def mockGetBlockIndexWithIndex(idx, start=None):
-                return mockBlockIndex[idx]
-            
-            def mockGetBlockIndexRange(idx):
-                return mockBlockIndex
-            
-            def mockXform(time, val, session=None, noBivariates=False):
-                if type(val) is tuple:
-                    return time, val
-                return time, [val.id]
-                
-            def mockParseBlock(block, start=None, end=None, step=None):
-                return [(block,)]
-            
-            self.eventList1._getBlockIndexRange = mockGetBlockIndexRange
-            self.eventList1._getBlockIndexWithIndex = mockGetBlockIndexWithIndex
-            self.eventList1.parent.parseBlock = mockParseBlock
-            self.eventList1._displayXform = self.eventList1._comboXform = \
-                    self.eventList1._fullXform = mockXform
-            
-            self.eventList1._data = [GenericObject() for _ in range(4)]
-            
-            for i, datum in enumerate(self.eventList1._data):
-                datum.id = i
-            
-        # mock without xforms
-        elif section == 1:
-            
-            def mockXform(time, val, session=None, noBivariates=False):
-                return None
-            
-            self.eventList1._displayXform = self.eventList1._comboXform = \
-                    self.eventList1._fullXform = mockXform
-                    
-        # with blockRollingMean
-        elif section == 2:
-        
-            self.mockForGetItem(0)
-            
-            self.eventList1._getBlockRollingMean = lambda x: [1]
-            
-            def mockXform(time, val, session=None, noBivariates=False):
-                if type(val) is tuple:
-                    return time, val
-                return time, [val.id]
-            
-            self.eventList1._displayXform = self.eventList1._comboXform = \
-                    self.eventList1._fullXform = mockXform
-                
-        # for __getitem__ to work in getEventIndexNear
-        elif section == 3:
-            self.mockForGetItem(0)
-            
-            def mockGetBlockSampleTime(idx, start=None):
-                return 1
-            
-            self.eventList1._getBlockSampleTime = mockGetBlockSampleTime
-                 
-                        
-    def testGetItem(self):
-        """ Test the getitem special method. """
-        length = 4
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=None,
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=lambda idx: range(length)[idx],
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-        )
-        eventList._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, startTime=eventList._getBlockSampleTime(i)
-            )
-        )
-        eventList.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        [(range(length)[block.id],)])
-        )
-
-        self.assertRaises(TypeError, EventList.__getitem__, eventList, 'd')
-
-        # if the transform returns a none type, it should just skip through
-        # and return None
-        eventList.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        None),
-            _getBlockRollingMean=lambda blockIdx: None,
-            hasSubchannels=True,
-        )
-        self.assertEqual(EventList.__getitem__(eventList, 0), None)
-        self.assertEqual(EventList.__getitem__(eventList, 1), None)
-        self.assertEqual(EventList.__getitem__(eventList, 2), None)
-        self.assertEqual(EventList.__getitem__(eventList, 3), None)
-
-        # if parent.parseBlock just bounces back data, then it should just
-        # get a tuple with the timestamp and data
-        eventList.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _getBlockRollingMean=lambda blockIdx: None,
-            hasSubchannels=True,
-        )
-        self.assertEqual(EventList.__getitem__(eventList, 0), (0.00, 0))
-        self.assertEqual(EventList.__getitem__(eventList, 1), (0.01, 7))
-        self.assertEqual(EventList.__getitem__(eventList, 2), (0.02, 14))
-        self.assertEqual(EventList.__getitem__(eventList, 3), (0.03, 21))
-
-        # If there is an offset, return a tuple of the timestamp and data,
-        # minus the offset
-        eventList.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _getBlockRollingMean=lambda blockIdx: (-5,),
-            hasSubchannels=True,
-        )
-
-        self.assertEqual(EventList.__getitem__(eventList, 0), (0.00, 35))
-        self.assertEqual(EventList.__getitem__(eventList, 1), (0.01, 42))
-        self.assertEqual(EventList.__getitem__(eventList, 2), (0.02, 49))
-        self.assertEqual(EventList.__getitem__(eventList, 3), (0.03, 56))
-
-        # if hasSubchannels is True, return a tuple of the timestamp and
-        # the single channel's data
-        eventList.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _getBlockRollingMean=lambda blockIdx: None,
-            hasSubchannels=False, subchannelId=0
-        )
-        eventList.parent.configure_mock()
-        self.assertEqual(EventList.__getitem__(eventList, 0), (0.00, 0))
-        self.assertEqual(EventList.__getitem__(eventList, 1), (0.01, 7))
-        self.assertEqual(EventList.__getitem__(eventList, 2), (0.02, 14))
-        self.assertEqual(EventList.__getitem__(eventList, 3), (0.03, 21))
-
-        
-    def testIter(self):
-        """ Test for iter magic method. """
-        eventList = mock.Mock(spec=EventList)
-        self.assertEqual(EventList.__iter__(eventList), eventList.iterSlice())
-            
-            
-    # TODO talk to david about how to test these
-    def testItervalues(self):
-        """ Test for itervalues method. """
-
-        # Stub dependencies
-        length = 4
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            iterSlice=lambda *a, **kw: EventList.iterSlice(eventList, *a, **kw)
-        )
-        eventList._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventList._getBlockSampleTime(i),
-            )
-        )
-        eventList.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        [(range(length)[block.id],)])
-        )
-
-        # Run test
-        self.assertListEqual(
-            list(EventList.itervalues(eventList)),
-            [(0,), (7,), (14,), (21,)]
-        )
-
-
-    def testIterSlice(self):
-        """ Test for iterSlice method. """
-        # Stub dependencies
-        length = 4
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-        )
-        eventList._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventList._getBlockSampleTime(i),
-            )
-        )
-        eventList.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        [(range(length)[block.id],)])
-        )
-
-        # Run test
-        self.assertListEqual(
-            list(EventList.iterSlice(eventList)),
-            [(0.00, 0), (0.01, 7), (0.02, 14), (0.03, 21)]
-        )
-
-
-    def testIterJitterySlice(self):
-        """ Test for the iterJitterySlice method. """
-        # Stub dependencies
-        length = 4
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-        )
-        eventList._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventList._getBlockSampleTime(i),
-            )
-        )
-        eventList.parent.configure_mock(
-            parseBlockByIndex=(lambda block, indices, subchannel=None:
-                               [(range(length)[block.id],)])
-        )
-
-        # Run test
-        self.assertListEqual(
-            list(EventList.iterJitterySlice(eventList)),
-            [(0.00, 0), (0.01, 7), (0.02, 14), (0.03, 21)]
-        )
-        
-        
-    def testGetEventIndexBefore(self):
-        """ Test for getEventIndexBefore method. """
-        self.mockData()
-
-        self.assertEqual(self.eventList1.getEventIndexBefore(1), 1)
-        self.assertEqual(self.eventList1.getEventIndexBefore(-1), -1)
-        
-        
-    def testGetEventIndexNear(self):
-        """ Test for getEventIndexNear method. """
-        length = 4
-        dt = 0.01
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            useAllTransforms=True,
-            _fullXform=None,
-            __len__=lambda self: length,
-            __getitem__=(lambda *a, **kw: EventList.__getitem__(*a, **kw)),
-            iterSlice=(lambda start=None, end=None, step=1, display=False:
-                       ((i*dt, i) for i in range(length)[start:end:step])),
-            _data=mock.Mock(),
-            getEventIndexBefore=lambda t: min(max(-1, int(t//dt)), length-1),
-            _getBlockIndexWithIndex=lambda idx: range(length)[idx],
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: dt*range(length)[idx],
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-        )
-        eventList._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, startTime=eventList._getBlockSampleTime(i)
-            )
-        )
-
-        self.assertEqual(EventList.getEventIndexNear(eventList, -1), 0)
-        self.assertEqual(EventList.getEventIndexNear(eventList, 0), 0)
-        self.assertEqual(EventList.getEventIndexNear(eventList, dt*0.9), 1)
-        self.assertEqual(EventList.getEventIndexNear(eventList, dt*1.1), 1)
-        self.assertEqual(EventList.getEventIndexNear(eventList, dt*(length-1)), length-1)
-        self.assertEqual(EventList.getEventIndexNear(eventList, dt*length), length-1)
-    
-    
-    def testGetRangeIndices(self):
-        """ Test for getRangeIndices method. """
-        self.mockData()
-        
-        # input permutations for multi sample
-        self.assertEqual(self.eventList1.getRangeIndices(1, 2), (2, 3))
-        self.assertEqual(self.eventList1.getRangeIndices(None, 2), (0, 3))
-        self.assertEqual(self.eventList1.getRangeIndices(None, None), (0, 4))
-        self.assertEqual(self.eventList1.getRangeIndices(2, -51), (3, 0))
-        
-        # input permutations for single sample
-        self.eventList1.parent.singleSample = True
-        self.assertEqual(self.eventList1.getRangeIndices(2, -51), (0, 1))
-        self.assertEqual(self.eventList1.getRangeIndices(2, None), (0, 4))
-        
-        
-    def testGetRange(self):
-        """ Test for getRange method. """
-        self.mockData()
-        self.eventList1.iterRange = lambda x, y, display: (x, y, display)
-        
-        self.assertEqual(
-            self.eventList1.getRange(), 
-            [None, None, False])
-    
-    
-    def testIterRange(self):
-        """ Test for iterRange method. """
-        self.mockData()
-        self.eventList1.iterSlice = lambda w, x, y, display: (w+1, x, y, display)
-        
-        self.assertEqual(
-            self.eventList1.iterRange(1, 4, 1, display=False),
-            self.eventList1.iterSlice(2, 4, 1, display=False)
-        )
-        
-
-    @unittest.skip('failing, poorly formed')
-    def testIterMinMeanMax(self):
-        """ Test for iterMinMeanMax method. """
-        self.mockData()
-        self.eventList1._data[0].minMeanMax = 1
-        self.eventList1._data[0].blockIndex = 2
-        self.eventList1._data[0].min = (3,)
-        self.eventList1._data[0].mean = (4,)
-        self.eventList1._data[0].max = (5,)
-
-        self.assertSequenceEqual(
-            [x for x in self.eventList1.iterMinMeanMax()],
-            [((0, 3), (0, 4), (0, 5))])
-
-
-    @unittest.skip('failing, poorly formed')
-    def testGetMinMeanMax(self):
-        """ Test getMinMeanMax. """
-        self.mockData()
-        self.eventList1._data[0].minMeanMax = 1
-        self.eventList1._data[0].blockIndex = 2
-        self.eventList1._data[0].min = (3,)
-        self.eventList1._data[0].mean = (4,)
-        self.eventList1._data[0].max = (5,)
-
-        self.assertListEqual(
-            self.eventList1.getMinMeanMax(),
-            [((0, 3), (0, 4), (0, 5))])
-
-
-    @unittest.skip('failing, poorly formed')
-    def testGetRangeMinMeanMax(self):
-        """ Test for getRangeMinMeanMax method. """
-        self.mockData()
-        self.eventList1._data[0].minMeanMax = 1
-        self.eventList1._data[0].blockIndex = 2
-        self.eventList1._data[0].min = (3,)
-        self.eventList1._data[0].mean = (4,)
-        self.eventList1._data[0].max = (5,)
-
-        self.assertEqual(self.eventList1.getRangeMinMeanMax(), (3, 4, 5))
-
-
-    def testGetMax(self):
-        """ Test for getMax method. """
-        # Stub dependencies
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            _data=mock.Mock(__getitem__=lambda self, idx: mock.Mock(
-                indexRange=(mock.sentinel.start, mock.sentinel.end)
-            )),
-            hasMinMeanMax=False,
-            hasSubchannels=False,
-            iterMinMeanMax=lambda *a, **kw: [((0, 3), (0, 4), (0, 5))],
-            iterSlice=lambda *a, **kw: [(0, 3), (0, 3.5), (0, 4.5), (0, 5)],
-        )
-
-        # Run test
-        self.assertEqual(EventList.getMax(eventList), (0, 5))
-
-
-    def testGetMin(self):
-        """ test for getMin method. """
-        # Stub dependencies
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            _data=mock.Mock(__getitem__=lambda self, idx: mock.Mock(
-                indexRange=(mock.sentinel.start, mock.sentinel.end)
-            )),
-            hasMinMeanMax=False,
-            hasSubchannels=False,
-            iterMinMeanMax=lambda *a, **kw: [((0, 3), (0, 4), (0, 5))],
-            iterSlice=lambda *a, **kw: [(0, 3), (0, 3.5), (0, 4.5), (0, 5)],
-        )
-
-        # Run test
-        self.assertEqual(EventList.getMin(eventList), (0, 3))
-
-
-    def testGetSampleTime(self):
-        """ Test for getSampleTime method. """
-        self.mockData()
-        
-        self.assertEqual(self.eventList1.getSampleTime(), 1)
-        self.assertEqual(self.eventList1.getSampleTime(1), 1)
-        self.assertEqual(self.dataset.channels[32].getSession().getSampleTime(), -1)
-        self.assertEqual(self.dataset.channels[32].getSession().getSampleTime(1), -1)
-        
-        
-    def testGetSampleRate(self):
-        """ Test for getSampleRate method. """
-        self.mockData()
-        self.eventList1._data[0].sampleRate = 5
-        self.dataset.channels[32].sampleRate = 3
-        self.dataset.channels[32].getSession()._data = self.eventList1._data
-        
-        self.assertEqual(self.eventList1.getSampleRate(), 5)
-        self.assertEqual(self.eventList1.getSampleRate(0), 5)
-        self.assertEqual(self.dataset.channels[32].getSession().getSampleRate(), 3)
-        self.assertEqual(self.dataset.channels[32].getSession().getSampleRate(0), 5)
-
-
-    def testGetValueAt(self):
-        """ Test for getValueAt method. """
-        # Stub dependencies
-        length = 4
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            getEventIndexBefore=lambda at: min(max(-1, int(at//0.01)), length-1),
-            _getBlockIndexWithIndex=lambda idx: range(length)[idx],
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: None,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            __getitem__=lambda self, *a, **kw: EventList.__getitem__(eventList, *a, **kw),
-        )
-        eventList._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, startTime=eventList._getBlockSampleTime(i)
-            )
-        )
-        eventList.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        [(range(length)[block.id],)])
-        )
-        eventList.parent.types.__len__ = lambda self: 1
-
-        # Run test
-        self.assertRaises(IndexError, EventList.getValueAt, eventList, -0.01)
-        self.assertEqual(EventList.getValueAt(eventList, -0.01, outOfRange=True), (0.00, 0))
-        self.assertEqual(EventList.getValueAt(eventList, 0.00), (0.00, 0))
-        self.assertEqual(EventList.getValueAt(eventList, 0.01), (0.01, 7))
-        self.assertEqual(EventList.getValueAt(eventList, 0.02), (0.02, 14))
-        self.assertEqual(EventList.getValueAt(eventList, 0.03), (0.03, 21))
-        self.assertEqual(EventList.getValueAt(eventList, 0.04, outOfRange=True), (0.03, 21))
-        self.assertRaises(IndexError, EventList.getValueAt, eventList, 0.04)
-
-
-    def testGetMeanNear(self):
-        """ Test for getMeanNear method. """
-        # Stub dependencies
-        length = 4
-        eventList = mock.Mock(spec=EventList)
-        eventList.configure_mock(
-            __len__=lambda self: length,
-            _comboXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithTime=lambda at: min(max(-1, int((at+0.005)//0.01)), length-1),
-            _getBlockRollingMean=lambda blockIdx, force=False: (range(length)[blockIdx],),
-            hasSubchannels=True,
-        )
-        eventList._data.configure_mock(
-            __len__=eventList.__len__,
-        )
-
-        # Run test
-        self.assertEqual(EventList.getMeanNear(eventList, 0.00), (0,))
-        self.assertEqual(EventList.getMeanNear(eventList, 0.01), (7,))
-        self.assertEqual(EventList.getMeanNear(eventList, 0.02), (14,))
-        self.assertEqual(EventList.getMeanNear(eventList, 0.03), (21,))
-
-
-    def testExportCSV(self):
-        """ Test exportCsv, """
-        self.mockData()
-        self.eventList1._data[0].minMeanMax = 1
-        self.eventList1._data[0].blockIndex = 2
-        self.eventList1._data[0].min = [3]
-        self.eventList1._data[0].mean = [4]
-        self.eventList1._data[0].max = [5]
-        
-        # print(self.eventList1.exportCsv(streamMocker))
-
-
-#===============================================================================
-#
-#===============================================================================
-
-class EventArrayTestCase(unittest.TestCase):
-    """ Test case for methods in the EventArray class. """
-
-    def assertArrayEqual(self, array1, array2):
-        self.assertTrue(np.all(array1 == array2))
-
-    def setUp(self):
-        self.dataset = importer.importFile('./testing/SSX70065.IDE')
-        self.dataset.addSession(0, 1, 2)
-        self.dataset.addSensor(0)
-
-        self.fakeParser = GenericObject()
-        self.fakeParser.types = [0]
-        self.fakeParser.format = []
-
-        self.channel1 = Channel(
-            self.dataset, channelId=0, name="channel1", parser=self.fakeParser,
-            displayRange=[0]
-        )
-        self.eventArray1 = EventArray(self.channel1,
-                                      session=self.dataset.sessions[0])
-
-        self.channel1.addSubChannel(subchannelId=0)
-
-        self.subChannel1 = SubChannel(self.channel1, 0)
-
-    def tearDown(self):
-        self.dataset = None
-        self.fakeParser = None
-        self.channel1 = None
-        self.eventArray1 = None
-
-    def mockData(self):
-        """ mock up a bit of fake data so I don't have to worry that external
-            classes are working during testing.
-        """
-        fakeData = GenericObject()
-        fakeData.startTime = 0
-        fakeData.indexRange = [0, 4]
-        fakeData.sampleTime = 1
-        fakeData.numSamples = 1
-        self.eventArray1._data = [fakeData]
-
-    def mockIterSlice(self,  *args, **kwargs):
-        """ Mock up iterslice so it doesn't get called while testing
-            other methods.
-        """
-        self.iterArgs = args
-        self.kwArgs = kwargs
-        return iter(np.array([(0, 1), (0, 2), (0, 3), (0, 4)], dtype=np.float64))
-
-    def mockArraySlice(self,  *args, **kwargs):
-        """ Mock up iterslice so it doesn't get called while testing
-            other methods.
-        """
-        self.iterArgs = args
-        self.kwArgs = kwargs
-        return np.array([(0, 1), (0, 2), (0, 3), (0, 4)], dtype=np.float64)
-
-    def mockXform(self, times, session=None, noBivariates=None):
-        self.xformArgs = (times, session, noBivariates)
-
-        self.mockXformReturn[2] += 1
-
-        return list(self.mockXformReturn)
-
-    # --------------------------------------------------------------------------
-    # Base Method Tests
-    # --------------------------------------------------------------------------
-
-    def testConstructor(self):
-        """ Test the __init__ method. """
-        self.assertEqual(self.eventArray1._blockIndices, [])
-        self.assertEqual(self.eventArray1._blockTimes, [])
-        self.assertEqual(self.eventArray1._childLists, [])
-        self.assertEqual(self.eventArray1._data, [])
-        self.assertEqual(self.eventArray1._firstTime, None)
-        self.assertFalse(self.eventArray1._hasSubsamples)
-        self.assertEqual(self.eventArray1._lastTime, None)
-        self.assertEqual(self.eventArray1._length, 0)
-        self.assertEqual(self.eventArray1._parentList, None)
-        self.assertEqual(self.eventArray1._singleSample,
-                         self.channel1.singleSample)
-
-        self.assertEqual(self.eventArray1.channelId, self.channel1.id)
-        self.assertEqual(self.eventArray1.dataset, self.channel1.dataset)
-        self.assertEqual(self.eventArray1.displayRange,
-                         self.channel1.displayRange)
-        self.assertTrue(self.eventArray1.hasMinMeanMax)
-        self.assertEqual(self.eventArray1.hasDisplayRange,
-                         self.channel1.hasDisplayRange)
-        self.assertTrue(self.eventArray1.hasSubchannels)
-        self.assertFalse(self.eventArray1.noBivariates)
-        self.assertEqual(self.eventArray1.parent, self.channel1)
-        self.assertFalse(self.eventArray1.removeMean)
-        self.assertEqual(self.eventArray1.rollingMeanSpan,
-                         EventArray.DEFAULT_MEAN_SPAN)
-        self.assertEqual(self.eventArray1.session, self.dataset.sessions[0])
-        self.assertEqual(self.eventArray1.subchannelId, None)
-
-        self.assertEqual(self.eventArray1._blockIndicesArray.size, 0)
-        self.assertEqual(self.eventArray1._blockTimesArray.size, 0)
-
-    # TODO add test
-    #def test_JoinTimesValues(self):
-
-    def testUpdateTransforms(self):
-        """ Test the updateTransforms method. """
-        # update transforms without recursion
-        self.eventArray1.updateTransforms(False)
-        self.assertEqual(
-            self.eventArray1._comboXform,
-            PolyPoly([self.channel1.transform]*len(self.channel1.types))
-        )
         xs = [c.transform if c is not None else None
-              for c in self.channel1.subchannels]
-        xs = [CombinedPoly(t, x=self.channel1.transform, dataset=self.dataset)
+              for c in channel1.subchannels]
+        xs = [CombinedPoly(t, x=channel1.transform, dataset=dataset)
               for t in xs]
-        self.assertEqual(self.eventArray1._fullXform,
-                         PolyPoly(xs, dataset=self.dataset))
-        self.tearDown()
+        eventArray1.updateTransforms(True)
+        assert eventArray1._displayXform == PolyPoly(xs, dataset=self.dataset)
 
-        # update transforms with recursion
-        self.setUp()
-        self.eventArray1.updateTransforms(True)
-        self.assertEqual(self.eventArray1._displayXform,
-                         PolyPoly(xs, dataset=self.dataset))
-        self.tearDown()
+    def testUpdateTransformsOther(self, eventArray1, channel1, dataset):
 
         # test for when there's a subchannel with a corresponding session
-        self.setUp()
-        self.eventArray1.session.sessionId = 'session0'
-        self.eventArray1.parent.subchannels[0]._sessions = {'session0': self.eventArray1}
-        self.eventArray1.updateTransforms()
+        eventArray1.session.sessionId = 'session0'
+        eventArray1.parent.subchannels[0]._sessions = {'session0': eventArray1}
+        eventArray1.updateTransforms()
         xs = [c.transform if c is not None else None
-              for c in self.eventArray1.parent.subchannels]
-        self.assertEqual(
-            self.eventArray1._displayXform,
-            PolyPoly(
-                [CombinedPoly(self.eventArray1.transform,
-                              x=xs[0], dataset=self.dataset)],
-                dataset=self.dataset,
-            )
-        )
+              for c in eventArray1.parent.subchannels]
+        assert eventArray1._displayXform == PolyPoly(
+                [CombinedPoly(eventArray1.transform, x=xs[0], dataset=dataset)],
+                dataset=dataset,
+                )
 
-    def testUnits(self):
+    def testUnits(self, eventArray1):
         """ Test the units property. """
-        self.assertEqual(self.eventArray1.units, ('', ''))
+        assert eventArray1.units == ('', '')
 
-    def testPath(self):
+    def testPath(self, eventArray1):
         """ Test the path method. """
-        self.assertEqual(self.eventArray1.path(), "channel1, 0")
+        assert eventArray1.path() == "channel1"
 
-    def testCopy(self):
+    def testCopy(self, eventArray1):
         """ Test the copy method. Since this is a shallow copy, don't use the
             build in equality check.
         """
-        eventArrayCopy = self.eventArray1.copy()
-        self.assertEqual(self.eventArray1.parent, eventArrayCopy.parent)
-        self.assertEqual(self.eventArray1.session, eventArrayCopy.session)
-        self.assertEqual(self.eventArray1.dataset, eventArrayCopy.dataset)
-        self.assertEqual(self.eventArray1.hasSubchannels, eventArrayCopy.hasSubchannels)
-        self.assertEqual(self.eventArray1.noBivariates, eventArrayCopy.noBivariates)
-        self.assertEqual(self.eventArray1.channelId, eventArrayCopy.channelId)
-        self.assertEqual(self.eventArray1.subchannelId, eventArrayCopy.subchannelId)
-        self.assertEqual(self.eventArray1.channelId, eventArrayCopy.channelId)
-        self.assertEqual(self.eventArray1.hasDisplayRange, eventArrayCopy.hasDisplayRange)
-        self.assertEqual(self.eventArray1.displayRange, eventArrayCopy.displayRange)
-        self.assertEqual(self.eventArray1.removeMean, eventArrayCopy.removeMean)
-        self.assertEqual(self.eventArray1.hasMinMeanMax, eventArrayCopy.hasMinMeanMax)
-        self.assertEqual(self.eventArray1.rollingMeanSpan, eventArrayCopy.rollingMeanSpan)
-        self.assertEqual(self.eventArray1.transform, eventArrayCopy.transform)
-        self.assertEqual(self.eventArray1.useAllTransforms, eventArrayCopy.useAllTransforms)
-        self.assertEqual(self.eventArray1.allowMeanRemoval, eventArrayCopy.allowMeanRemoval)
+        eventArrayCopy = eventArray1.copy()
+        assert eventArray1.parent == eventArrayCopy.parent
+        assert eventArray1.session == eventArrayCopy.session
+        assert eventArray1.dataset == eventArrayCopy.dataset
+        assert eventArray1.hasSubchannels == eventArrayCopy.hasSubchannels
+        assert eventArray1.noBivariates == eventArrayCopy.noBivariates
+        assert eventArray1.channelId == eventArrayCopy.channelId
+        assert eventArray1.subchannelId == eventArrayCopy.subchannelId
+        assert eventArray1.channelId == eventArrayCopy.channelId
+        assert eventArray1.hasDisplayRange == eventArrayCopy.hasDisplayRange
+        assert eventArray1.displayRange == eventArrayCopy.displayRange
+        assert eventArray1.removeMean == eventArrayCopy.removeMean
+        assert eventArray1.hasMinMeanMax == eventArrayCopy.hasMinMeanMax
+        assert eventArray1.rollingMeanSpan == eventArrayCopy.rollingMeanSpan
+        assert eventArray1.transform == eventArrayCopy.transform
+        assert eventArray1.useAllTransforms == eventArrayCopy.useAllTransforms
+        assert eventArray1.allowMeanRemoval == eventArrayCopy.allowMeanRemoval
 
 
     @unittest.skip('failing, poorly formed')
-    def testAppend(self):
+    def testAppend(self, eventArray1):
         """ Test the append method. """
         fakeData = GenericObject()
         fakeData.numSamples = 1
@@ -1824,671 +1125,320 @@ def testAppend(self):
         fakeData.parseMinMeanMax = lambda x: x
 
         # append boring basic fakeData
-        self.eventArray1.append(fakeData)
-
-        self.assertEqual(fakeData.blockIndex, 0)
-        self.assertFalse(fakeData.cache)
-        self.assertEqual(fakeData.indexRange, (0, 1))
-        self.assertEqual(self.eventArray1._blockIndices, [0])
-        self.assertEqual(self.eventArray1._blockTimes, [2])
-        self.assertEqual(self.eventArray1._firstTime, 2)
-        self.assertEqual(self.eventArray1._lastTime, 4)
-        self.assertEqual(self.eventArray1._length, 1)
-        self.assertTrue(self.eventArray1._singleSample)
+        eventArray1.append(fakeData)
+
+        assert fakeData.blockIndex == 0
+        assert fakeData.cache is False
+        assert fakeData.indexRange == (0, 1)
+        assert eventArray1._blockIndices == [0]
+        assert eventArray1._blockTimes == [2]
+        assert eventArray1._firstTime == 2
+        assert eventArray1._lastTime == 4
+        assert eventArray1._length == 1
+        assert eventArray1._singleSample is True
 
         # append single sample fakeData
-        self.eventArray1._singleSample = True
+        eventArray1._singleSample = True
 
-        self.eventArray1.append(fakeData)
+        eventArray1.append(fakeData)
 
-        self.assertEqual(fakeData.blockIndex, 1)
-        self.assertFalse(fakeData.cache)
-        self.assertEqual(fakeData.indexRange, (1, 2))
-        self.assertEqual(self.eventArray1._blockIndices, [0, 1])
-        self.assertEqual(self.eventArray1._blockTimes, [2, 2])
-        self.assertEqual(self.eventArray1._firstTime, 2)
-        self.assertEqual(self.eventArray1._lastTime, 4)
-        self.assertEqual(self.eventArray1._length, 2)
-        self.assertTrue(self.eventArray1._singleSample)
+        assert fakeData.blockIndex == 1
+        assert fakeData.cache is False
+        assert fakeData.indexRange == (1, 2)
+        assert eventArray1._blockIndices == [0, 1]
+        assert eventArray1._blockTimes == [2, 2]
+        assert eventArray1._firstTime == 2
+        assert eventArray1._lastTime == 4
+        assert eventArray1._length == 2
+        assert eventArray1._singleSample is True
 
         # append with times stripped out
-        self.eventArray1.session.firstTime = None
-        self.eventArray1.session.lastTime = None
-        self.eventArray1._firstTime = None
-
-        self.eventArray1.append(fakeData)
-
-        self.assertEqual(fakeData.blockIndex, 2)
-        self.assertFalse(fakeData.cache)
-        self.assertEqual(fakeData.indexRange, (2, 3))
-        self.assertEqual(self.eventArray1._blockIndices, [0, 1, 2])
-        self.assertEqual(self.eventArray1._blockTimes, [2, 2, 2])
-        self.assertEqual(self.eventArray1._firstTime, 2)
-        self.assertEqual(self.eventArray1._lastTime, 4)
-        self.assertEqual(self.eventArray1._length, 3)
-        self.assertTrue(self.eventArray1._singleSample)
-
-    def testGetInterval(self):
+        eventArray1.session.firstTime = None
+        eventArray1.session.lastTime = None
+        eventArray1._firstTime = None
+
+        eventArray1.append(fakeData)
+
+        assert fakeData.blockIndex == 2
+        assert fakeData.cache is False
+        assert fakeData.indexRange == (2, 3)
+        assert eventArray1._blockIndices == [0, 1, 2]
+        assert eventArray1._blockTimes == [2, 2, 2]
+        assert eventArray1._firstTime == 2
+        assert eventArray1._lastTime == 4
+        assert eventArray1._length == 3
+        assert eventArray1._singleSample is True
+
+    def testGetInterval(self, dataset):
         """ Test the getInterval method. """
         fakeObject = GenericObject()
         fakeObject.startTime = 3
         fakeObject.endTime = 1
-        accel = self.dataset.channels[32].getSession()
+        accel = dataset.channels[32].getSession()
 
         # without _data, return None
-        self.assertEqual(accel.getInterval(), None)
-        self.assertEqual(accel._lastTime, None)
+        assert accel.getInterval() == None
+        assert accel._lastTime is None
 
         # with mocked data
         accel._data = [fakeObject]
-        self.assertEqual(accel.getInterval(), (3, 1))
-        self.assertEqual(accel._lastTime, 1)
+        assert accel.getInterval() == (3, 1)
+        assert accel._lastTime == 1
 
         # with mocked data and a mocked dataset
         accel.dataset = GenericObject()
         accel.dataset.loading = True
-        self.assertEqual(accel.getInterval(), (3, 1))
-
-    def mockForGetItem(self, section):
-        """ Mock different things for testGetItem. """
-
-        # mock with xforms
-        if section == 0:
-
-            mockBlockIndex = [0, 1, 2, 3]
-
-            def mockGetBlockIndexWithIndex(idx, start=None):
-                return mockBlockIndex[idx]
-
-            def mockGetBlockIndexRange(idx):
-                return mockBlockIndex
-
-            def mockXform(time, val, session=None, noBivariates=False):
-                if type(val) is tuple:
-                    return time, val
-                return time, [val.id]
-
-            def mockParseBlock(block, start=None, end=None, step=None):
-                return [(block,)]
-
-            self.eventArray1._getBlockIndexRange = mockGetBlockIndexRange
-            self.eventArray1._getBlockIndexWithIndex = mockGetBlockIndexWithIndex
-            self.eventArray1.parent.parseBlock = mockParseBlock
-            self.eventArray1._displayXform = self.eventArray1._comboXform = \
-                self.eventArray1._fullXform = mockXform
-
-            self.eventArray1._data = [GenericObject() for _ in range(4)]
-
-            for i, datum in enumerate(self.eventArray1._data):
-                datum.id = i
-
-        # mock without xforms
-        elif section == 1:
-
-            def mockXform(time, val, session=None, noBivariates=False):
-                return None
-
-            self.eventArray1._displayXform = self.eventArray1._comboXform = \
-                self.eventArray1._fullXform = mockXform
-
-        # with blockRollingMean
-        elif section == 2:
-
-            self.mockForGetItem(0)
-
-            self.eventArray1._getBlockRollingMean = lambda x: [1]
-
-            def mockXform(time, val, session=None, noBivariates=False):
-                if type(val) is tuple:
-                    return time, val
-                return time, [val.id]
-
-            self.eventArray1._displayXform = self.eventArray1._comboXform = \
-                self.eventArray1._fullXform = mockXform
-
-        # for __getitem__ to work in getEventIndexNear
-        elif section == 3:
-            self.mockForGetItem(0)
-
-            def mockGetBlockSampleTime(idx, start=None):
-                return 1
-
-            self.eventArray1._getBlockSampleTime = mockGetBlockSampleTime
-
-    def testGetItem(self):
+        assert accel.getInterval() == (3, 1)
+
+    @pytest.mark.parametrize(
+            'idx, raises',
+            [(0, nullcontext()),
+             (5, nullcontext()),
+             (100, nullcontext()),
+             (-1, nullcontext()),
+             (-500, nullcontext()),
+             (1000, pytest.raises(IndexError)),
+             ('d', pytest.raises(TypeError)),
+             ])
+    def testGetItem(self, eventArray, idx, raises):
         """ Test the getitem special method. """
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=None,
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=lambda idx: range(length)[idx],
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, startTime=eventArray._getBlockSampleTime(i)
-            )
-        )
-        eventArray.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        np.array([[range(length)[block.id]]]))
-        )
-
-        self.assertRaises(TypeError, EventArray.__getitem__, eventArray, 'd')
-
-        # if the transform returns a none type, it should just skip through
-        # and return None
-        eventArray.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        None),
-            _getBlockRollingMean=lambda blockIdx: None,
-            hasSubchannels=True,
-        )
-        self.assertEqual(EventArray.__getitem__(eventArray, 0), None)
-        self.assertEqual(EventArray.__getitem__(eventArray, 1), None)
-        self.assertEqual(EventArray.__getitem__(eventArray, 2), None)
-        self.assertEqual(EventArray.__getitem__(eventArray, 3), None)
-
-        # if parent.parseBlock just bounces back data, then it should just
-        # get a tuple with the timestamp and data
-        eventArray.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _getBlockRollingMean=lambda blockIdx: None,
-            hasSubchannels=True,
-        )
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 0), (0.00, 0))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 1), (0.01, 7))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 2), (0.02, 14))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 3), (0.03, 21))
-
-        # If there is an offset, return a tuple of the timestamp and data,
-        # minus the offset
-        eventArray.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _getBlockRollingMean=lambda blockIdx: (-5,),
-            hasSubchannels=True,
-        )
-
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 0), (0.00, 35))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 1), (0.01, 42))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 2), (0.02, 49))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 3), (0.03, 56))
-
-        # if hasSubchannels is True, return a tuple of the timestamp and
-        # the single channel's data
-        eventArray.configure_mock(
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _getBlockRollingMean=lambda blockIdx: None,
-            hasSubchannels=False, subchannelId=0
-        )
-        eventArray.parent.configure_mock()
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 0), (0.00, 0))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 1), (0.01, 7))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 2), (0.02, 14))
-        np.testing.assert_array_equal(EventArray.__getitem__(eventArray, 3), (0.03, 21))
 
-    def testIter(self):
-        """ Test for iter special method. """
-        self.eventArray1.iterSlice = self.mockIterSlice
-        np.testing.assert_array_equal(
-            [x for x in self.eventArray1],
-            [x for x in self.eventArray1.iterSlice()]
-        )
-
-    # TODO talk to david about how to test these
-    def testIterValues(self):
-        """ Test for itervalues method. """
-        # Stub dependencies
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            iterSlice=(
-                lambda *a, **kw:
-                EventArray.iterSlice(eventArray, *a, **kw)
-            ),
-            _blockSlice=(
-                lambda *a, **kw:
-                EventArray._blockSlice(eventArray, *a, **kw)
-            ),
-            _makeBlockEventsFactory=(
-                lambda *a, **kw:
-                EventArray._makeBlockEventsFactory(eventArray, *a, **kw)
-            ),
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventArray._getBlockSampleTime(i),
-            )
-        )
-        eventArray.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        np.array([[range(length)[block.id]]]))
-        )
-
-        # Run test
-        self.assertListEqual(
-            list(EventArray.itervalues(eventArray)),
-            [(0,), (7,), (14,), (21,)]
-        )
-
-    def testArrayValues(self):
-        """ Test for arrayValues method. """
-        # Stub dependencies
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            arraySlice=(
-                lambda *a, **kw:
-                EventArray.arraySlice(eventArray, *a, **kw)
-            ),
-            _blockSlice=(
-                lambda *a, **kw:
-                EventArray._blockSlice(eventArray, *a, **kw)
-            ),
-            _makeBlockEventsFactory=(
-                lambda *a, **kw:
-                EventArray._makeBlockEventsFactory(eventArray, *a, **kw)
-            ),
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventArray._getBlockSampleTime(i),
-            )
-        )
-        eventArray.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        np.array([[range(length)[block.id]]]))
-        )
+        if isinstance(raises, nullcontext):
+            x = idx
+            if x < 0:
+                x += 1000
+            expected = np.floor(np.array([x*1000, x, 1000.*(x/1000)**2, 1000*(x/1000)**0.5]))
+        else:
+            expected = None
 
-        # Run test
+        with raises:
+            np.testing.assert_array_equal(eventArray[idx], expected)
+
+    def testIter(self, eventArray1):
+        """ Test for iter special method. """
+        eventArray1.iterSlice = self.mockIterSlice
         np.testing.assert_array_equal(
-            EventArray.arrayValues(eventArray),
-            [[0, 7, 14, 21]]
-        )
+            [x for x in eventArray1],
+            [x for x in eventArray1.iterSlice()]
+        )
+
+    @pytest.mark.parametrize('start, end, step',
+                             [
+                                 (None, None, 1),
+                                 (None, None, 5),
+                                 (10, 300, 3),
+                                 ],
+                             )
+    def testIterValues(self, eventArray, start, end, step):
+        """ Test for itervalues method. """
+
+        values = np.stack(tuple(eventArray.itervalues(start, end, step))).T
+
+        np.testing.assert_array_equal(values, eventArray.arrayValues(start, end, step))
+
+    @pytest.mark.parametrize('start, end, step',
+                             [
+                                 (None, None, 1),
+                                 (None, None, 5),
+                                 (10, 300, 3),
+                                 ],
+                             )
+    def testArrayValues(self, testIDE, start, end, step):
+        """ Test for arrayValues method. """
+
+        x = np.arange(*(slice(start, end, step).indices(1000)))/1000
+        expected = np.floor(np.vstack((x, x**2, x**0.5))*1000 + 1e-6)
+
+        actual = testIDE.channels[8].getSession().arrayValues(start, end, step)
+
+        np.testing.assert_equal(actual, expected)
 
-    def testIterSlice(self):
+    @pytest.mark.parametrize('start, end, step',
+                             [
+                                 (None, None, 1),
+                                 (None, None, 5),
+                                 (10, 300, 3),
+                                 ],
+                             )
+    def testIterSlice(self, eventArray, start, end, step):
         """ Test for the iterSlice method. """
-        # Stub dependencies
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            _blockSlice=(
-                lambda *a, **kw:
-                EventArray._blockSlice(eventArray, *a, **kw)
-            ),
-            _makeBlockEventsFactory=(
-                lambda *a, **kw:
-                EventArray._makeBlockEventsFactory(eventArray, *a, **kw)
-            ),
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventArray._getBlockSampleTime(i),
-            )
-        )
-        eventArray.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        np.array([[range(length)[block.id]]]))
-        )
 
-        # Run test
-        np.testing.assert_array_equal(
-            list(EventArray.iterSlice(eventArray)),
-            [(0.00, 0), (0.01, 7), (0.02, 14), (0.03, 21)]
-        )
+        values = np.stack(tuple(eventArray.iterSlice(start, end, step))).T
 
-    def testArraySlice(self):
+        np.testing.assert_array_equal(values, eventArray.arraySlice(start, end, step))
+
+    @pytest.mark.parametrize('start, end, step',
+                             [
+                                 (None, None, 1),
+                                 (None, None, 5),
+                                 (10, 300, 3),
+                                 ],
+                             )
+    def testArraySlice(self, testIDE, start, end, step):
         """ Test for the arraySlice method. """
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            _blockSlice=(
-                lambda *a, **kw:
-                EventArray._blockSlice(eventArray, *a, **kw)
-            ),
-            _makeBlockEventsFactory=(
-                lambda *a, **kw:
-                EventArray._makeBlockEventsFactory(eventArray, *a, **kw)
-            ),
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventArray._getBlockSampleTime(i),
-            )
-        )
-        eventArray.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        np.array([[range(length)[block.id]]]))
-        )
 
-        # Run test
-        np.testing.assert_array_equal(
-            EventArray.arraySlice(eventArray),
-            [(0.00, 0.01, 0.02, 0.03), (0, 7, 14, 21)]
-        )
+        x = np.arange(*(slice(start, end, step).indices(1000)))/1000
+        expected = np.floor(np.vstack((x, x, x**2, x**0.5))*1000 + 1e-6)
+        expected[0] = np.arange(*(slice(start, end, step).indices(1000)))*1000
 
-    def testIterJitterySlice(self):
+        actual = testIDE.channels[8].getSession().arraySlice(start, end, step)
+
+        np.testing.assert_equal(actual, expected)
+
+    @pytest.mark.parametrize('jitter, step', [(0.5, 5), (0.5, 1), (0.1, 20), (0.1, 5)])
+    def testIterJitterySlice(self, testIDE, jitter, step):
         """ Test for the iterJitterySlice method. """
-        # Stub dependencies
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            _blockJitterySlice=(
-                lambda *a, **kw:
-                EventArray._blockJitterySlice(eventArray, *a, **kw)
-            ),
-            _makeBlockEventsFactory=(
-                lambda *a, **kw:
-                EventArray._makeBlockEventsFactory(eventArray, *a, **kw)
-            ),
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventArray._getBlockSampleTime(i),
-            )
-        )
-        eventArray.parent.configure_mock(
-            parseBlockByIndex=(lambda block, indices, subchannel=None:
-                               np.array([[range(length)[block.id]]]))
-        )
 
-        # Run test
-        np.testing.assert_array_equal(
-            list(EventArray.iterJitterySlice(eventArray)),
-            [(0.00, 0), (0.01, 7), (0.02, 14), (0.03, 21)]
-        )
+        targetIdx = np.arange(0, 1000, step)
 
-    def testArrayJitterySlice(self):
+        dt = np.diff(testIDE.channels[8].getSession()[:][0]).mean()
+        idx = np.array([x[0] for x in testIDE.channels[8].getSession().iterJitterySlice(None, None, step, jitter=jitter)])/dt
+
+        np.testing.assert_array_less(np.abs(targetIdx - idx).round(), step/jitter)
+
+    @pytest.mark.parametrize('jitter, step', [(0.5, 5), (0.5, 1), (0.1, 20), (0.1, 5)])
+    def testArrayJitterySlice(self, testIDE, jitter, step):
         """ Test for the arrayJitterySlice method. """
-        # Stub dependencies
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithIndex=(lambda idx, start=0, stop=None:
-                                     range(length)[idx]),
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: (0,),
-            allowMeanRemoval=True,
-            removeMean=True,
-            hasMinMeanMax=True,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            _blockJitterySlice=(
-                lambda *a, **kw:
-                EventArray._blockJitterySlice(eventArray, *a, **kw)
-            ),
-            _makeBlockEventsFactory=(
-                lambda *a, **kw:
-                EventArray._makeBlockEventsFactory(eventArray, *a, **kw)
-            ),
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, numSamples=1,
-                startTime=eventArray._getBlockSampleTime(i),
-            )
-        )
-        eventArray.parent.configure_mock(
-            parseBlockByIndex=(lambda block, indices, subchannel=None:
-                               np.array([[range(length)[block.id]]]))
-        )
 
-        # Run test
-        np.testing.assert_array_equal(
-            EventArray.arrayJitterySlice(eventArray),
-            [(0.00, 0.01, 0.02, 0.03), (0, 7, 14, 21)]
-        )
+        targetIdx = np.arange(0, 1000, step)
 
-    def testGetEventIndexBefore(self):
-        """ Test for getEventIndexBefore method. """
-        self.mockData()
+        dt = np.diff(testIDE.channels[8].getSession()[:][0]).mean()
+        idx = testIDE.channels[8].getSession().arrayJitterySlice(None, None, step, jitter=jitter)[0]/dt
 
-        self.assertEqual(self.eventArray1.getEventIndexBefore(1), 1)
-        self.assertEqual(self.eventArray1.getEventIndexBefore(-1), -1)
+        np.testing.assert_array_less(np.abs(targetIdx - idx).round(), step/jitter)
 
-    def testGetEventIndexNear(self):
-        """ Test for getEventIndexNear method. """
+    @pytest.mark.parametrize('t, expected', [(1, 0), (-1, -1), (1005, 1)])
+    def testGetEventIndexBefore(self, testIDE, t, expected):
+        """ Test for getEventIndexBefore method. """
 
-        # Stub data/methods
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray._data = [mock.Mock(startTime=0, indexRange=[0, 4],
-                                      sampleTime=1, numSamples=1)]
-        eventArray.getEventIndexBefore = lambda t: int(t)
-        eventArray.__getitem__ = (
-            lambda self, index: np.array([
-                (i, i+1)
-                for i in range(index.start or 0, index.stop,
-                               index.step or 1)
-            ])
-        )
+        assert testIDE.channels[8].getSession().getEventIndexBefore(t) == expected
 
-        # Tests
-        self.assertEqual(EventArray.getEventIndexNear(eventArray, -1), 0)
-        self.assertEqual(EventArray.getEventIndexNear(eventArray, 0), 0)
-        self.assertEqual(EventArray.getEventIndexNear(eventArray, 1), 1)
-        self.assertEqual(EventArray.getEventIndexNear(eventArray, -1), 0)
+    @pytest.mark.parametrize('t, expected', [(1, 0), (-1, 0), (1005, 1), (1e99, 1000)])
+    def testGetEventIndexNear(self, testIDE, t, expected):
+        """ Test for getEventIndexNear method. """
 
-    def testGetRangeIndices(self):
+        assert testIDE.channels[8].getSession().getEventIndexNear(t) == expected
+
+    @pytest.mark.parametrize(
+            'indices, expected, isSingleSample',
+            [
+                ((1,    1500), (1, 2),    False),
+                ((None, 1),    (0, 1),    False),
+                ((None, None), (0, 1000), False),
+                ((2,    -51),  (1, 0),    False),
+                ((2,    -51),  (0, 1),    True),
+                ((2,    None), (0, 1000), True)
+                ],
+            )
+    def testGetRangeIndices(self, testIDE, indices, expected, isSingleSample):
         """ Test for getRangeIndices method. """
-        self.mockData()
-
-        # input permutations for multi sample
-        self.assertEqual(self.eventArray1.getRangeIndices(1, 2), (2, 3))
-        self.assertEqual(self.eventArray1.getRangeIndices(None, 2), (0, 3))
-        self.assertEqual(self.eventArray1.getRangeIndices(None, None), (0, 4))
-        self.assertEqual(self.eventArray1.getRangeIndices(2, -51), (3, 0))
+        testIDE.channels[8].singleSample = isSingleSample
+        eventArray = testIDE.channels[8].getSession()
 
-        # input permutations for single sample
-        self.eventArray1.parent.singleSample = True
-        self.assertEqual(self.eventArray1.getRangeIndices(2, -51), (0, 1))
-        self.assertEqual(self.eventArray1.getRangeIndices(2, None), (0, 4))
+        assert eventArray.getRangeIndices(*indices) == expected
 
-    def testIterRange(self):
+    @pytest.mark.parametrize(
+            'args, kwargs, expectedIdx',
+            [
+                ((0, 10000, 1), {'display': False}, (None, 11, None)),
+                ((0, 99999999, 1), {'display': False}, (None, None, None)),
+                ],
+            )
+    def testIterRange(self, testIDE, args, kwargs, expectedIdx):
         """ Test for iterRange method. """
-        self.mockData()
-        self.eventArray1.iterSlice = lambda w, x, y, display: (w+1, x, y, display)
 
-        self.assertEqual(
-            self.eventArray1.iterRange(1, 4, 1, display=False),
-            self.eventArray1.iterSlice(2, 4, 1, display=False)
-        )
-
-    def testArrayRange(self):
+        np.testing.assert_array_almost_equal(
+                np.vstack(list(testIDE.channels[8].getSession().iterRange(*args, **kwargs))).T,
+                testIDE.channels[8].getSession().arraySlice(*expectedIdx),
+                )
+
+    @pytest.mark.parametrize(
+            'args, kwargs, expectedIdx',
+            [
+                ((0, 10000, 1), {'display': False}, (None, 11, None)),
+                ((0, 99999999, 1), {'display': False}, (None, None, None)),
+                ],
+            )
+    def testArrayRange(self, testIDE, args, kwargs, expectedIdx):
         """ Test for arrayRange method. """
-        self.mockData()
-        self.eventArray1.arraySlice = (
-            lambda w, x, y, display: np.array([w+1, x, y, display])
-        )
 
-        np.testing.assert_array_equal(
-            self.eventArray1.arrayRange(1, 4, 1, display=False),
-            self.eventArray1.arraySlice(2, 4, 1, display=False)
-        )
+        np.testing.assert_array_almost_equal(
+                testIDE.channels[8].getSession().arrayRange(*args, **kwargs),
+                testIDE.channels[8].getSession().arraySlice(*expectedIdx),
+                )
 
-    def testGetRange(self):
+    def testGetRange(self, testIDE):
         """ Test for getRange method. """
-        self.mockData()
-        self.eventArray1.arrayRange = lambda x, y, display: (x, y, display)
 
-        self.assertSequenceEqual(self.eventArray1.getRange(),
-                                 [None, None, False])
+        eventArray = testIDE.channels[8].getSession()
+
+        np.testing.assert_array_almost_equal(
+                eventArray.getRange(),
+                eventArray.arraySlice(),
+                )
 
     @unittest.skip('failing, poorly formed')
-    def testIterMinMeanMax(self):
+    def testIterMinMeanMax(self, eventArray1):
         """ Test for iterMinMeanMax method. """
         self.mockData()
-        self.eventArray1._data[0].minMeanMax = 1
-        self.eventArray1._data[0].blockIndex = 2
-        self.eventArray1._data[0].min = [3]
-        self.eventArray1._data[0].mean = [4]
-        self.eventArray1._data[0].max = [5]
+        eventArray1._data[0].minMeanMax = 1
+        eventArray1._data[0].blockIndex = 2
+        eventArray1._data[0].min = [3]
+        eventArray1._data[0].mean = [4]
+        eventArray1._data[0].max = [5]
 
         self.assertListEqual(
-            [x for x in self.eventArray1.iterMinMeanMax()],
+            [x for x in eventArray1.iterMinMeanMax()],
             [((0, 3), (0, 4), (0, 5))]
         )
 
-    def testArrayMinMeanMax(self):
+    def testArrayMinMeanMax(self, eventArray):
         """ Test arrayMinMeanMax. """
-        # TODO test other hasSubchannels x times combos
-
-        # Stub data/methods
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.iterMinMeanMax = mock.Mock(spec=EventArray.iterMinMeanMax)
 
-        statsStub = [((0., 3), (0., 4), (0., 5))]
-        eventArray.hasSubchannels = True
-        eventArray.iterMinMeanMax.return_value = iter(statsStub)
+        expected = np.zeros((3, 4, 10))
+        expected[:, 0, :] = np.linspace(0, 900000, 10)
+        expected[1, 1, :] = 499
+        expected[1, 2, :] = 332
+        expected[1, 3, :] = 666
+        expected[2, 1, :] = 999
+        expected[2, 2, :] = 998
+        expected[2, 3, :] = 999
 
         # Run tests
-        result = EventArray.arrayMinMeanMax(eventArray)
-        np.testing.assert_array_equal(result, np.moveaxis(statsStub, 0, -1))
+        result = eventArray.arrayMinMeanMax()
+        print(result[:, 0, :])
+        np.testing.assert_array_equal(result, expected)
 
-    def testGetMinMeanMax(self):
+    def testGetMinMeanMax(self, testIDE):
         """ Test getMinMeanMax. """
 
-        # Stub data/methods
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.arrayMinMeanMax = mock.Mock(
-            spec=EventArray.arrayMinMeanMax,
-            return_value=mock.sentinel.return_value
-        )
-        args = (mock.sentinel.startTime, mock.sentinel.endTime,
-                mock.sentinel.padding, mock.sentinel.times,
-                mock.sentinel.display, mock.sentinel.iterator)
+        eventArray = testIDE.channels[8].getSession()
+        eventArray.hasMinMeanMax = False
+
+        mins_ = [np.concatenate(((d.startTime,), d.min)) for d in eventArray._data]
+        means = [np.concatenate(((d.startTime,), d.mean)) for d in eventArray._data]
+        maxes = [np.concatenate(((d.startTime,), d.max)) for d in eventArray._data]
 
-        self.assertEqual(EventArray.getMinMeanMax(eventArray, *args),
-                         mock.sentinel.return_value)
-        self.assertEqual(eventArray.arrayMinMeanMax.call_args, (args,))
         np.testing.assert_array_equal(
-            self.dataset.channels[32].getSession().getMinMeanMax(),
-            np.array([])
-        )
+                np.stack(eventArray.getMinMeanMax()),
+                np.moveaxis(np.stack([mins_, means, maxes]), 1, -1),
+                )
 
-    def testGetRangeMinMeanMax(self):
+    def testGetRangeMinMeanMax(self, testIDE):
         """ Test for getRangeMinMeanMax method. """
-        # TODO fix
-        eventArray = mock.Mock(spec=EventArray, hasSubchannels=True)
 
-        statsStub = np.array([
-            ((3, 10), (4., 11.)),
-            ((5, 12), (6., 13.)),
-            ((7, 14), (8., 15.)),
-        ])
-        eventArray.arrayMinMeanMax = mock.Mock(spec=EventArray.arrayMinMeanMax,
-                                               return_value=statsStub)
+        eventArray = testIDE.channels[8].getSession()
+        eventArray.hasMinMeanMax = False
+
+        mmm = eventArray.getMinMeanMax()
+        _min = mmm[0][1:].min()
+        _mean = np.median(mmm[1][1:], axis=-1).mean()
+        _max = mmm[2][1:].max()
 
         np.testing.assert_array_equal(
-            EventArray.getRangeMinMeanMax(eventArray), (3, 9, 15)
-        )
+                eventArray.getRangeMinMeanMax(),
+                [_min, _mean, _max],
+                )
 
     def testGetMax(self):
         """ Test for getMax method. """
@@ -2535,288 +1485,214 @@ def testGetMin(self):
 
         # Run test
         np.testing.assert_array_equal(EventArray.getMin(eventArray), (0, 3))
-        self.assertEqual(eventArray._computeMinMeanMax.call_count, 1)
-
-    def testGetSampleTime(self):
+        assert eventArray._computeMinMeanMax.call_count == 1
+
+    @pytest.mark.parametrize(
+            'kwargs, expected',
+            [
+                ({}, 1e-3),
+                ({'idx': 1}, 1000.),
+                ({'idx': 404}, 1000.),
+                ],
+            )
+    def testGetSampleTime(self, testIDE, kwargs, expected):
         """ Test for getSampleTime method. """
-        self.mockData()
-
-        self.assertEqual(self.eventArray1.getSampleTime(), 1)
-        self.assertEqual(self.eventArray1.getSampleTime(1), 1)
-        self.assertEqual(
-            self.dataset.channels[32].getSession().getSampleTime(), -1
-        )
-        self.assertEqual(
-            self.dataset.channels[32].getSession().getSampleTime(1), -1
-        )
-
-    def testGetSampleRate(self):
+        testIDE.channels[8].sampleRate = 1000.
+        eventArray = testIDE.channels[8].getSession()
+
+        assert eventArray.getSampleTime(**kwargs) == expected
+
+    @pytest.mark.parametrize(
+            'sr, idx, expected',
+            [
+                (None, None, 1000.),
+                (100., None, 100.),
+                (None, 1, 1000.),
+                (None, 8, 1000.),
+                ]
+            )
+    def testGetSampleRate(self, testIDE, sr, idx, expected):
         """ Test for getSampleRate method. """
-        self.mockData()
-        self.eventArray1._data[0].sampleRate = 5
-        self.dataset.channels[32].sampleRate = 3
-        self.dataset.channels[32].getSession()._data = self.eventArray1._data
 
-        self.assertEqual(self.eventArray1.getSampleRate(), 5)
-        self.assertEqual(self.eventArray1.getSampleRate(0), 5)
-        self.assertEqual(self.dataset.channels[32].getSession().getSampleRate(), 3)
-        self.assertEqual(self.dataset.channels[32].getSession().getSampleRate(0), 5)
+        eventArray = testIDE.channels[8].getSession()
+        eventArray.parent.sampleRate = sr
 
-    def testGetValueAt(self):
+        assert eventArray.getSampleRate(idx) == expected
+
+    @pytest.mark.parametrize(
+            'at, raises',
+            [
+                (0, nullcontext()),
+                (10, nullcontext()),
+                (2000, nullcontext()),
+                (9500, nullcontext()),
+                (-1, pytest.raises(IndexError)),
+                ],
+            )
+    def testGetValueAt(self, testIDE, at, raises):
         """ Test for getValueAt method. """
-        # Stub dependencies
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            useAllTransforms=True,
-            __len__=lambda self: length,
-            _fullXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            getEventIndexBefore=lambda at: min(max(-1, int(at//0.01)), length-1),
-            _getBlockIndexWithIndex=lambda idx: range(length)[idx],
-            _getBlockIndexRange=lambda idx: [idx, idx+1],
-            _getBlockSampleTime=lambda idx: 0.01*idx,
-            _getBlockRollingMean=lambda blockIdx: None,
-            parent=mock.Mock(),
-            session=mock.sentinel.session,
-            noBivariates=mock.sentinel.noBivariates,
-            hasSubchannels=True,
-            __getitem__=lambda self, *a, **kw: EventArray.__getitem__(eventArray, *a, **kw),
-        )
-        eventArray._data.configure_mock(
-            __getitem__=lambda self, i: mock.Mock(
-                id=i % length, startTime=eventArray._getBlockSampleTime(i)
+        if isinstance(raises, nullcontext):
+            x = np.arange(1000)
+            vals = np.floor(np.array([x*1000, x, 1000.*(x/1000)**2, 1000*(x/1000)**0.5]))
+            expected = np.zeros([4])
+            expected[0] = at
+            for i in range(1, 4):
+                expected[i] = np.interp([at], vals[0], vals[i])
+        else:
+            expected = None
+
+        eventArray = testIDE.channels[8].getSession()
+        with raises:
+            np.testing.assert_equal(eventArray.getValueAt(at), expected)
+
+    @pytest.mark.parametrize(
+            't, expected',
+            [
+                (0, (499., 332., 666.)),
+                (10, (499., 332., 666.)),
+                (2000, (499., 332., 666.)),
+                (9500, (499., 332., 666.)),
+                (-1, (499., 332., 666.)),
+                ],
             )
-        )
-        eventArray.parent.configure_mock(
-            parseBlock=(lambda block, start=None, end=None, step=1:
-                        np.array([[range(length)[block.id]]]))
-        )
-        eventArray.parent.types.__len__ = lambda self: 1
-
-        # Run test
-        self.assertRaises(IndexError, EventArray.getValueAt, eventArray, -0.01)
-        np.testing.assert_array_equal(
-            EventArray.getValueAt(eventArray, -0.01, outOfRange=True),
-            (0.00, 0)
-        )
-        np.testing.assert_array_equal(
-            EventArray.getValueAt(eventArray, 0.00), (0.00, 0)
-        )
-        np.testing.assert_array_equal(
-            EventArray.getValueAt(eventArray, 0.01), (0.01, 7)
-        )
-        np.testing.assert_array_equal(
-            EventArray.getValueAt(eventArray, 0.02), (0.02, 14)
-        )
-        np.testing.assert_array_equal(
-            EventArray.getValueAt(eventArray, 0.03), (0.03, 21)
-        )
-        np.testing.assert_array_equal(
-            EventArray.getValueAt(eventArray, 0.04, outOfRange=True),
-            (0.03, 21)
-        )
-        self.assertRaises(IndexError, EventArray.getValueAt, eventArray, 0.04)
-
-    def testGetMeanNear(self):
+    def testGetMeanNear(self, testIDE, t, expected):
         """ Test for getMeanNear method. """
-        # Stub dependencies
-        length = 4
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            __len__=lambda self: length,
-            _comboXform=(lambda time, val, session=None, noBivariates=False:
-                        (time, tuple(7*i for i in val))),
-            _data=mock.Mock(),
-            _getBlockIndexWithTime=lambda at: min(max(-1, int((at+0.005)//0.01)), length-1),
-            _getBlockRollingMean=lambda blockIdx, force=False: (range(length)[blockIdx],),
-            hasSubchannels=True,
-        )
-        eventArray._data.configure_mock(
-            __len__=eventArray.__len__,
-        )
 
-        # Run test
-        self.assertEqual(EventArray.getMeanNear(eventArray, 0.00), (0,))
-        self.assertEqual(EventArray.getMeanNear(eventArray, 0.01), (7,))
-        self.assertEqual(EventArray.getMeanNear(eventArray, 0.02), (14,))
-        self.assertEqual(EventArray.getMeanNear(eventArray, 0.03), (21,))
+        eventArray = testIDE.channels[8].getSession()
+        assert eventArray.getMeanNear(t) == expected
 
-    def testIterResampledRange(self):
+    def testIterResampledRange(self, testIDE):
         """ Test for iterResampledRange method. """
-        
-        # Stub data/methods
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            __len__=lambda self: 100,
-        )
-        eventArray.getRangeIndices.return_value = 0, 105
-        eventArray.iterSlice.return_value = mock.sentinel.a
-        eventArray.iterJitterySlice.return_value = mock.sentinel.b
 
-        startTime = mock.sentinel.startTime
-        stopTime = mock.sentinel.stopTime
-        maxPoints = 43
+        eventArray = testIDE.channels[8].getSession()
 
-        # Run tests
-        self.assertEqual(
-            EventArray.iterResampledRange(eventArray, startTime, stopTime,
-                                          maxPoints),
-            mock.sentinel.a
-        )
-        startIdx, stopIdx, step = (
-            eventArray.iterSlice.call_args[0]
-        )
-        self.assertTrue(startIdx >= 0)
-        self.assertTrue(stopIdx <= len(eventArray))
-        self.assertTrue(len(range(startIdx, stopIdx, step)) <= maxPoints)
+        dat = eventArray.arraySlice()
 
-        self.assertEqual(
-            EventArray.iterResampledRange(eventArray, startTime, stopTime,
-                                          maxPoints, jitter=0.1,),
-            mock.sentinel.b
-        )
-        startIdx, stopIdx, step, jitter = (
-            eventArray.iterJitterySlice.call_args[0]
-        )
-        self.assertTrue(startIdx >= 0)
-        self.assertTrue(stopIdx <= len(eventArray))
-        self.assertTrue(len(range(startIdx, stopIdx, step)) <= maxPoints)
+        # Run tests
+        np.testing.assert_array_almost_equal(
+                np.stack(list(eventArray.iterResampledRange(0, 1e6, 9))).T,
+                dat[:, [0, 112, 224, 336, 448, 560, 672, 784, 896]],
+                )
 
-    def testArrayResampledRange(self):
+    def testArrayResampledRange(self, testIDE):
         """ Test for arrayResampledRange method. """
-        
-        # Stub data/methods
-        eventArray = mock.Mock(spec=EventArray)
-        eventArray.configure_mock(
-            __len__=lambda self: 100,
-        )
-        eventArray.getRangeIndices.return_value = 0, 105
-        eventArray.arraySlice.return_value = mock.sentinel.a
-        eventArray.arrayJitterySlice.return_value = mock.sentinel.b
 
-        startTime = mock.sentinel.startTime
-        stopTime = mock.sentinel.stopTime
-        maxPoints = 43
+        eventArray = testIDE.channels[8].getSession()
 
-        # Run tests
-        self.assertEqual(
-            EventArray.arrayResampledRange(eventArray, startTime, stopTime,
-                                           maxPoints),
-            mock.sentinel.a
-        )
-        startIdx, stopIdx, step = (
-            eventArray.arraySlice.call_args[0]
-        )
-        self.assertTrue(startIdx >= 0)
-        self.assertTrue(stopIdx <= len(eventArray))
-        self.assertTrue(len(range(startIdx, stopIdx, step)) <= maxPoints)
+        dat = eventArray.arraySlice()
 
-        self.assertEqual(
-            EventArray.arrayResampledRange(eventArray, startTime, stopTime,
-                                           maxPoints, jitter=0.1,),
-            mock.sentinel.b
-        )
-        startIdx, stopIdx, step, jitter = (
-            eventArray.arrayJitterySlice.call_args[0]
-        )
-        self.assertTrue(startIdx >= 0)
-        self.assertTrue(stopIdx <= len(eventArray))
-        self.assertTrue(len(range(startIdx, stopIdx, step)) <= maxPoints)
+        # Run tests
+        np.testing.assert_array_almost_equal(
+                eventArray.arrayResampledRange(0, 1e6, 9),
+                dat[:, [0, 112, 224, 336, 448, 560, 672, 784, 896]],
+                )
 
-    def testExportCSV(self):
+    @pytest.mark.skip("this doesn't actually do anything")
+    def testExportCSV(self, eventArray1):
         """ Test for exportCsv method."""
         self.mockData()
-        self.eventArray1._data[0].minMeanMax = 1
-        self.eventArray1._data[0].blockIndex = 2
-        self.eventArray1._data[0].min = [3]
-        self.eventArray1._data[0].mean = [4]
-        self.eventArray1._data[0].max = [5]
+        eventArray1._data[0].minMeanMax = 1
+        eventArray1._data[0].blockIndex = 2
+        eventArray1._data[0].min = [3]
+        eventArray1._data[0].mean = [4]
+        eventArray1._data[0].max = [5]
+
+    def testMeanRemovalSingleBlock(self, testIDE):
+        """ Testing mean removal for spans less than one block """
+
+        eventArray = testIDE.channels[8].getSession()
+        eventArray.removeMean = False
+
+        unremovedData = eventArray[:]
+
+        eventArray.rollingMeanSpan = 1
+        eventArray.removeMean = True
+
+        # for d in eventArray._data:
+        #     unremovedData[1:, slice(*d.indexRange)] -= d.mean[:, np.newaxis]
+        unremovedData[1:] -= unremovedData[1:].mean(axis=1, keepdims=True)
+
+        removedData = eventArray[:]
+
+        np.testing.assert_array_equal(removedData, unremovedData)
+
+    def testMeanRemovalFullFile(self, testIDE):
+        """ Testing mean removal spanning the full file """
+
+        eventArray = testIDE.channels[8].getSession()
+        eventArray.removeMean = False
+
+        unremovedData = eventArray[:]
+        unremovedData[1:] -= unremovedData[1:].mean(axis=1)[:, np.newaxis]
+
+        eventArray.rollingMeanSpan = -1
+        eventArray.removeMean = True
 
+        removedData = eventArray[:]
+
+        np.testing.assert_array_equal(removedData, unremovedData)
 
 #===============================================================================
 #
 #===============================================================================
 
-class PlotTestCase(unittest.TestCase):
+class TestPlot:
     """ Unit test for the Plot class. """
-    
-    def setUp(self):
-        self.dataset = importer.importFile('./testing/SSX70065.IDE')
-        self.dataset.addSession(0, 1, 2)
-        self.dataset.addSensor(0)
-        
-        self.fakeParser = GenericObject()
-        self.fakeParser.types = [0]
-        self.fakeParser.format = []
-        
-        self.channel1 = Channel(
-            self.dataset, channelId=0, name="channel1", parser=self.fakeParser,
-            displayRange=[0])
-        self.eventList1 = EventList(self.channel1, session=self.dataset.sessions[0])
-        
-        self.channel1.addSubChannel(subchannelId=0)
-        
-        self.subChannel1 = SubChannel(self.channel1, 0)
-        
-        self.plot1 =  Plot(self.eventList1, 0, name="Plot1")
-    
-    
-    def tearDown(self):
-        self.dataset.close()
-        self.dataset = None
-        
-        self.fakeParser = None
-        self.channel1 = None
-        self.eventList1 = None
-        self.subChannel1 = None
-        self.plot1 = None
-    
-    
-    def mockData(self):
-        """ mock up a bit of fake data so I don't have to worry that external
-            classes are working during testing.
-        """
-        fakeData = GenericObject()
-        fakeData.startTime = 0
-        fakeData.indexRange = [0, 3]
-        fakeData.sampleTime = 1
-        fakeData.numSamples = 1
-        self.eventList1._data = [fakeData]
-    
-    
-    def testConstructor(self):
+
+    @pytest.fixture
+    def channel32(self, SSX70065IDE):
+        return SSX70065IDE.channels[32]
+
+    @pytest.fixture
+    def eventArray(self, channel32):
+        return channel32.getSession()
+
+    @pytest.fixture
+    def plot1(self, eventArray):
+        return Plot(eventArray, 0, name='Plot1')
+
+    def testConstructor(self, plot1, eventArray):
         """ Test for the constructor. """
-        self.assertEqual(self.plot1.source, self.eventList1)
-        self.assertEqual(self.plot1.id, 0)
-        self.assertEqual(self.plot1.session, self.eventList1.session)
-        self.assertEqual(self.plot1.dataset, self.eventList1.dataset)
-        self.assertEqual(self.plot1.name, "Plot1")
-        self.assertEqual(self.plot1.units, self.eventList1.units)
-        self.assertEqual(self.plot1.attributes, None)
-        
-        
-    def testGetEventIndexBefore(self):
+
+        plotParams = (
+            plot1.source,
+            plot1.id,
+            plot1.session,
+            plot1.dataset,
+            plot1.name,
+            plot1.units,
+            plot1.attributes,
+            )
+
+        targetParams = (
+            eventArray,
+            0,
+            eventArray.session,
+            eventArray.dataset,
+            'Plot1',
+            eventArray.units,
+            None,
+            )
+
+        assert plotParams == targetParams
+
+    @pytest.mark.parametrize('t', [0, 1, 10, 100, 1000, 10000, 100000])
+    def testGetEventIndexBefore(self, eventArray, plot1, t):
         """ Test for getEventIndexBefore method. """
-        self.mockData()
-        
-        self.assertEqual(
-            self.plot1.getEventIndexBefore(0), 
-            self.eventList1.getEventIndexBefore(0))
-        
-        
+
+        assert plot1.getEventIndexBefore(t) == eventArray.getEventIndexBefore(t)
+
+    @pytest.mark.skip('not implemented')
     def testGetRange(self):
         """ Test for getRange method. """
-        print('gotta get to this')#self.plot1.getRange(0, 1))
-        # TODO
+        pass
 
 
 #===============================================================================
-#--- Data test cases 
+#--- Data test cases
 #===============================================================================
 
-class DataTestCase(unittest.TestCase):
+class TestData:
     """ Basic tests of data fidelity against older, "known good" CSV exports.
         Exports were generated using the library as of the release of 1.8.0.
 
@@ -2824,174 +1700,153 @@ class DataTestCase(unittest.TestCase):
         errors. 
     """
 
-    def setUp(self):
-        self.dataset = importer.importFile('./testing/SSX_Data.IDE')
-        self.delta = 0.0015
+    @pytest.fixture
+    def dataset(self, SSX_DataIDE):
+        return SSX_DataIDE
+
+    @pytest.fixture
+    def channel8(self, dataset):
+        return dataset.channels[8]
 
+    @pytest.fixture
+    def accelArray(self, channel8):
+        return channel8.getSession()
 
-    def testCalibratedExport(self):
+    @pytest.fixture
+    def out(self):
+        return StringIO()
+
+    @staticmethod
+    def generateCsvArray(filestream, eventArray, **kwargs):
+        eventArray.exportCsv(filestream, **kwargs)
+        filestream.seek(0)
+        return np.genfromtxt(filestream, delimiter=', ').T
+
+    def testCalibratedExport(self, accelArray, out):
         """ Test regular export, with bivariate polynomials applied.
         """
-        out = StringIO()
-        accel = self.dataset.channels[8].getSession()
-
-        accel.exportCsv(out)
-        out.seek(0)
-        
-        with open('./testing/SSX_Data_Ch8_Calibrated.csv', 'rb') as f:
-            for new, old in zip(out, f):
-#                 self.assertEqual(old.strip(), new.strip())
-                for a,b in zip(eval(new),eval(old)):
-                    self.assertAlmostEqual(a, b, delta=self.delta, 
-                                           msg="Output differs: %r != %r" %
-                                           (old,new))
-
-    
-    def testUncalibratedExport(self):
-        """ Test export with no per-channel polynomials.
-        """
-        self.dataset.channels[8][0].setTransform(None)
-        self.dataset.channels[8][1].setTransform(None)
-        self.dataset.channels[8][2].setTransform(None)
-
-        out = StringIO()
-        accel = self.dataset.channels[8].getSession()
-
-        accel.exportCsv(out)
-        out.seek(0)
-        
-        with open('./testing/SSX_Data_Ch8_NoCalibration.csv', 'rb') as f:
-            for new, old in zip(out, f):
-#                 self.assertEqual(old.strip(), new.strip())
-                for a,b in zip(eval(new),eval(old)):
-                    self.assertAlmostEqual(a, b, delta=self.delta, 
-                                           msg="Output differs: %r != %r" %
-                                           (old,new))
-
-
-    def testNoBivariates(self):
+
+        new = self.generateCsvArray(out, accelArray)
+        old = accelArray.__getitem__(slice(None), display=True)
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_equal(new[1:], old[1:])
+
+    def testUncalibratedExport(self, accelArray, out):
+        """ Test export with no per-channel polynomials."""
+        new = self.generateCsvArray(out, accelArray)
+        old = accelArray.__getitem__(slice(None), display=True)
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_equal(new[1:], old[1:])
+
+    def testNoBivariates(self, accelArray, out):
         """ Test export with bivariate polynomial references disabled (values
             only offset, not temperature-corrected).
         """
-        out = StringIO()
-        accel = self.dataset.channels[8].getSession()
-        accel.noBivariates = True
-
-        accel.exportCsv(out)
-        out.seek(0)
-        
-        with open('./testing/SSX_Data_Ch8_NoBivariates.csv', 'rb') as f:
-            for new, old in zip(out, f):
-#                 self.assertEqual(old.strip(), new.strip())
-                for a,b in zip(eval(new),eval(old)):
-                    self.assertAlmostEqual(a, b, delta=self.delta, 
-                                           msg="Output differs: %r != %r" %
-                                           (old,new))
-
-
-    def testRollingMeanRemoval(self):
+
+        accelArray.noBivariates = True
+
+        new = self.generateCsvArray(out, accelArray)
+        old = accelArray.__getitem__(slice(None), display=True)
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_equal(new[1:], old[1:])
+
+    def testRollingMeanRemoval(self, accelArray, out):
         """ Test regular export, with the rolling mean removed from the data.
         """
-        self.dataset.channels[8][0].setTransform(None)
-        self.dataset.channels[8][1].setTransform(None)
-        self.dataset.channels[8][2].setTransform(None)
-        
-        out = StringIO()
-        accel = self.dataset.channels[8].getSession()
-        accel.removeMean = True
-        accel.rollingMeanSpan = 5000000
-
-        accel.exportCsv(out)
-        out.seek(0)
-        
-        with open('./testing/SSX_Data_Ch8_RollingMean_NoCal.csv', 'rb') as f:
-            for new, old in zip(out, f):
-                for a,b in zip(eval(new),eval(old)):
-                    self.assertAlmostEqual(a, b, delta=self.delta, 
-                                           msg="Output differs: %r != %r" %
-                                           (old,new))
-
-    
-    def testTotalMeanRemoval(self):
+
+        removeMean = True
+        meanSpan = 5000000
+
+        accelArray.removeMean = removeMean
+        accelArray.rollingMeanSpan = meanSpan
+
+        new = self.generateCsvArray(out, accelArray, removeMean=removeMean, meanSpan=meanSpan)
+        old = accelArray.__getitem__(slice(None), display=True)
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_equal(new[1:], old[1:])
+
+    def testTotalMeanRemoval(self, accelArray, out):
         """ Test regular export, calibrated, with the total mean removed from
             the data.
         """
-        self.dataset.channels[8][0].setTransform(None)
-        self.dataset.channels[8][1].setTransform(None)
-        self.dataset.channels[8][2].setTransform(None)
-
-        out = StringIO()
-        accel = self.dataset.channels[8].getSession()
-        accel.removeMean = True
-        accel.rollingMeanSpan = -1
-
-        accel.exportCsv(out)
-        out.seek(0)
-        
-        with open('./testing/SSX_Data_Ch8_TotalMean_NoCal.csv', 'rb') as f:
-            for new, old in zip(out, f):
-                for a,b in zip(eval(new),eval(old)):
-                    self.assertAlmostEqual(a, b, delta=self.delta, 
-                                           msg="Output differs: %r != %r" %
-                                           (old,new))
-    
-
-    def testCalibratedRollingMeanRemoval(self):
+
+        removeMean = True
+        meanSpan = -1
+
+        accelArray.removeMean = removeMean
+        accelArray.rollingMeanSpan = meanSpan
+
+        new = self.generateCsvArray(out, accelArray, removeMean=removeMean, meanSpan=meanSpan)
+        old = accelArray.__getitem__(slice(None), display=True)
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_equal(new[1:], old[1:])
+
+    def testCalibratedRollingMeanRemoval(self, accelArray, out):
         """ Test regular export, calibrated, with the rolling mean removed from
             the data.
         """
-        out = StringIO()
-        accel = self.dataset.channels[8].getSession()
-        accel.removeMean = True
-        accel.rollingMeanSpan = 5000000
-
-        accel.exportCsv(out)
-        out.seek(0)
-        
-        with open('./testing/SSX_Data_Ch8_RollingMean.csv', 'rb') as f:
-            for new, old in zip(out, f):
-                for a,b in zip(eval(new),eval(old)):
-                    self.assertAlmostEqual(a, b, delta=self.delta, 
-                                           msg="Output differs: %r != %r" %
-                                           (old,new))
-
-    
-    def testCalibratedTotalMeanRemoval(self):
+
+        removeMean = True
+        meanSpan = 5000000
+
+        accelArray.removeMean = removeMean
+        accelArray.rollingMeanSpan = meanSpan
+
+        new = self.generateCsvArray(out, accelArray, removeMean=removeMean, meanSpan=meanSpan)
+        old = accelArray.__getitem__(slice(None), display=True)
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_equal(new[1:], old[1:])
+
+    def testCalibratedTotalMeanRemoval(self, accelArray, out):
         """ Test regular export, with the total mean removed from the data.
         """
-        out = StringIO()
-        accel = self.dataset.channels[8].getSession()
-        accel.removeMean = True
-        accel.rollingMeanSpan = -1
-
-        accel.exportCsv(out)
-        out.seek(0)
-        
-        with open('./testing/SSX_Data_Ch8_TotalMean.csv', 'rb') as f:
-            for new, old in zip(out, f):
-                for a,b in zip(eval(new),eval(old)):
-                    self.assertAlmostEqual(a, b, delta=self.delta, 
-                                           msg="Output differs: %r != %r" %
-                                           (old,new))
-    
 
-#===============================================================================
+        removeMean = True
+        meanSpan = -1
+
+        accelArray.removeMean = removeMean
+        accelArray.rollingMeanSpan = meanSpan
+
+        new = self.generateCsvArray(out, accelArray, removeMean=removeMean, meanSpan=meanSpan)
+        old = accelArray.__getitem__(slice(None), display=True)
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_equal(new[1:], old[1:])
+
+    def testTimestamps(self, accelArray, out):
+        """ Tests the timestamps, which are the same on all exports
+        """
+
+        new = self.generateCsvArray(out, accelArray)
+        old = accelArray[:]
+        old = np.round(1e6*old)/1e6
+
+        np.testing.assert_allclose(new[0], old[0], rtol=1e-10)
+
+
+# ===============================================================================
 # 
-#===============================================================================
+# ==============================================================================
 
 DEFAULTS = {
     "sensors": {
         0x00: {"name": "832M1 Accelerometer"},
         0x01: {"name": "MPL3115 Temperature/Pressure"}
     },
-    
+
     "channels": {
             0x00: {"name": "Accelerometer XYZ",
-    #                 "parser": struct.Struct("<HHH"), 
+    #                 "parser": struct.Struct("<HHH"),
     #                 "transform": 0, #calibration.AccelTransform(),
                     "parser": parsers.AccelerometerParser(),
                     "transform": AccelTransform(-500,500),
-                    "subchannels":{0: {"name": "Accelerometer Z", 
+                    "subchannels":{0: {"name": "Accelerometer Z",
                                        "axisName": "Z",
                                        "units":('Acceleration','g'),
                                        "displayRange": (-100.0,100.0),
@@ -2999,7 +1854,7 @@ def testCalibratedTotalMeanRemoval(self):
                                        "warningId": [0],
                                        "sensorId": 0,
                                      },
-                                   1: {"name": "Accelerometer Y", 
+                                   1: {"name": "Accelerometer Y",
                                        "axisName": "Y",
                                        "units":('Acceleration','g'),
                                        "displayRange": (-100.0,100.0),
@@ -3007,7 +1862,7 @@ def testCalibratedTotalMeanRemoval(self):
                                        "warningId": [0],
                                        "sensorId": 0,
                                        },
-                                   2: {"name": "Accelerometer X", 
+                                   2: {"name": "Accelerometer X",
                                        "axisName": "X",
                                        "units":('Acceleration','g'),
                                        "displayRange": (-100.0,100.0),
@@ -3019,12 +1874,12 @@ def testCalibratedTotalMeanRemoval(self):
                    },
             0x01: {"name": "Pressure/Temperature",
                    "parser": parsers.MPL3115PressureTempParser(),
-                   "subchannels": {0: {"name": "Pressure", 
+                   "subchannels": {0: {"name": "Pressure",
                                        "units":('Pressure','Pa'),
                                        "displayRange": (0.0,120000.0),
                                       "sensorId": 1,
                                        },
-                                   1: {"name": "Temperature", 
+                                   1: {"name": "Temperature",
                                        "units":('Temperature','\xb0C'),
                                        "displayRange": (-40.0,80.0),
                                       "sensorId": 1,
@@ -3034,7 +1889,7 @@ def testCalibratedTotalMeanRemoval(self):
                    "singleSample": True,
                    },
     },
-    
+
     "warnings": [{"warningId": 0,
                    "channelId": 1,
                    "subchannelId": 1,
@@ -3042,4 +1897,3 @@ def testCalibratedTotalMeanRemoval(self):
                    "high": 60.0
                    }]
 }
-        
\ No newline at end of file
diff --git a/testing/test_import.py b/testing/test_import.py
new file mode 100644
index 00000000..3c8dfe54
--- /dev/null
+++ b/testing/test_import.py
@@ -0,0 +1,116 @@
+"""
+Tests for special features of the importing functions.
+"""
+import os.path
+
+import pytest  # type: ignore
+
+from idelib import importer
+from testing.file_streams import makeStreamLike
+
+
+# ==============================================================================
+#
+# ==============================================================================
+
+def _functionlike(cls):
+    """ Decorator for a singleton callable class. """
+    return cls()
+
+
+@_functionlike
+class NullUpdater:
+    """ A progress updater stand-in that does nothing. """
+    cancelled = False
+    paused = False
+
+    def __call__(self, *args, **kwargs):
+        if kwargs.get('error', None) is not None:
+            raise kwargs['error']
+
+
+# ==============================================================================
+#
+# ==============================================================================
+
+class TestImportRange:
+
+    def test_import_updater(self):
+        """
+        Test importing with an updater
+        """
+        doc = importer.openFile(makeStreamLike("./testing/SSX66115.IDE"))
+        importer.readData(doc, updater=NullUpdater)
+
+
+    @classmethod
+    def setup_class(cls):
+        cls.dataset = importer.openFile(makeStreamLike("./testing/SSX66115.IDE"))
+        importer.readData(cls.dataset)
+
+        cls.extractionStart = cls.dataset.sessions[0].lastTime * .33
+        cls.extractionEnd = cls.extractionStart * 2
+
+        cls.dataset.close()
+
+
+    def test_import_startTime(self):
+        """
+        Test importing a range with only start time specified.
+        """
+        doc = importer.openFile(makeStreamLike("./testing/SSX66115.IDE"))
+        importer.readData(doc,
+                          startTime=self.extractionStart,
+                          endTime=None,
+                          channels=None)
+
+        for channel in doc.channels.values():
+            data = channel.getSession()
+            assert data[0][0] <= self.extractionStart
+
+
+    def test_import_endTime(self):
+        """
+        Test importing a range with only end time specified.
+        """
+        doc = importer.openFile(makeStreamLike("./testing/SSX66115.IDE"))
+        importer.readData(doc,
+                          startTime=0,
+                          endTime=self.extractionEnd,
+                          channels=None)
+
+        for channel in doc.channels.values():
+            data = channel.getSession()
+            assert data[-1][0] >= self.extractionEnd
+
+
+    def test_import_startTime_endTime(self):
+        """
+        Test importing a range with both start and end times specified.
+        """
+        doc = importer.openFile(makeStreamLike("./testing/SSX66115.IDE"))
+        importer.readData(doc,
+                          startTime=self.extractionStart,
+                          endTime=self.extractionEnd,
+                          channels=None)
+
+        for channel in doc.channels.values():
+            data = channel.getSession()
+            assert data[0][0] <= self.extractionStart
+            assert data[-1][0] >= self.extractionEnd
+
+
+    def test_import_channels(self):
+        """
+        Test that only data from specified channels is imported.
+        """
+        doc = importer.openFile(makeStreamLike("./testing/SSX66115.IDE"))
+        importer.readData(doc,
+                          channels=[8, 36])
+
+        assert len(doc.channels[8].getSession()) > 0, \
+            "Imported file did not contain data for specified channel"
+        assert len(doc.channels[36].getSession()) > 0, \
+            "Imported file did not contain data for specified channel"
+        assert len(doc.channels[32].getSession()) == 0, \
+            "Imported file contains data from excluded channel"
diff --git a/testing/test_new_dataset.py b/testing/test_new_dataset.py
index d8c59b56..979471ca 100644
--- a/testing/test_new_dataset.py
+++ b/testing/test_new_dataset.py
@@ -100,7 +100,7 @@ def test_cascading_repr(self, cascading_1):
 
 
 # ==============================================================================
-# EventList Tests
+# EventArray Tests
 # ==============================================================================
 
 
@@ -109,6 +109,11 @@ def channel_8_eventarray(load_test_dataset):
     yield load_test_dataset.channels[8].getSession()
 
 
+@pytest.fixture()
+def channel_8_0_eventarray(load_test_dataset):
+    yield load_test_dataset.channels[8].subchannels[0].getSession()
+
+
 @pytest.fixture()
 def channel_36_eventarray(load_test_dataset):
     yield load_test_dataset.channels[36].getSession()
@@ -149,3 +154,25 @@ def test_array_values(self, channel_8_eventarray, start, end, step, expected):
     def test_array_jittery_values(self, channel_8_eventarray, start, end, step, expected):
         values = channel_8_eventarray.arrayJitterySlice(start=start, end=end, step=step)[1, :]
         np.testing.assert_equal(values, expected)
+
+    @pytest.mark.parametrize('idx', [0, 500, 999, -1])
+    def test_getitem_channel(self, channel_8_eventarray, idx):
+
+        if idx < 0:
+            x = 1000 + idx
+        else:
+            x = idx
+        expected = np.floor(np.array([x*1000, x, 1000*(x/1000)**2, 1000*(x/1000)**0.5]))
+
+        np.testing.assert_equal(channel_8_eventarray[idx], expected)
+
+    @pytest.mark.parametrize('idx', [0, 500, 999, -1])
+    def test_getitem_subchannel(self, channel_8_0_eventarray, idx):
+
+        if idx < 0:
+            x = 1000 + idx
+        else:
+            x = idx
+        expected = np.floor(np.array([x*1000, x]))
+
+        np.testing.assert_equal(channel_8_0_eventarray[idx], expected)
diff --git a/testing/test_parsers.py b/testing/test_parsers.py
index fc999622..9d5f4f21 100644
--- a/testing/test_parsers.py
+++ b/testing/test_parsers.py
@@ -6,20 +6,20 @@
 import numpy as np  # type: ignore
 
 from idelib.importer import openFile
-from idelib.parsers import ChannelDataArrayBlockParser, ChannelDataArrayBlock
+from idelib.parsers import ChannelDataBlockParser, ChannelDataBlock
 
 from .file_streams import makeStreamLike
 
-class TestChannelDataArrayBlockParser(unittest.TestCase):
-    """ Tests for ChannelDataArrayBlockParser """
+class TestChannelDataBlockParser(unittest.TestCase):
+    """ Tests for ChannelDataBlockParser """
 
     def setUp(self):
         self.doc = openFile(makeStreamLike('./testing/SSX70065.IDE'))
         chDatBlockEl = self.doc.ebmldoc.children[161]
         self.element = [x for x in self.doc.ebmldoc.value if type(x) is chDatBlockEl and x[0].value == 32][0]
-        self.block = ChannelDataArrayBlock(self.element)
+        self.block = ChannelDataBlock(self.element)
 
-        self.parser = ChannelDataArrayBlockParser(self.doc)
+        self.parser = ChannelDataBlockParser(self.doc)
 
     def testConstructor(self):
         self.assertIsNone(self.parser.children)
@@ -27,8 +27,8 @@ def testConstructor(self):
         self.assertFalse(self.parser.isHeader)
 
         self.assertIs(self.doc,                          self.parser.doc)
-        self.assertIs(ChannelDataArrayBlock,             self.parser.product)
-        self.assertEqual(ChannelDataArrayBlock.__name__, self.parser.elementName)
+        self.assertIs(ChannelDataBlock,             self.parser.product)
+        self.assertEqual(ChannelDataBlock.__name__, self.parser.elementName)
         self.assertEqual(10**6 / 2**15,                  self.parser.timeScalar)
         self.assertEqual({},                             self.parser.timestampOffset)
         self.assertEqual({},                             self.parser.lastStamp)
@@ -36,7 +36,7 @@ def testConstructor(self):
         self.assertEqual({},                             self.parser.timeModulus, )
 
     def testParse(self):
-        """ Test parsing for ChannelDataArrayBlocks, which is basically the same """
+        """ Test parsing for ChannelDataBlocks, which is basically the same """
         # Straightforward case
         ch = self.doc.channels[self.block.channel]
         self.assertEqual(self.parser.parse(self.element), self.block.getNumSamples(ch.parser) * len(ch.children))
@@ -53,8 +53,8 @@ def testMakesData(self):
         self.assertTrue(self.parser.makesData())
 
 
-class TestChannelDataArrayBlock(unittest.TestCase):
-    """ Tests for ChannelDataArrayBlock """
+class TestChannelDataBlock(unittest.TestCase):
+    """ Tests for ChannelDataBlock """
     # NOTE: payload and parse* definitely need to be re-written, and tested
     # with new stuff, but (most of) the other stuff should be fine as-is
 
@@ -66,7 +66,7 @@ def chFilter(x):
             return type(x) is self.ebmldoc.children[161] and x[0].value == 32
 
         self.element = [x for x in self.ebmldoc.value if chFilter(x)][0]
-        self.block = ChannelDataArrayBlock(self.element)
+        self.block = ChannelDataBlock(self.element)
 
     def testConstructor(self):
         self.assertIs(self.block.element, self.element)
@@ -92,79 +92,23 @@ def testConstructor(self):
 
     def testRepr(self):
         self.assertEqual(repr(self.block),
-                         '<ChannelDataArrayBlock Channel: 32>')
+                         '<ChannelDataBlock Channel: 32>')
 
     def testPayload(self):
         np.testing.assert_array_equal(self.block.payload,
                                       np.asarray(self.block._payloadEl.value))
 
-    def testParseMinMeanMax(self):
-        parser = self.doc.channels[32].parser
-
-        oldVal = super(self.block.__class__, self.block).parseMinMeanMax(parser)
-        newVal = self.block.parseMinMeanMax(parser)
-        np.testing.assert_array_equal(oldVal, newVal)
-
     def testGetHeader(self):
         self.assertEqual(self.block.getHeader(), (211, 32))
 
     def testToNpTypestr(self):
-        for stype, nptype in ChannelDataArrayBlock.TO_NP_TYPESTR.items():
+        for stype, nptype in ChannelDataBlock.TO_NP_TYPESTR.items():
             for endian in ('<', '>'):
                 assert (
                     struct.calcsize(endian+stype)
                     == np.dtype(endian+nptype).itemsize
                 )
 
-    def testParseWith(self):
-        parser = self.doc.channels[32].parser
-
-        # Plain case
-        blockOut = self.block.parseWith(parser)
-        oldOut = [x for x in super(self.block.__class__, self.block).parseWith(parser)]
-        oldOut = np.array(oldOut).T
-        np.testing.assert_array_equal(oldOut, blockOut)
-
-        # different start
-        blockOut = self.block.parseWith(parser, start=5)
-        oldOut = [x for x in super(self.block.__class__, self.block).parseWith(parser, start=5)]
-        oldOut = np.array(oldOut).T
-        np.testing.assert_array_equal(oldOut, blockOut)
-
-        # different end
-        blockOut = self.block.parseWith(parser, end=100)
-        oldOut = [x for x in super(self.block.__class__, self.block).parseWith(parser, end=100)]
-        oldOut = np.array(oldOut).T
-        np.testing.assert_array_equal(oldOut, blockOut)
-
-        # different step
-        blockOut = self.block.parseWith(parser, step=10)
-        oldOut = [x for x in super(self.block.__class__, self.block).parseWith(parser, step=10)]
-        oldOut = np.array(oldOut).T
-        np.testing.assert_array_equal(oldOut, blockOut)
-
-        # fully different params
-        blockOut = self.block.parseWith(parser, start=10, end=100, step=10)
-        oldOut = [x for x in super(self.block.__class__, self.block).parseWith(parser, start= 10, end=100, step=10)]
-        oldOut = np.array(oldOut).T
-        np.testing.assert_array_equal(oldOut, blockOut)
-
-        # specific subchannel
-        blockOut = self.block.parseWith(parser, subchannel=1)
-        oldOut = [x for x in super(self.block.__class__, self.block).parseWith(parser, subchannel=1)]
-        oldOut = np.array(oldOut)[np.newaxis]
-        np.testing.assert_array_equal(oldOut, blockOut)
-
-    def testParseByIndexWith(self):
-        parser = self.doc.channels[32].parser
-        dtype_desc = [('ch'+str(i), parser.format[0:2])
-                      for i in range(len(parser.format[1:]))]
-        self.block.getNumSamples(parser)
-
-        blockOut = self.block.parseByIndexWith(parser, list(range(20, 100)))
-        oldOut = [x for x in super(self.block.__class__, self.block).parseByIndexWith(parser, list(range(20, 100)))]
-        np.testing.assert_array_equal(oldOut, tuplify(blockOut))
-
     def testGetNumSamples(self):
         self.assertEqual(self.block.getNumSamples(self.doc.channels[32].parser), 1357)
 
diff --git a/testing/test_transforms.py b/testing/test_transforms.py
new file mode 100644
index 00000000..3b95fd15
--- /dev/null
+++ b/testing/test_transforms.py
@@ -0,0 +1,243 @@
+from collections import namedtuple
+
+import pytest
+import numpy as np
+
+from idelib import importer
+from idelib.dataset import Dataset
+from idelib.transforms import Transform, AccelTransform, Univariate, Bivariate
+from .file_streams import makeStreamLike
+
+
+@pytest.fixture()
+def genericTransform():
+    yield Transform()
+
+
+@pytest.fixture()
+def badTransform():
+    tf = Transform()
+    tf._function = None
+    yield tf
+
+
+@pytest.fixture(
+        params=[
+            ((0,), 0),
+            ((1,), 0),
+            ((3, 1), 0),
+            ((3, 1), 5),
+            ],
+        ids=[
+            'zero-trivial',
+            'constant',
+            'simple-linear',
+            'referenced-linear',
+            ],
+        )
+def univariate(request):
+    coeffs, reference = request.param
+    tf = Univariate(coeffs, reference=reference)
+    return tf
+
+
+@pytest.fixture()
+def ssx66115():
+    testDataset = importer.openFile(makeStreamLike('./testing/SSX66115.IDE'))
+    importer.readData(testDataset)
+    yield testDataset
+    testDataset.close()
+
+
+@pytest.fixture(params=[(1,), (2,), (3,)], ids=['transform {}'.format(i) for i in [1, 2, 3]])
+def bivariate(request, ssx66115):
+    (tId,) = request.param
+    return ssx66115.transforms[tId]
+
+
+class TestTransform:
+
+    def testInit(self, genericTransform):
+        assert genericTransform._source == "lambda x: x"
+
+    def testInitAttrs(self):
+        tf = Transform(attributes={'attr1': 10})
+        assert tf.attributes['attr1'] == 10
+
+    def testCopy(self, genericTransform):
+        tfCopy = genericTransform.copy()
+        assert genericTransform == tfCopy
+
+    def testStr(self, genericTransform):
+        assert str(genericTransform) == 'x'
+
+    def testRepr(self, genericTransform):
+        assert repr(genericTransform) == '<Transform: (x)>'
+
+    def testHash(self, genericTransform):
+        assert hash(genericTransform) == hash('x')
+
+    def testEq(self, genericTransform):
+        assert genericTransform == Transform()
+
+    @pytest.mark.parametrize('other', [(5,), (AccelTransform(),)])
+    def testNeq(self, other, genericTransform):
+        assert genericTransform != other
+
+    def testFunction(self, genericTransform):
+        x = np.arange(5)
+        np.testing.assert_array_equal(genericTransform.function(x), x)
+
+    @pytest.mark.parametrize('session, offset',
+                             [(None, 0),
+                              (namedtuple('fakeSession', 'startTime')(startTime=1), 1),
+                              ])
+    def testCall(self, session, offset, genericTransform):
+        timestamp = 0
+        value = 10
+
+        assert genericTransform(timestamp, value, session=session) == (timestamp + offset, value)
+
+    @pytest.mark.parametrize('transformType,     expected',
+                           [('genericTransform', True),
+                            ('badTransform',     False)])
+    def testIsValid(self, transformType, expected, request):
+        tf = request.getfixturevalue(transformType)
+        assert tf.isValid() == expected
+
+    def testUseMean(self, genericTransform):
+        with pytest.warns(UserWarning):
+            assert genericTransform.useMean is True
+
+    def testAddWatcher(self):
+        """ Check that the weakset of watchers is updated properly """
+        tf = Transform()
+        tf.addWatcher(tf)
+
+        assert tf in list(tf._watchers)
+
+
+class TestAccelTransform:
+
+    @pytest.mark.parametrize('amin, amax, calId',
+                             [
+                                 (-100., 100., 0),
+                                 (0., 10., 1),
+                                 (0., 1e-10, 200)
+                             ])
+    def testInit(self, amin, amax, calId):
+        tf = AccelTransform(amin=amin, amax=amax, calId=calId)
+
+        assert tf.id == calId
+        assert tf.range == (amin, amax)
+        assert tf._str == '(x / 32767.0) * {:.3f}'.format(amax)
+        assert tf._source == 'lambda x: x * {:4f}'.format(amax/32767.0)
+
+    @pytest.mark.parametrize('amin, amax, calId',
+                             [
+                                 (-100., 100., 0),
+                                 (0., 10., 1),
+                                 (0., 1e-10, 200)
+                             ])
+    def testCopy(self, amin, amax, calId):
+        tf = AccelTransform(amin=amin, amax=amax, calId=calId)
+
+        assert tf == tf.copy()
+
+
+class TestUnivariate:
+
+    def testInplace(self, univariate):
+        values = np.arange(5)
+        out = np.zeros(5)
+        univariate.inplace(values, out=out)
+
+        if len(univariate.coefficients) == 1:
+            a = univariate.coefficients[0]
+            np.testing.assert_array_equal(out, np.ones(5)*a)
+        elif len(univariate.coefficients) == 2:
+            a, b = univariate.coefficients
+            ref = univariate.references[0]
+            np.testing.assert_array_equal(out, a*(values - ref) + b)
+        else:
+            raise
+
+
+class TestBivariate:
+
+    @pytest.mark.parametrize('useTimestamp, noBivariates, useMean',
+                           [(True,          True,         True),
+                            (True,          True,         False),
+                            (True,          False,        True),
+                            (True,          False,        False),
+                            (False,         True,         True),
+                            (False,         True,         False),
+                            (False,         False,        True),
+                            (False,         False,        False),
+                            ])
+    def testInplace(self, useTimestamp, noBivariates, useMean, bivariate, ssx66115):
+        """
+            Test inplace math for bivariate polynomials.  This one's more complex.
+
+            Parameters are for all combinations of using timestamps, using the
+            mean, and using bivariate calibration.
+
+            The formula is z = a*(x - xref)*(y - yref) + b*(x - xref) + c*(y - yref) + d
+
+            If noBivariates is `True`, then y must be 0.
+
+            If useMean is `True` or useTimestamp is `False`, y is the mean of
+            the secondary channel.
+
+            Otherwise, y is the value of the other channel at the timestamps.
+
+        """
+        eventList = ssx66115.channels[bivariate.channelId][bivariate.subchannelId].getSession()
+        if useTimestamp:
+            timestamps = np.linspace(
+                    ssx66115.channels[8].getSession()._blockTimes[0],
+                    ssx66115.channels[8].getSession()._blockTimes[-1],
+                    5,
+                    dtype=np.uint64,
+                    )
+            y = np.fromiter(
+                    (eventList[eventList.getEventIndexNear(t)][1] for t in timestamps),
+                    dtype=np.float64,
+                    )
+        else:
+            timestamps = None
+            y = eventList.getMean()
+
+        if useMean:
+            y = eventList.getMean()
+
+        if noBivariates:
+            y = 0
+
+        x = np.arange(5)
+        xref, yref = bivariate.references
+        a, b, c, d = bivariate.coefficients
+
+        bivariate.useMean = useMean
+        vals = bivariate.inplace(
+                np.arange(5),
+                y=y,
+                timestamp=timestamps,
+                noBivariates=noBivariates,
+                )
+
+        expected = a*(x - xref)*(y - yref) + b*(x - xref) + c*(y - yref) + d
+
+        np.testing.assert_array_almost_equal(vals, expected)
+
+
+class TestCombinedPoly:
+
+    def testInplace(self):
+        pass
+
+
+class TestPolyPoly:
+
+    def testInplace(self):
+        pass
diff --git a/testing/test_util.py b/testing/test_util.py
index d7fb15f7..141c5657 100644
--- a/testing/test_util.py
+++ b/testing/test_util.py
@@ -1,47 +1,227 @@
-import datetime
-import time
-
-import pytest
-
-import idelib.util
-
-NOW_DATETIME = datetime.datetime.now()
-NOW_STRUCT_TIME = time.localtime()
-NOW_UTC = time.time()
-
-ATTRIBUTES = [{},
-              {'string': 'this is a unicode string'},
-              {'bytes': b'this is a bytes string'},
-              {'float': 0.1},
-              {'int': 10},
-              {'datetimeTypes': NOW_DATETIME},
-              {'time.struct_time': NOW_STRUCT_TIME},
-              [['int', (2, 'IntAttribute')]],
-              [['int', ([2, 3, 4, 5], 'IntAttribute')]],
-              [('datetime datetime', NOW_DATETIME)],
-              [('datetime struct', NOW_STRUCT_TIME)],
-              [('datetime int', (NOW_UTC, 'DateAttribute'))],
-             ]
-
-ENCODED_ATTRIBUTES = [[],
-                      [{'AttributeName': 'string', 'UnicodeAttribute': 'this is a unicode string'}],
-                      [{'AttributeName': 'bytes', 'StringAttribute': b'this is a bytes string'}],
-                      [{'AttributeName': 'float', 'FloatAttribute': 0.1}],
-                      [{'AttributeName': 'int', 'IntAttribute': 10}],
-                      [{'AttributeName': 'datetimeTypes', 'DateAttribute': NOW_DATETIME}],
-                      [{'AttributeName': 'time.struct_time', 'DateAttribute': NOW_STRUCT_TIME}],
-                      [{'AttributeName': 'int', 'IntAttribute': 2}],
-                      [{'AttributeName': 'int', 'IntAttribute': [2, 3, 4, 5]}],
-                      [{'AttributeName': 'datetime datetime', 'DateAttribute': NOW_DATETIME}],
-                      [{'AttributeName': 'datetime struct', 'DateAttribute': NOW_STRUCT_TIME}],
-                      [{'AttributeName': 'datetime int', 'DateAttribute': int(NOW_UTC)}],
-                    ]
-
-
-@pytest.mark.parametrize(
-        "test_input, expected",
-        zip(ATTRIBUTES, ENCODED_ATTRIBUTES)
-        )
-def test_encode_attributes(test_input, expected):
-
-    assert idelib.util.encode_attributes(test_input) == expected
+import os.path
+import tempfile
+
+from ebmlite import loadSchema
+import pytest  # type: ignore
+
+from idelib import importer
+from idelib import util
+from testing.file_streams import makeStreamLike
+
+
+# ==============================================================================
+#
+# ==============================================================================
+
+class TestExtractTime:
+
+    @classmethod
+    def setup_class(cls):
+        cls.dataset = importer.openFile(os.path.join(os.path.dirname(__file__), "SSX66115.IDE"))
+        importer.readData(cls.dataset)
+
+        cls.extractionStart = cls.dataset.sessions[0].lastTime * .33
+        cls.extractionEnd = cls.extractionStart * 2
+
+
+    @classmethod
+    def teardown_class(cls):
+        cls.dataset.close()
+
+
+    def extractTime2tempfile(self, **kwargs):
+        """
+        Helper to do the busywork of running `extractTime()`, extracting to a
+        temporary file, and importing the extracted data as a new `Dataset`.
+        Keyword arguments are passed to `extractTime()`.
+        """
+        out = tempfile.SpooledTemporaryFile(suffix=".ide")
+        util.extractTime(self.dataset, out, **kwargs)
+        out.seek(0)
+        extracted = importer.openFile(out)
+        importer.readData(extracted)
+
+        return extracted
+
+
+    def test_extractTime_start(self):
+        """
+        Test extraction with only start time specified.
+        """
+        extracted = self.extractTime2tempfile(startTime=self.extractionStart,
+                                              endTime=None,
+                                              channels=None)
+
+        for channel in extracted.channels.values():
+            data = channel.getSession()
+            assert data[0][0] <= self.extractionStart
+
+
+    def test_extractTime_end(self):
+        """
+        Test extraction with only end time specified.
+        """
+        extracted = self.extractTime2tempfile(startTime=0,
+                                              endTime=self.extractionEnd,
+                                              channels=None)
+
+        for channel in extracted.channels.values():
+            data = channel.getSession()
+            assert data[-1][0] >= self.extractionEnd
+
+
+    def test_extractTime_start_end(self):
+        """
+        Test extraction with both start and end times specified.
+        """
+        extracted = self.extractTime2tempfile(startTime=self.extractionStart,
+                                              endTime=self.extractionEnd,
+                                              channels=None)
+
+        for channel in extracted.channels.values():
+            data = channel.getSession()
+            assert data[0][0] <= self.extractionStart
+            assert data[-1][0] >= self.extractionEnd
+
+
+    def test_extractTime_channels(self):
+        """
+        Test that only data from specified channels is extracted.
+        """
+        extracted = self.extractTime2tempfile(startTime=self.extractionStart,
+                                              endTime=self.extractionEnd,
+                                              channels=[8, 36])
+
+        assert len(extracted.channels[8].getSession()) > 0, \
+            "Extracted file did not contain data for specified channel"
+        assert len(extracted.channels[36].getSession()) > 0, \
+            "Extracted file did not contain data for specified channel"
+        assert len(extracted.channels[32].getSession()) == 0, \
+            "Extracted file contain data from excluded channel"
+
+
+    def test_extractTime_error(self):
+        with pytest.raises(TypeError):
+            util.extractTime(self.dataset, None)
+
+
+# ==============================================================================
+#
+# ==============================================================================
+
+class TestGetLength:
+    """
+    Test `util.getLength()` and associated `util` functions. Also tests
+    `util.getExitCondition()`, since it uses some of the same functions.
+    """
+
+    @classmethod
+    def setup_class(cls):
+        cls.idefile = os.path.join(os.path.dirname(__file__), "SSX66115.IDE")
+        cls.dataset = importer.importFile(cls.idefile)
+
+
+    @classmethod
+    def teardown_class(cls):
+        cls.dataset.close()
+
+
+    def test_getSize(self):
+        with open(self.idefile, 'rb') as fs:
+            assert util._getSize(fs) == os.path.getsize(self.idefile), \
+                    "_getSize() of file stream did not match actual size"
+
+            fs.seek(100)
+            assert util._getSize(fs) == os.path.getsize(self.idefile), \
+                    "_getSize() of file stream (tell > 0) did not match actual size"
+            assert fs.tell() == 100, \
+                    "_getSize() did not restore file position"
+
+        with makeStreamLike('./testing/SSX66115.IDE') as fs:
+            assert util._getSize(fs) == os.path.getsize(self.idefile), \
+                   "_getSize() of non-file stream did not match actual size"
+
+        with pytest.raises(TypeError):
+            util._getSize("not a stream")
+
+
+    def test_getLastSync(self):
+        # get all SyncElement offsets, since _getLastSync() doesn't get the
+        # actual last sync, just one with data following it.
+        syncs = []
+        lastData = None
+        for el in self.dataset.ebmldoc:
+            if el.name == "ChannelDataBlock":
+                lastData = el
+            elif el.name == "Sync":
+                syncs.append(el.offset)
+
+        with open(self.idefile, 'rb') as fs:
+            lastSync = util._getLastSync(fs)
+
+            assert lastSync in syncs, \
+                "_getLastSync(file) did not find SyncElement"
+            assert lastSync < lastData.offset, \
+                "_getLastSync(file) returned a SyncElement after all data"
+
+        with makeStreamLike('./testing/SSX66115.IDE') as fs:
+            lastSync = util._getLastSync(fs)
+
+            assert lastSync in syncs, \
+                "_getLastSync(stream) did not find SyncElement"
+            assert lastSync < lastData.offset, \
+                "_getLastSync(stream) returned a SyncElement after all data"
+
+        with pytest.raises(TypeError):
+            util._getLastSync("not a stream")
+
+
+    def test_getBlockSize(self):
+        # Check _getBlockSize() gets the same values as the 'real' parser
+        for channel in self.dataset.channels.values():
+            for block in channel.getSession()._data:
+                cid, start, end = util._getBlockTime(self.dataset, block.element)
+
+                assert cid == channel.id, \
+                    "_getBlockSize() parsed ChannelID wrong"
+                assert start == block.startTime
+                assert end == block.endTime
+
+
+    def test_getLength(self):
+        first, last = util.getLength(self.dataset)
+
+        assert self.dataset.lastSession.firstTime == first, \
+            "getLength(Dataset) starting time did not match Dataset"
+        assert self.dataset.lastSession.lastTime == last, \
+            "getLength(Dataset) ending time did not match Dataset"
+
+        with makeStreamLike('./testing/SSX66115.IDE') as fs:
+            first, last = util.getLength(fs)
+
+            assert self.dataset.lastSession.firstTime == first, \
+                "getLength(stream) starting time did not match Dataset"
+            assert self.dataset.lastSession.lastTime == last, \
+                "getLength(stream) ending time did not match Dataset"
+
+        with pytest.raises(TypeError):
+            util.getLength(None)
+
+
+    def test_getExitCondition(self):
+        # Exit condition read when the file is fully imported.
+        assert self.dataset.exitCondition == util.getExitCondition(self.idefile), \
+            "getExitCondition(filename) did not match Dataset.exitCondition"
+
+        with makeStreamLike('./testing/SSX66115.IDE') as fs:
+            assert self.dataset.exitCondition == util.getExitCondition(fs), \
+                "getExitCondition(stream) did not match Dataset.exitCondition"
+
+        assert self.dataset.exitCondition == util.getExitCondition(self.dataset), \
+            "getExitCondition(Dataset) did not match Dataset.exitCondition"
+
+        assert self.dataset.exitCondition == util.getExitCondition(self.dataset.ebmldoc), \
+            "getExitCondition(ebmlite.Document) did not match Dataset.exitCondition"
+
+        with pytest.raises(TypeError):
+            util.getExitCondition(None)
diff --git a/testing/utils.py b/testing/utils.py
new file mode 100644
index 00000000..4297468a
--- /dev/null
+++ b/testing/utils.py
@@ -0,0 +1,9 @@
+class nullcontext:
+    """ A replacement for `contextlib.nullcontext` for python versions before 3.7
+    """
+
+    def __enter__(self):
+        pass
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        pass