Repository: ROVI-org/battery-data-toolkit (language: python)

Issue:
Switch current sign convention to match ROVI standards
We have decided that positive = charge, negative = discharge. Make sure to update all the examples datasets and capacity calculations 


Discussion / hints:
Need to fix #57 while doing this

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 8bbaaba..1306d43 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -29,3 +29,12 @@ jobs:
           coveralls --service=github
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Test example notebooks
+        run: |
+          pip install jupyter matplotlib
+          home_dir=`pwd`
+          for notebook in `find . -name "*.ipynb"`; do
+             cd `dirname $notebook`
+             jupyter execute `basename $notebook`
+             cd $home_dir
+          done
diff --git a/battdat/consistency/__init__.py b/battdat/consistency/__init__.py
new file mode 100644
index 0000000..1d0a8e9
--- /dev/null
+++ b/battdat/consistency/__init__.py
@@ -0,0 +1,1 @@
+"""Tools for checking whether data contained in a dataset is self-consistent"""
diff --git a/battdat/consistency/base.py b/battdat/consistency/base.py
new file mode 100644
index 0000000..a701a37
--- /dev/null
+++ b/battdat/consistency/base.py
@@ -0,0 +1,20 @@
+"""Base class for consistency checkers"""
+from typing import List
+
+from battdat.data import BatteryDataset
+
+
+# TODO (wardlt): Consider standardizing the error messages: which table, how bad, possible remedy
+# TODO (wardlt): Make attributes defining which subsets to explore part of the base class
+class ConsistencyChecker:
+    """Interface for classes which assess whether data in a :class:`~battdata.data.BatteryDataset` are self-consistent"""
+
+    def check(self, dataset: BatteryDataset) -> List[str]:
+        """Report possible inconsistencies within a dataset
+
+        Args:
+            dataset: Dataset to be evaluated
+        Returns:
+            List of observed inconsistencies
+        """
+        raise NotImplementedError()
diff --git a/battdat/consistency/current.py b/battdat/consistency/current.py
new file mode 100644
index 0000000..408455f
--- /dev/null
+++ b/battdat/consistency/current.py
@@ -0,0 +1,56 @@
+"""Checks related to the current in time series data"""
+from dataclasses import dataclass
+from typing import List, Collection, Optional
+
+from datetime import datetime, timedelta
+
+import numpy as np
+import pandas as pd
+
+from battdat.data import BatteryDataset
+from battdat.consistency.base import ConsistencyChecker
+
+
+# TODO (wardlt): Check over different cycles
+@dataclass
+class SignConventionChecker(ConsistencyChecker):
+    """Estimate whether the sign convention of a dataset is likely to be correct
+
+    The concept underpinning this class is that the voltage of a cell should increase as it is charged.
+    The algorithm looks for a period where the current is the most consistent the measures whether
+    the change in measured voltage during that period.
+    """
+
+    subsets_to_check: Collection[str] = ('raw_data',)
+    """Which subsets within a dataset to evaluate"""
+    window_length: float = 360.
+    """Length of time period over which to assess voltage change (units: s)"""
+    minimum_current: float = 1e-6
+    """Minimum current used when determining periods of charge or discharge"""
+
+    def check(self, dataset: BatteryDataset) -> List[str]:
+        warnings = []
+        for subset in self.subsets_to_check:
+            if (warning := self.check_subset(dataset.datasets[subset])) is not None:
+                warnings.append(warning)
+        return warnings
+
+    def check_subset(self, time_series: pd.DataFrame) -> Optional[str]:
+        # Convert the test time (seconds) to a time object so that Panda's rolling window can use a time
+        time_series['timestamp'] = time_series['test_time'].apply(datetime.fromtimestamp)
+        nonzero_current = time_series.query(f'current > {self.minimum_current} or current < {-self.minimum_current}')  # Only get nonzero currents
+        windowed = nonzero_current[['timestamp', 'test_time', 'current', 'voltage']].rolling(
+            window=timedelta(seconds=self.window_length), on='timestamp', min_periods=4,
+        )
+        if len(nonzero_current) < 4:
+            raise ValueError(f'Insufficient data to judge the sign convention (only {len(nonzero_current)}). Consider raising the minimum current threshold.')
+
+        # Find the region with the lowest standard deviation
+        most_stable_point = windowed['current'].std().idxmin()
+        most_stable_time = nonzero_current['test_time'].loc[most_stable_point]
+        stable_window = nonzero_current.query(f'test_time < {most_stable_time} and test_time > {most_stable_time - self.window_length}')
+        curr_volt_cov = np.cov(stable_window['voltage'], stable_window['test_time'])[0, 1]
+        if np.sign(curr_volt_cov) != np.sign(stable_window['current'].mean()):  # TODO (wardlt): Invert when we flip sign convention
+            return (f'Potential sign error in current. Average current between test_time={most_stable_time - self.window_length:.1f}s and '
+                    f'test_time={most_stable_time:.1f} is {stable_window["current"].mean():.1e} A and the covariance between the voltage and time '
+                    f'is {curr_volt_cov:.1e} V/s. The current and this covariance should have the same sign.')
diff --git a/battdat/io/arbin.py b/battdat/io/arbin.py
index 547193f..5c30303 100644
--- a/battdat/io/arbin.py
+++ b/battdat/io/arbin.py
@@ -36,7 +36,7 @@ def read_file(self, file: str, file_number: int = 0, start_cycle: int = 0,
         df_out['cycle_number'] = df_out['cycle_number'].astype('int64')
         df_out['file_number'] = file_number  # df_out['cycle_number']*0
         df_out['test_time'] = np.array(df['test_time'] - df['test_time'][0] + start_time, dtype=float)
-        df_out['current'] = -df['Current']
+        df_out['current'] = df['Current']  # TODO (wardlt): Check this!?
         df_out['temperature'] = df['Temperature']
         df_out['internal_resistance'] = df['Internal_Resistance']
         df_out['voltage'] = df['Voltage']
diff --git a/battdat/io/hdf.py b/battdat/io/hdf.py
index 4483bbb..80bcdaf 100644
--- a/battdat/io/hdf.py
+++ b/battdat/io/hdf.py
@@ -6,11 +6,10 @@
 
 import numpy as np
 import pandas as pd
-from pandas import HDFStore
 from tables import Group, File, Filters, descr_from_dtype, Table
 
 from battdat import __version__
-from .base import DatasetWriter, PathLike, DatasetFileReader
+from .base import DatasetWriter, PathLike, DatasetReader
 from ..data import BatteryDataset
 from ..schemas import BatteryMetadata
 from ..schemas.column import ColumnSchema
@@ -121,7 +120,7 @@ def inspect_hdf(file: File) -> Tuple[BatteryMetadata, Set[Union[str, None]]]:
     return metadata, prefixes
 
 
-class HDF5Reader(DatasetFileReader):
+class HDF5Reader(DatasetReader):
     """Read datasets from a battery-data-toolkit format HDF5 file
 
     The HDF5 format permits multiple datasets in a single HDF5 file
@@ -178,8 +177,8 @@ def read_dataset(self, path: PathLike, metadata: Optional[Union[BatteryMetadata,
         Returns:
             Dataset read from the file
         """
-        with HDFStore(path) as store:
-            data = self.read_from_hdf(store, prefix=None)
+        with File(path) as file:
+            data = self.read_from_hdf(file, prefix=None)
             if metadata is not None:
                 data.metadata = metadata
             return data
diff --git a/battdat/io/maccor.py b/battdat/io/maccor.py
index a72cb42..9233fd4 100644
--- a/battdat/io/maccor.py
+++ b/battdat/io/maccor.py
@@ -1,4 +1,4 @@
-"""Extractor for MACCOR (untested!!)"""
+"""Extractor for MACCOR"""
 import itertools
 from typing import Union, List, Iterator, Tuple
 
@@ -51,7 +51,7 @@ def read_file(self, file: str, file_number: int = 0, start_cycle: int = 0,
         df_out['file_number'] = file_number  # df_out['cycle_number']*0
         df_out['test_time'] = df['Test (Min)'] * 60 - df['Test (Min)'].iloc[0] * 60 + start_time
         df_out['state'] = df['State']
-        df_out['current'] = -df['Amps']
+        df_out['current'] = df['Amps']
         df_out['current'] = np.where(df['State'] == 'D', -1 * df_out['current'], df_out['current'])
         #   0 is rest, 1 is charge, -1 is discharge
         df_out.loc[df_out['state'] == 'R', 'state'] = ChargingState.hold
diff --git a/battdat/postprocess/__init__.py b/battdat/postprocess/__init__.py
index 5a2262e..e69de29 100644
--- a/battdat/postprocess/__init__.py
+++ b/battdat/postprocess/__init__.py
@@ -1,1 +0,0 @@
-"""Methods that compute new data based on existing columns in a cycling data file"""
diff --git a/battdat/postprocess/integral.py b/battdat/postprocess/integral.py
index 8731846..ddd2c08 100644
--- a/battdat/postprocess/integral.py
+++ b/battdat/postprocess/integral.py
@@ -129,9 +129,9 @@ class StateOfCharge(RawDataEnhancer):
     The energy change is determined by integrating the product
     of current and voltage.
 
-    Output dataframe has 4 new columns:
-        - ``cycle_capacity``: Amount of charge dispersed from the battery over the cycle, in A-hr
-        - ``cycle_energy``: Amount of energy dispersed from the battery over the cycle, in J
+    Output dataframe has 2 new columns:
+        - ``cycle_capacity``: Amount of charge charged since the beginning of the cycle, in A-hr
+        - ``cycle_energy``: Amount of energy charged since the beginning of the cycle, in J
     """
 
     column_names = ['cycle_capacity', 'cycle_energy']
diff --git a/battdat/schemas/column.py b/battdat/schemas/column.py
index 61b71a9..63534a2 100644
--- a/battdat/schemas/column.py
+++ b/battdat/schemas/column.py
@@ -214,7 +214,7 @@ class RawData(ColumnSchema):
     # Required measurement data
     test_time: ColumnInfo = ColumnInfo(description="Time from the beginning of measurements", units='s', monotonic=True, type=DataType.FLOAT, required=True)
     voltage: ColumnInfo = ColumnInfo(description="Measured voltage of the system", units="V", type=DataType.FLOAT, required=True)
-    current: ColumnInfo = ColumnInfo(description="Measured current of the system. Positive current represents the battery discharging.",
+    current: ColumnInfo = ColumnInfo(description="Measured current of the system. Positive current represents the battery charging.",
                                      units='A', required=True, type=DataType.FLOAT)
     internal_resistance: ColumnInfo = ColumnInfo(description="Internal resistance of the battery.", units="ohm", type=DataType.FLOAT)
 
diff --git a/battdat/streaming/__init__.py b/battdat/streaming/__init__.py
index 6f5bf99..35c2e05 100644
--- a/battdat/streaming/__init__.py
+++ b/battdat/streaming/__init__.py
@@ -1,57 +1,51 @@
 """Retrieve data in smaller chunks from a large HDF5 file"""
+from itertools import groupby
 from pathlib import Path
-from contextlib import contextmanager
 from typing import Union, Iterator, Dict, List
 
+import numpy as np
 import pandas as pd
 from pandas import HDFStore
-from pandas.io.pytables import TableIterator
+from tables import File, Table
 
 from battdat.data import CellDataset
+from battdat.io.hdf import as_hdf5_object
 
 
-@contextmanager
-def _get_raw_data_iterator_h5(hdf5_path: Union[Path, str, HDFStore], **kwargs) -> Iterator[pd.DataFrame]:
-    """Open an iterator over chunks of an HDF5 file"""
-    chunk_iter: TableIterator = pd.read_hdf(hdf5_path, key='raw_data', iterator=True, **kwargs)
-    try:
-        yield chunk_iter
-    finally:
-        if not isinstance(hdf5_path, HDFStore):
-            chunk_iter.close()
+def _get_raw_data_iterator_h5(hdf5_path: Union[Path, str, File], key: str) -> Iterator[np.ndarray]:
+    """Open an iterator over rows of an HDF5 Table"""
 
+    with as_hdf5_object(hdf5_path) as file:
+        table: Table = file.get_node(key)
+        names = table.dtype.fields.keys()
+        for row in table.iterrows():
+            out = dict((n, row[n]) for n in names)
+            yield out
 
-def iterate_records_from_file(hdf5_path: Union[Path, str, HDFStore], **kwargs) -> Iterator[Dict[str, Union[str, float, int]]]:
-    """Stream individual records from a file
 
-    Keyword arguments are passed to :meth:`~pandas.read_hdf`, which includes options
-    to limit which rows and columns are read.
+def iterate_records_from_file(hdf5_path: Union[Path, str, HDFStore], key: str = '/raw_data') -> Iterator[Dict[str, Union[str, float, int]]]:
+    """Stream individual records from a file
 
     Args:
         hdf5_path: Path to the data file
-
+        key: Which table to read
     Yields:
         Individual rows from the "raw_data" section of the HDF5 file
     """
 
-    with _get_raw_data_iterator_h5(hdf5_path, **kwargs) as chunk_iter:
-        for chunk in chunk_iter:
-            for _, row in chunk.iterrows():
-                yield row
+    yield from _get_raw_data_iterator_h5(hdf5_path, key=key)
 
 
 def iterate_cycles_from_file(hdf5_path: Union[Path, str, HDFStore],
                              make_dataset: bool = False,
-                             **kwargs) -> Iterator[Union[pd.DataFrame, CellDataset]]:
+                             key: str = '/raw_data') -> Iterator[Union[pd.DataFrame, CellDataset]]:
     """Stream single-cycle datasets from the HDF5 file
 
-    Keyword arguments are passed to :meth:`~pandas.read_hdf`, which includes options
-    to limit which rows and columns are read.
-
     Args:
         hdf5_path: Path to the data file
         make_dataset: Whether to form a :class:`~battdat.data.BatteryDataset` for each cycle,
             including the metadata from the source file.
+        key: Which table to read
 
     Yields:
         All rows belonging to each cycle from the "raw_data" section fo the HDF5 file.
@@ -62,26 +56,11 @@ def iterate_cycles_from_file(hdf5_path: Union[Path, str, HDFStore],
     if make_dataset:
         metadata, _ = CellDataset.inspect_hdf(hdf5_path)
 
-    def _assemble_from_chunks(chunks: List[pd.DataFrame]):
-        combined = pd.concat(chunks)
+    def _assemble_from_records(chunks: List[dict]):
+        combined = pd.DataFrame(chunks)
         if make_dataset:
             return CellDataset(metadata=metadata, raw_data=combined)
         return combined
 
-    current_cycle = None
-    current_cycle_chunks = None
-    with _get_raw_data_iterator_h5(hdf5_path, **kwargs) as chunk_iter:
-        for new_chunk in chunk_iter:  # Until no more data
-            while not (cycle_mask := new_chunk['cycle_number'] == current_cycle).all():  # Loop until only current cycle
-                my_cycle = new_chunk[cycle_mask]  # Retrieve the parts from the current cycle
-                new_chunk = new_chunk[~cycle_mask]  # Remove those parts from the current chunk
-
-                # Yield the current data then reset the collection, increment cycle tracker
-                if current_cycle_chunks is not None:
-                    current_cycle_chunks.append(my_cycle)
-                    yield _assemble_from_chunks(current_cycle_chunks)
-                current_cycle_chunks = []
-                current_cycle = new_chunk['cycle_number'].iloc[0]
-            current_cycle_chunks.append(new_chunk)
-
-    yield _assemble_from_chunks(current_cycle_chunks)
+    for _, chunk in groupby(_get_raw_data_iterator_h5(hdf5_path, key), lambda x: x['cycle_number']):
+        yield _assemble_from_records(chunk)
diff --git a/docs/source/modules.rst b/docs/source/modules.rst
index e9653af..475b7c9 100644
--- a/docs/source/modules.rst
+++ b/docs/source/modules.rst
@@ -11,4 +11,5 @@ API documentation for each module
    schemas
    io
    postprocess
+   consistency
    streaming
diff --git a/docs/user-guide/consistency/check-sign-convention.ipynb b/docs/user-guide/consistency/check-sign-convention.ipynb
new file mode 100644
index 0000000..12b61bd
--- /dev/null
+++ b/docs/user-guide/consistency/check-sign-convention.ipynb
@@ -0,0 +1,437 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "cccf7fdb-d8bf-4281-a21d-dbe88d28a6a2",
+   "metadata": {},
+   "source": [
+    "# Checking Sign Conventions\n",
+    "The sign convention for current varies between sources of battery data.\n",
+    "Some define \"charging\" as positive whereas for others charging is negative current.\n",
+    "The ``SignConventionChecker`` class checks whether our sign choice in the battery-data-toolkit, \"charging current is postive,\" is followed."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "e07d0f83-d2e5-4306-9a56-22e4843b8148",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib inline\n",
+    "from matplotlib import pyplot as plt\n",
+    "from battdat.consistency.current import SignConventionChecker\n",
+    "from battdat.data import CellDataset\n",
+    "from datetime import datetime, timedelta"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "82db7e17-6866-4855-a492-e545cf081fd5",
+   "metadata": {},
+   "source": [
+    "## Load Example Data\n",
+    "We have two simple cells that vary only by the where the \"cycle\" starts."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "a27a6598-7836-4705-81fb-e05fc8aad24c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from battdat.io.batterydata import BDReader\n",
+    "dataset = BDReader().read_dataset(['../../../tests/files/batterydata/p492-13-raw.csv'])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4c2d1e16-ead3-4e51-b84b-0e27d42fbae4",
+   "metadata": {},
+   "source": [
+    "## Voltage Should Increase During Charge\n",
+    "The core concept behind our sign convention is that the voltage for a battery should increase while it is charging: an principle that is true for many types of batteries.\n",
+    "\n",
+    "The algorithm for checking a dataset is consistent with this principle is conceptually simple:\n",
+    "1. Find a point where the current is the most constant\n",
+    "2. Measure the slope between voltage and time\n",
+    "3. Make sure the slope and the current have the same sign (if voltage 📈, current ➕)\n",
+    "\n",
+    "There are, however, a few nuances to the actual implementation."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "41f5741a-d426-48d2-b48f-7f9f2746fbcf",
+   "metadata": {},
+   "source": [
+    "### Step 1: Finding a Region of Stable Current\n",
+    "Time spans with stable current are where the standard deviation of current is small.\n",
+    "The checker finds the most-stable region using Panda's windowing function.\n",
+    "\n",
+    "Start by converting the \"test_time\" column in data to a Python time object so that Panda's recognizes how to convert a time window to a \"real\" measure"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "36048ad5-16ca-4f37-a50e-73822fc9668f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "raw_data = dataset.raw_data.query('test_time < 8000').copy()  # Get the time series\n",
+    "raw_data['timestamp'] = raw_data['test_time'].apply(datetime.fromtimestamp)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "daf33c9b-b6d1-4349-ae28-009e03cc1b13",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "current_std = raw_data.rolling(timedelta(seconds=360), on='timestamp')['current'].std()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "6476de44-835c-48f7-9294-2c881b8bcac8",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "Text(0.5, 0, 'Time (s)')"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    },
+    {
+     "data": {
+      "image/png": "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",
+      "text/plain": [
+       "<Figure size 300x180 with 1 Axes>"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "fig, ax = plt.subplots(figsize=(3., 1.8))\n",
+    "\n",
+    "ax.plot(raw_data['test_time'], raw_data['current'], label='$I$ (A)')\n",
+    "ax.plot(raw_data['test_time'], current_std, label='$\\\\sigma_I$ (A)')\n",
+    "\n",
+    "ax.legend()\n",
+    "ax.set_xlabel('Time (s)')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "588b6054-f534-44f0-b0bc-296e45a9716d",
+   "metadata": {},
+   "source": [
+    "There are plenty of regions of stable current in this data and some where the current is nonzero."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "720475c2-b5d2-4f89-9c69-dfe036bdcbdb",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "window = raw_data.query('1000 < test_time < 2000')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "229f0e66-58b1-4260-b7bb-8f41fbb43a29",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>cycle_number</th>\n",
+       "      <th>step_index</th>\n",
+       "      <th>test_time</th>\n",
+       "      <th>current</th>\n",
+       "      <th>voltage</th>\n",
+       "      <th>temperature</th>\n",
+       "      <th>time</th>\n",
+       "      <th>timestamp</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>16</th>\n",
+       "      <td>0</td>\n",
+       "      <td>5</td>\n",
+       "      <td>1052.958</td>\n",
+       "      <td>0.00095</td>\n",
+       "      <td>3.183413</td>\n",
+       "      <td>29.8454</td>\n",
+       "      <td>1.578067e+09</td>\n",
+       "      <td>1969-12-31 19:17:32.958</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>17</th>\n",
+       "      <td>0</td>\n",
+       "      <td>5</td>\n",
+       "      <td>1252.962</td>\n",
+       "      <td>0.00095</td>\n",
+       "      <td>3.166171</td>\n",
+       "      <td>29.8776</td>\n",
+       "      <td>1.578067e+09</td>\n",
+       "      <td>1969-12-31 19:20:52.962</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>18</th>\n",
+       "      <td>0</td>\n",
+       "      <td>5</td>\n",
+       "      <td>1452.960</td>\n",
+       "      <td>0.00095</td>\n",
+       "      <td>3.148165</td>\n",
+       "      <td>29.8937</td>\n",
+       "      <td>1.578067e+09</td>\n",
+       "      <td>1969-12-31 19:24:12.960</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>19</th>\n",
+       "      <td>0</td>\n",
+       "      <td>5</td>\n",
+       "      <td>1652.958</td>\n",
+       "      <td>0.00095</td>\n",
+       "      <td>3.129091</td>\n",
+       "      <td>29.8454</td>\n",
+       "      <td>1.578067e+09</td>\n",
+       "      <td>1969-12-31 19:27:32.958</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>20</th>\n",
+       "      <td>0</td>\n",
+       "      <td>5</td>\n",
+       "      <td>1852.962</td>\n",
+       "      <td>0.00095</td>\n",
+       "      <td>3.109255</td>\n",
+       "      <td>29.8293</td>\n",
+       "      <td>1.578068e+09</td>\n",
+       "      <td>1969-12-31 19:30:52.962</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "    cycle_number  step_index  test_time  current   voltage  temperature  \\\n",
+       "16             0           5   1052.958  0.00095  3.183413      29.8454   \n",
+       "17             0           5   1252.962  0.00095  3.166171      29.8776   \n",
+       "18             0           5   1452.960  0.00095  3.148165      29.8937   \n",
+       "19             0           5   1652.958  0.00095  3.129091      29.8454   \n",
+       "20             0           5   1852.962  0.00095  3.109255      29.8293   \n",
+       "\n",
+       "            time               timestamp  \n",
+       "16  1.578067e+09 1969-12-31 19:17:32.958  \n",
+       "17  1.578067e+09 1969-12-31 19:20:52.962  \n",
+       "18  1.578067e+09 1969-12-31 19:24:12.960  \n",
+       "19  1.578067e+09 1969-12-31 19:27:32.958  \n",
+       "20  1.578068e+09 1969-12-31 19:30:52.962  "
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "window"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "93623621-e53e-4b4b-b754-c0fd1c6b2577",
+   "metadata": {},
+   "source": [
+    "## Step 2: Compute the slope between voltage and time\n",
+    "The [slope between two variables is proportional to the covariance](https://en.wikipedia.org/wiki/Simple_linear_regression#Relationship_with_the_sample_covariance_matrix), which can also be computed for a rolling window"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "d12c05f3-939d-4127-bc73-0a166c6a151d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "cov = raw_data.rolling(timedelta(seconds=360), on='timestamp')['voltage'].cov(raw_data['test_time'])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "8018b6b4-2a35-4ea3-bf4f-cbb6228315f9",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "Text(0.5, 24.0, 'Time (s)')"
+      ]
+     },
+     "execution_count": 9,
+     "metadata": {},
+     "output_type": "execute_result"
+    },
+    {
+     "data": {
+      "image/png": "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",
+      "text/plain": [
+       "<Figure size 300x240 with 3 Axes>"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "fig, axs = plt.subplots(3, 1, figsize=(3., 2.4), sharex=True)\n",
+    "\n",
+    "axs[0].plot(raw_data['test_time'], raw_data['voltage'])\n",
+    "axs[0].set_ylabel('$V$ (V)')\n",
+    "\n",
+    "axs[1].plot(raw_data['test_time'], raw_data['current'] * 1000)\n",
+    "axs[1].set_ylabel('$I$ (mA)')\n",
+    "\n",
+    "axs[2].plot(raw_data['test_time'], cov)\n",
+    "axs[2].set_ylabel('$\\\\sigma_{V,t}$ (mA)')\n",
+    "\n",
+    "ax.legend()\n",
+    "ax.set_xlabel('Time (s)')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "29d76761-a20c-4c25-8373-c5745b9c8d64",
+   "metadata": {},
+   "source": [
+    "Note how the covariance between voltage and time ($\\sigma_{V,t}$) has the same sign as the current for the regions where the data are the same."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "72d35f64-e5bb-45f9-8eb9-00562a6998e5",
+   "metadata": {},
+   "source": [
+    "## Step 3: Compare signs\n",
+    "The ``SignConventionChecker`` does these two previous steps and compares the outcome"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "id": "1c26dc07-2569-40e0-bff1-592eb466d1a7",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "[]"
+      ]
+     },
+     "execution_count": 10,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "chckr = SignConventionChecker()\n",
+    "chckr.check(dataset)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d8f4388b-dafc-40a9-b6aa-8a560655ad82",
+   "metadata": {},
+   "source": [
+    "No errors for this dataset. ✅\n",
+    "\n",
+    "If we change the sign, the checker will notice and report why the data are wrong"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "id": "752d930d-b86a-48d1-bc63-2d3b36bc8349",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "['Potential sign error in current. Average current between test_time=299170.4s and test_time=299530.4 is -9.5e-03 and the covariance between the voltage and time is -1.5e+00 V/s. The current and this covariance should not have the same sign.']"
+      ]
+     },
+     "execution_count": 11,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "dataset.raw_data['current'] *= -1\n",
+    "chckr.check(dataset)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "e01cebe7-d216-4221-9427-e64b360db101",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.15"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/user-guide/consistency/index.rst b/docs/user-guide/consistency/index.rst
new file mode 100644
index 0000000..e1e0acd
--- /dev/null
+++ b/docs/user-guide/consistency/index.rst
@@ -0,0 +1,25 @@
+Consistency Checks
+==================
+
+Many problems, such as sign convention mishaps or unit conversion issues, can be detected from inconsistencies between
+or within columns in a dataset.
+The :mod:`battdat.consistency` module provides algorithms that check whether there may be problems within a battery dataset.
+
+All algorithms are based on :class:`~battdat.consistency.base.ConsistencyChecker`,
+which creates a list of warnings given a dataset.
+
+.. code-block:: python
+
+    computer = ConsistencyChecker()
+    warnings = computer.check(data)
+
+    if len(warnings) > 0:
+        print(f'There are {len(warnings)} warnings, which includes: {warnings[0]}')
+
+
+.. toctree::
+    :maxdepth: 1
+    :caption: Available consistency checks:
+
+    check-sign-convention
+
diff --git a/docs/user-guide/index.rst b/docs/user-guide/index.rst
index 593a842..f5855c8 100644
--- a/docs/user-guide/index.rst
+++ b/docs/user-guide/index.rst
@@ -15,4 +15,5 @@ or post-processing if using already-existing data.
    formats
    schemas/index
    post-processing/index
+   consistency/index
    streaming
diff --git a/docs/user-guide/post-processing/cell-capacity.ipynb b/docs/user-guide/post-processing/cell-capacity.ipynb
index 7eeab5a..4df29ce 100644
--- a/docs/user-guide/post-processing/cell-capacity.ipynb
+++ b/docs/user-guide/post-processing/cell-capacity.ipynb
@@ -22,7 +22,7 @@
     "%matplotlib inline\n",
     "from matplotlib import pyplot as plt\n",
     "from battdat.postprocess.integral import StateOfCharge, CapacityPerCycle\n",
-    "from battdat.data import BatteryDataset\n",
+    "from battdat.data import CellDataset\n",
     "from pathlib import Path"
    ]
   },
@@ -42,7 +42,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from_charged = BatteryDataset.from_hdf('../../../tests/files/example-data/single-resistor-constant-charge_from-charged.hdf')"
+    "from_charged = CellDataset.from_hdf('../../../tests/files/example-data/single-resistor-constant-charge_from-charged.hdf')"
    ]
   },
   {
@@ -52,7 +52,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from_discharged = BatteryDataset.from_hdf('../../../tests/files/example-data/single-resistor-constant-charge_from-discharged.hdf')"
+    "from_discharged = CellDataset.from_hdf('../../../tests/files/example-data/single-resistor-constant-charge_from-discharged.hdf')"
    ]
   },
   {
@@ -61,7 +61,7 @@
    "metadata": {},
    "source": [
     "## Step 1: Intergrate Current and Power\n",
-    "First integrate the current ($I$) and power output ($IV$) of a battery to determine the amount of charge and energy transfered from the battery during each cycle.\n",
+    "First integrate the current ($I$) and power output ($IV$) of a battery to determine the amount of charge and energy charged into the battery each cycle.\n",
     "\n",
     "The `StateOfCharge` tool in battery-data-toolkit computes these integrals."
    ]
@@ -121,6 +121,14 @@
     "fig.tight_layout()"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "1bb7ab58-1076-4402-93c2-053610c86bea",
+   "metadata": {},
+   "source": [
+    "The energy changes are negative for the battery which starts from fully-charged and positive for a battery which starts from empty."
+   ]
+  },
   {
    "cell_type": "markdown",
    "id": "1d1ee766-f95e-4d27-b583-841875f012a7",
@@ -235,7 +243,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "complex = BatteryDataset.from_hdf('../../../tests/files/example-data/single-resistor-complex-charge_from-discharged.hdf')"
+    "complex = CellDataset.from_hdf('../../../tests/files/example-data/single-resistor-complex-charge_from-discharged.hdf')"
    ]
   },
   {
@@ -487,8 +495,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from battdat.extractors.batterydata import BDExtractor\n",
-    "xcel_data = BDExtractor().parse_to_dataframe(['../../../tests/files/batterydata/p492-13-raw.csv'])"
+    "from battdat.io.batterydata import BDReader\n",
+    "xcel_data = BDReader().read_dataset(['../../../tests/files/batterydata/p492-13-raw.csv'])"
    ]
   },
   {
@@ -572,7 +580,7 @@
      "name": "stderr",
      "output_type": "stream",
      "text": [
-      "/home/lward/Work/ROVI/battery-data-toolkit/battdat/postprocess/integral.py:101: UserWarning: Unable to clearly detect if battery started charged or discharged in cycle 6. Amount discharged is -0.00e+00 A-s, charged is 0.00e+00 A-s\n",
+      "C:\\Users\\lward\\Work\\ROVI\\battery-data-toolkit\\battdat\\postprocess\\integral.py:101: UserWarning: Unable to clearly detect if battery started charged or discharged in cycle 6. Amount discharged is -0.00e+00 A-s, charged is 0.00e+00 A-s\n",
       "  warnings.warn(f'Unable to clearly detect if battery started charged or discharged in cycle {cyc}. '\n"
      ]
     },
diff --git a/docs/user-guide/post-processing/cycle-times.ipynb b/docs/user-guide/post-processing/cycle-times.ipynb
index d1a9dea..e7819db 100644
--- a/docs/user-guide/post-processing/cycle-times.ipynb
+++ b/docs/user-guide/post-processing/cycle-times.ipynb
@@ -22,7 +22,7 @@
     "%matplotlib inline\n",
     "from matplotlib import pyplot as plt\n",
     "from battdat.postprocess.timing import CycleTimesSummarizer, TimeEnhancer\n",
-    "from battdat.data import BatteryDataset\n",
+    "from battdat.data import CellDataset\n",
     "from datetime import datetime, timedelta\n",
     "import pandas as pd"
    ]
@@ -125,7 +125,7 @@
     }
    ],
    "source": [
-    "dataset = BatteryDataset.from_hdf('../../../tests/files/example-data/single-resistor-constant-charge_from-charged.hdf')\n",
+    "dataset = CellDataset.from_hdf('../../../tests/files/example-data/single-resistor-constant-charge_from-charged.hdf')\n",
     "dataset.raw_data.tail()"
    ]
   },
@@ -300,35 +300,35 @@
        "      <td>-1.0</td>\n",
        "      <td>2.899167</td>\n",
        "      <td>0</td>\n",
-       "      <td>2024-11-07 14:15:41.336930</td>\n",
+       "      <td>2024-11-13 12:40:23.571730</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>1</th>\n",
        "      <td>-1.0</td>\n",
        "      <td>2.898333</td>\n",
        "      <td>0</td>\n",
-       "      <td>2024-11-07 14:15:44.336930</td>\n",
+       "      <td>2024-11-13 12:40:26.571730</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>2</th>\n",
        "      <td>-1.0</td>\n",
        "      <td>2.897500</td>\n",
        "      <td>0</td>\n",
-       "      <td>2024-11-07 14:15:47.336930</td>\n",
+       "      <td>2024-11-13 12:40:29.571730</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>3</th>\n",
        "      <td>-1.0</td>\n",
        "      <td>2.896667</td>\n",
        "      <td>0</td>\n",
-       "      <td>2024-11-07 14:15:50.336930</td>\n",
+       "      <td>2024-11-13 12:40:32.571730</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>4</th>\n",
        "      <td>-1.0</td>\n",
        "      <td>2.895833</td>\n",
        "      <td>0</td>\n",
-       "      <td>2024-11-07 14:15:53.336930</td>\n",
+       "      <td>2024-11-13 12:40:35.571730</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
@@ -336,11 +336,11 @@
       ],
       "text/plain": [
        "   current   voltage  cycle_number                  date_time\n",
-       "0     -1.0  2.899167             0 2024-11-07 14:15:41.336930\n",
-       "1     -1.0  2.898333             0 2024-11-07 14:15:44.336930\n",
-       "2     -1.0  2.897500             0 2024-11-07 14:15:47.336930\n",
-       "3     -1.0  2.896667             0 2024-11-07 14:15:50.336930\n",
-       "4     -1.0  2.895833             0 2024-11-07 14:15:53.336930"
+       "0     -1.0  2.899167             0 2024-11-13 12:40:23.571730\n",
+       "1     -1.0  2.898333             0 2024-11-13 12:40:26.571730\n",
+       "2     -1.0  2.897500             0 2024-11-13 12:40:29.571730\n",
+       "3     -1.0  2.896667             0 2024-11-13 12:40:32.571730\n",
+       "4     -1.0  2.895833             0 2024-11-13 12:40:35.571730"
       ]
      },
      "execution_count": 6,
@@ -349,7 +349,7 @@
     }
    ],
    "source": [
-    "dataset.raw_data = timeseries\n",
+    "dataset.datasets['raw_data'] = timeseries\n",
     "dataset.raw_data.head()"
    ]
   },
diff --git a/docs/user-guide/post-processing/index.rst b/docs/user-guide/post-processing/index.rst
index fdead8a..983b921 100644
--- a/docs/user-guide/post-processing/index.rst
+++ b/docs/user-guide/post-processing/index.rst
@@ -6,11 +6,11 @@ but the other properties which are derived from them may be missing.
 The battery data toolkit provides "post-processing" classes which
 add compute these derived data sources.
 
-All post-processing tools are based on the ``BaseFeatureComputer`` class
-and, as a result, provide a ``compute_features`` function that adds
+All post-processing tools are based on the :class:`~battdat.postprocess.base.BaseFeatureComputer` class
+and, as a result, provide a :meth:`~battdat.postprocess.base.BaseFeatureComputer.compute_features` function that adds
 new information to a battery dataset.
 Use them by first creating the tool and invoking that method with
-a ``BatteryDataset``:
+a :class:`~battdat.data.BatteryDataset`:
 
 .. code-block:: python
 
@@ -22,10 +22,14 @@ will be returned from the function.
 
 The feature computers fall into two categories:
 
-- ``RawDataEnhancer``, which add information to the raw data as a function of time
-- ``CycleSummarizer``, which summarize the raw data and add new columns to the ``cycle_stats``
+- :class:`~battdat.postprocess.base.RawDataEnhancer`, which add information to the raw data as a function of time
+- :class:`~battdat.postprocess.base.CycleSummarizer`, which summarize the raw data and add new columns to the ``cycle_stats``
 
 
+.. note::
+
+    Post-processing is only supported for :class:`battdat.data.CellDataset` for now.
+
 Integral Quantities
 -------------------
 
diff --git a/notebooks/extract-from-batterydata.ipynb b/notebooks/extract-from-batterydata.ipynb
index 64d98ad..e39b124 100644
--- a/notebooks/extract-from-batterydata.ipynb
+++ b/notebooks/extract-from-batterydata.ipynb
@@ -20,7 +20,7 @@
    "source": [
     "%matplotlib inline\n",
     "from matplotlib import pyplot as plt\n",
-    "from battdat.extractors.batterydata import BDExtractor\n",
+    "from battdat.io.batterydata import BDReader\n",
     "from pathlib import Path\n",
     "import pandas as pd"
    ]
@@ -521,7 +521,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "extractor = BDExtractor()"
+    "extractor = BDReader()"
    ]
   },
   {
@@ -533,8 +533,8 @@
     {
      "data": {
       "text/plain": [
-       "[['../tests/files/batterydata/p492-13-raw.csv',\n",
-       "  '../tests/files/batterydata/p492-13-summary.csv']]"
+       "[['..\\\\tests\\\\files\\\\batterydata\\\\p492-13-raw.csv',\n",
+       "  '..\\\\tests\\\\files\\\\batterydata\\\\p492-13-summary.csv']]"
       ]
      },
      "execution_count": 6,
@@ -571,7 +571,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "data = extractor.parse_to_dataframe(groups[0])"
+    "data = extractor.read_dataset(groups[0])"
    ]
   },
   {
@@ -1186,7 +1186,7 @@
     }
    ],
    "source": [
-    "dataset = extractor.parse_to_dataframe(groups[0])\n",
+    "dataset = extractor.read_dataset(groups[0])\n",
     "dataset.raw_data"
    ]
   },


Test patch:
diff --git a/tests/consistency/test_sign.py b/tests/consistency/test_sign.py
new file mode 100644
index 0000000..7e4406c
--- /dev/null
+++ b/tests/consistency/test_sign.py
@@ -0,0 +1,36 @@
+import numpy as np
+import pandas as pd
+from pytest import fixture
+
+from battdat.data import CellDataset
+from battdat.consistency.current import SignConventionChecker
+
+
+@fixture()
+def example_dataset():
+    # Make a rest period followed by a charge where the voltage increases
+    times = np.linspace(0, 1800, 256)
+    current = np.zeros_like(times)
+    current[128:] = 1.
+
+    voltage = np.ones_like(times)
+    voltage[128:] = np.linspace(1., 1.3, 128)
+
+    return CellDataset(
+        raw_data=pd.DataFrame({
+            'test_time': times,
+            'current': current,
+            'voltage': voltage
+        })
+    )
+
+
+def test_sign_checker(example_dataset):
+    chcker = SignConventionChecker()
+    result = chcker.check(example_dataset)
+    assert len(result) == 0
+
+    # Make sure swapping the sign breaks things
+    example_dataset.datasets['raw_data']['current'] *= -1
+    result = chcker.check(example_dataset)
+    assert len(result) == 1
diff --git a/tests/files/example-data/single-resistor-constant-charge_from-charged.hdf b/tests/files/example-data/single-resistor-constant-charge_from-charged.hdf
index 6e142e9..67b64ba 100644
Binary files a/tests/files/example-data/single-resistor-constant-charge_from-charged.hdf and b/tests/files/example-data/single-resistor-constant-charge_from-charged.hdf differ
diff --git a/tests/files/example-data/single-resistor-constant-charge_from-discharged.hdf b/tests/files/example-data/single-resistor-constant-charge_from-discharged.hdf
index b32ab6c..8bcb100 100644
Binary files a/tests/files/example-data/single-resistor-constant-charge_from-discharged.hdf and b/tests/files/example-data/single-resistor-constant-charge_from-discharged.hdf differ
diff --git a/tests/io/test_cell_consistency.py b/tests/io/test_cell_consistency.py
new file mode 100644
index 0000000..e25ed73
--- /dev/null
+++ b/tests/io/test_cell_consistency.py
@@ -0,0 +1,30 @@
+"""Run consistency checks for data corresponding to cells"""
+from battdat.consistency.current import SignConventionChecker
+
+from pytest import mark
+
+from battdat.data import CellDataset
+from battdat.io.arbin import ArbinReader
+from battdat.io.batterydata import BDReader
+from battdat.io.hdf import HDF5Reader
+
+checkers = [
+    SignConventionChecker()
+]
+
+
+@mark.parametrize(
+    'reader,example_data',
+    [(ArbinReader(), ['arbin_example.csv']),
+     (BDReader(), ['batterydata/p492-13-raw.csv']),
+     (HDF5Reader(), 'example-data/single-resistor-complex-charge_from-discharged.hdf')]
+)
+def test_consistency(reader, example_data, file_path):
+    reader.output_class = CellDataset
+    dataset = reader.read_dataset(
+        [file_path / p for p in example_data] if isinstance(example_data, list) else file_path / example_data
+    )
+
+    for checker in checkers:
+        warnings = checker.check(dataset)
+        assert len(warnings) == 0, warnings
diff --git a/tests/test_stream.py b/tests/test_stream.py
index e57c2aa..cd5b20c 100644
--- a/tests/test_stream.py
+++ b/tests/test_stream.py
@@ -37,7 +37,7 @@ def test_stream_by_rows(example_h5_path):
 
 def test_stream_by_cycles(example_h5_path):
     test_data = CellDataset.from_hdf(example_h5_path)
-    cycle_iter = iterate_cycles_from_file(example_h5_path, chunksize=4)  # Small to make sure we split cycles across chunks
+    cycle_iter = iterate_cycles_from_file(example_h5_path)
     for streamed, (_, original) in zip_longest(cycle_iter, test_data.raw_data.groupby('cycle_number')):
         assert streamed is not None
         assert original is not None