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

Issue:
Consolidate import and export to a single, io module
Avoid the code which works on the same file format from being split between different modules


Discussion / hints:

Fix patch:
diff --git a/battdat/exporters/__init__.py b/battdat/exporters/__init__.py
deleted file mode 100644
index f908c70..0000000
--- a/battdat/exporters/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""Tools for exporting battery data into a format usable by other tools"""
diff --git a/battdat/exporters/base.py b/battdat/exporters/base.py
deleted file mode 100644
index 380d2b0..0000000
--- a/battdat/exporters/base.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""Interface definitions"""
-from pathlib import Path
-
-from battdat.data import CellDataset
-
-
-class DatasetExporter:
-    """Tool which exports data from a :class:`~battdat.data.BatteryDataset` to disk in a specific format
-
-    Implementing an Exporter
-    ------------------------
-    """
-
-    def export(self, dataset: CellDataset, path: Path):
-        """Write the dataset to disk in a specific path
-
-        All files from the dataset must be placed in the provided directory
-
-        Args:
-            dataset: Dataset to be exported
-            path: Output path
-        """
-        raise NotImplementedError()
diff --git a/battdat/extractors/__init__.py b/battdat/extractors/__init__.py
deleted file mode 100644
index 4c4e5be..0000000
--- a/battdat/extractors/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""Data extraction tools"""
diff --git a/battdat/extractors/batteryarchive.py b/battdat/extractors/batteryarchive.py
deleted file mode 100644
index c2e3225..0000000
--- a/battdat/extractors/batteryarchive.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from collections import defaultdict
-from typing import Union, List, Iterator, Tuple, Optional
-
-import pandas as pd
-
-from .base import BatteryDataExtractor
-from ..data import CellDataset
-
-
-class BatteryArchiveExtractor(BatteryDataExtractor):
-    """Convert the CSV file(s) from BatteryArchive into HDF5 format"""
-
-    def group(self, files: Union[str, List[str]], directories: List[str] = None,
-              context: dict = None) -> Iterator[Tuple[str, ...]]:
-        # Special case: If given a single file, wrap it as a list
-        if isinstance(files, str):
-            files = [files]
-
-        # Filter-out non CSV files
-        csvs = [x for x in files if x.lower().endswith('.csv')]
-
-        # Pair files in the same directory that begin with the same prefix
-        paired_files = defaultdict(list)
-        for file in csvs:
-            file_lower = file.lower()
-
-            # if it is denoted with BA's "timeseries" or "cycle_data" postfix,
-            #  get the prefix and wait until we find its mate
-            if file_lower.endswith('cycle_data.csv') or file_lower.endswith('timeseries.csv'):
-                prefix = file[:-14]
-                paired_files[prefix].append(file)
-
-            # otherwise, return the file alone
-            else:
-                yield file
-
-        # Return the paired groups of files
-        for pair in paired_files.values():
-            yield tuple(pair)
-
-    def parse_timeseries_to_dataframe(self, path: str) -> pd.DataFrame:
-        """Parse the time series data from a BatteryArchive CSV file
-
-        Parameters
-        ----------
-        path: str
-            Path to the CSV file
-
-        Returns
-        -------
-        ts_data: pd.DataFrame
-            Time series in a format ready for HDF5 file
-        """
-
-        # Read in the ASCII file (I found this notation works)
-        df = pd.read_csv(path)
-        df = df.rename(columns={'Test_Time (s)': 'test_time', 'Cycle_Index': 'cycle_number',
-                                'Date_Time': 'date_time', 'Current (A)': 'current',
-                                'Voltage (V)': 'voltage', 'Cell_Temperature (C)': 'temperature'})
-
-        # Change the datatypes for the cycle_number
-        df['cycle_number'] = df['cycle_number'].astype(int)
-
-        return df
-
-    def parse_to_dataframe(self, group: List[str], metadata: Optional[dict]):
-        # TODO (wardlt): Do _something_ with the cycle-level data
-        if len(group) == 1:
-            ts_path = group[0]
-        else:
-            ts_path = [x for x in group if 'timeseries' in x][0]
-
-        ts_data = self.parse_timeseries_to_dataframe(ts_path)
-        return CellDataset(raw_data=ts_data, metadata=metadata)
diff --git a/battdat/extractors/tIVT.py b/battdat/extractors/tIVT.py
deleted file mode 100644
index 55a2a7a..0000000
--- a/battdat/extractors/tIVT.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Extractor for .npy tIVT-format files"""
-from typing import Union, List, Iterator, Tuple
-from logging import getLogger
-
-import numpy as np
-import pandas as pd
-
-from battdat.extractors.base import BatteryDataExtractor
-from battdat.schemas.column import ChargingState
-from battdat.utils import drop_cycles
-from battdat.postprocess.tagging import add_steps, add_method, add_substeps
-from battdat.postprocess.timing import compute_capacity_energy
-
-from scipy.interpolate import interp1d
-from scipy.optimize import differential_evolution
-
-logger = getLogger(__name__)
-
-
-class TIVTExtractor(BatteryDataExtractor):
-    """Parser for reading from .npy tIVT files
-
-    Expects the files to be in .npy format
-    """
-
-    def group(self, files: Union[str, List[str]], directories: List[str] = None,
-              context: dict = None) -> Iterator[Tuple[str, ...]]:
-        for file in files:
-            if file.lower().endswith('.npy'):
-                yield file
-
-    def generate_dataframe(self, file: str, file_number: int = 0, start_cycle: int = 0,
-                           start_time: float = 0) -> pd.DataFrame:
-        # load .npy file
-        raw = np.load(file)
-        t = raw[:, 0] - raw[0, 0]
-        current = raw[:, 1]
-        voltage = raw[:, 2]
-        temp = raw[:, 3]
-
-        # create dataframe
-        df_out = pd.DataFrame()
-        df_out['test_time'] = t
-        df_out['current'] = current
-        df_out['voltage'] = voltage
-        df_out['temperature'] = temp
-
-        # calculate cycles
-        spd = 3600*24
-        f = interp1d(t, current, kind='linear', fill_value='extrapolate')
-
-        def err(offset):
-            day_sts = np.arange(0, t.max(), spd)[:-1] + offset
-            return -1*np.sum(f(day_sts))
-
-        res = differential_evolution(err, [(0, spd)])
-        offset = res.x[0] + 0.25*spd
-        day_sts = np.arange(offset, t.max(), spd)
-        day_sts_indx = [np.argmin(np.abs(t - day_st)) for day_st in day_sts]
-        if np.unique(day_sts_indx).size < day_sts.size:
-            logger.warning('repeated index')
-        day_sts_bool = np.zeros(t.shape)
-        day_sts_bool[day_sts_indx] = 1
-        df_out['cycle_number'] = np.cumsum(day_sts_bool).astype('int64')
-
-        # Drop the duplicate rows
-        df_out = drop_cycles(df_out)
-
-        # Determine whether the battery is charging or discharging:
-        #   0 is rest, 1 is charge, -1 is discharge
-        # TODO (wardlt): This function should move to post-processing
-        def compute_state(x):
-            if abs(x) < self.eps:
-                return ChargingState.hold
-            return ChargingState.charging if x > 0 else ChargingState.discharging
-        df_out['state'] = df_out['current'].apply(compute_state)
-
-        # Determine the method uses to control charging/discharging
-        add_steps(df_out)
-        add_method(df_out)
-        add_substeps(df_out)
-
-        # Add capacity and energy calculations
-        df_out = compute_capacity_energy(df_out)
-
-        return df_out
diff --git a/battdat/io/__init__.py b/battdat/io/__init__.py
new file mode 100644
index 0000000..60794e6
--- /dev/null
+++ b/battdat/io/__init__.py
@@ -0,0 +1,2 @@
+"""Tools for reading external formats into :class:`~battdat.data.BatteryDataset` objects
+and exporting data to disk."""
diff --git a/battdat/extractors/arbin.py b/battdat/io/arbin.py
similarity index 87%
rename from battdat/extractors/arbin.py
rename to battdat/io/arbin.py
index 688c78c..547193f 100644
--- a/battdat/extractors/arbin.py
+++ b/battdat/io/arbin.py
@@ -4,13 +4,13 @@
 import numpy as np
 import pandas as pd
 
-from battdat.extractors.base import BatteryDataExtractor
+from battdat.io.base import CycleTestReader
 from battdat.schemas.column import ChargingState
 from battdat.utils import drop_cycles
 from battdat.postprocess.tagging import AddMethod, AddSteps, AddSubSteps
 
 
-class ArbinExtractor(BatteryDataExtractor):
+class ArbinReader(CycleTestReader):
     """Parser for reading from Arbin-format files
 
     Expects the files to be in CSV format"""
@@ -21,8 +21,8 @@ def group(self, files: Union[str, List[str]], directories: List[str] = None,
             if file.lower().endswith('.csv'):
                 yield file
 
-    def generate_dataframe(self, file: str, file_number: int = 0, start_cycle: int = 0,
-                           start_time: float = 0) -> pd.DataFrame:
+    def read_file(self, file: str, file_number: int = 0, start_cycle: int = 0,
+                  start_time: float = 0) -> pd.DataFrame:
 
         # Read the file and rename the file
         df = pd.read_csv(file)
@@ -48,7 +48,7 @@ def generate_dataframe(self, file: str, file_number: int = 0, start_cycle: int =
         #   0 is rest, 1 is charge, -1 is discharge
         # TODO (wardlt): This function should move to post-processing
         def compute_state(x):
-            if abs(x) < self.eps:
+            if abs(x) < 1e-6:
                 return ChargingState.hold
             return ChargingState.charging if x > 0 else ChargingState.discharging
 
diff --git a/battdat/exporters/ba.py b/battdat/io/ba.py
similarity index 97%
rename from battdat/exporters/ba.py
rename to battdat/io/ba.py
index b32ab11..ba8371c 100644
--- a/battdat/exporters/ba.py
+++ b/battdat/io/ba.py
@@ -11,9 +11,9 @@
 import numpy as np
 import pandas as pd
 
-from .base import DatasetExporter
-from ..data import CellDataset
-from ..schemas import BatteryMetadata
+from battdat.io.base import DatasetWriter
+from battdat.data import CellDataset
+from battdat.schemas import BatteryMetadata
 
 logger = logging.getLogger(__name__)
 
@@ -60,7 +60,7 @@
 # TODO (wardlt): Reconsider saving in CSV. Parquet would preserve data types
 
 @dataclass
-class BatteryArchiveExporter(DatasetExporter):
+class BatteryArchiveWriter(DatasetWriter):
     """Export data into CSV files that follow the format definitions used in BatteryArchive
 
     The exporter writes files for each table in the
diff --git a/battdat/extractors/base.py b/battdat/io/base.py
similarity index 53%
rename from battdat/extractors/base.py
rename to battdat/io/base.py
index dd57234..6282520 100644
--- a/battdat/extractors/base.py
+++ b/battdat/io/base.py
@@ -1,35 +1,46 @@
-"""Base class for a battery data extractor"""
-from typing import List, Optional, Union, Iterator
+"""Base class for a battery data import and export tools"""
+from typing import List, Optional, Union, Iterator, Sequence
 from pathlib import Path
 import os
 
 import pandas as pd
 
-from battdat.data import CellDataset
+from battdat.data import CellDataset, BatteryDataset
 from battdat.schemas import BatteryMetadata
 
+PathLike = Union[str, Path]
 
-class BatteryDataExtractor:
-    """Base class for a data extractors
 
-    Implementing an Extractor
-    -------------------------
+class DatasetReader:
+    """Base class for tools which read battery data as a :class:`~battdat.data.BatteryDataset`
 
-    The minimum is to define the `generate_dataframe` method, which produces
-    a data-frame containing the time-series data with standardized column names.
+    All readers must implement a function which receives battery metadata as input and produces
+    a completed :class:`battdat.data.BatteryDataset` as an output.
 
-    If the data format contains additional metadata or cycle-level features,
-    override the :meth:`parse_to_dataframe` such that it adds such data
-    after parsing the time-series results.
+    Subclasses provide additional suggested operations useful when working with data from
+    common sources (e.g., file systems, web APIs)
+    """
+
+    def read_dataset(self, metadata: Optional[Union[BatteryMetadata, dict]] = None, **kwargs) -> BatteryDataset:
+        """Parse a set of  files into a Pandas dataframe
 
-    Provide an :meth:`identify_files` or :meth:`group` function to find related files
-    if data are often split into multiple files.
+        Args:
+            metadata: Metadata for the battery
+        Returns:
+            Dataset holding all available information about the dataset
+        """
+        raise NotImplementedError()
+
+
+class DatasetFileReader(DatasetReader):
+    """Tool which reads datasets written to files
+
+    Provide an :meth:`identify_files` to filter out files likely to be in this format,
+    or :meth:`group` function to find related file if data are often split into multiple files.
     """
-    def __init__(self, eps: float = 1e-10):
-        self.eps = eps  # TODO (wardlt): Move this from extractor to post-processing logic
 
-    def identify_files(self, path: str, context: dict = None) -> Iterator[tuple[str]]:
-        """Identify all groups of files likely to be compatible with this extractor
+    def identify_files(self, path: PathLike, context: dict = None) -> Iterator[tuple[PathLike]]:
+        """Identify all groups of files likely to be compatible with this reader
 
         Uses the :meth:`group` function to determine groups of files that should be parsed together.
 
@@ -50,13 +61,15 @@ def identify_files(self, path: str, context: dict = None) -> Iterator[tuple[str]
             for group in self.group(files, dirs, context):
                 yield group
 
-    def group(self, files: Union[str, List[str]], directories: List[str] = None,
-              context: dict = None) -> Iterator[tuple[str, ...]]:
+    def group(self,
+              files: Union[PathLike, List[PathLike]],
+              directories: List[PathLike] = None,
+              context: dict = None) -> Iterator[tuple[PathLike, ...]]:
         """Identify a groups of files and directories that should be parsed together
 
         Will create groups using only the files and directories included as input.
 
-        The files of files are _all_ files that could be read by this extractor,
+        The files of files are *all* files that could be read by this extractor,
         which may include many false positives.
 
         Args:
@@ -76,8 +89,18 @@ def group(self, files: Union[str, List[str]], directories: List[str] = None,
         for f in files:
             yield f,
 
-    def generate_dataframe(self, file: str, file_number: int = 0, start_cycle: int = 0,
-                           start_time: int = 0) -> pd.DataFrame:
+
+class CycleTestReader(DatasetFileReader):
+    """Template class for reading the files output by battery cell cyclers
+
+    Adds logic for reading cycling time series from a list of files.
+    """
+
+    def read_file(self,
+                  file: str,
+                  file_number: int = 0,
+                  start_cycle: int = 0,
+                  start_time: int = 0) -> pd.DataFrame:
         """Generate a DataFrame containing the data in this file
 
         The dataframe will be in our standard format
@@ -93,7 +116,7 @@ def generate_dataframe(self, file: str, file_number: int = 0, start_cycle: int =
         """
         raise NotImplementedError()
 
-    def parse_to_dataframe(self, group: List[str], metadata: Optional[Union[BatteryMetadata, dict]] = None) -> CellDataset:
+    def read_dataset(self, group: Sequence[PathLike] = (), metadata: Optional[BatteryMetadata] = None) -> CellDataset:
         """Parse a set of  files into a Pandas dataframe
 
         Args:
@@ -104,7 +127,7 @@ def parse_to_dataframe(self, group: List[str], metadata: Optional[Union[BatteryM
             DataFrame containing the information from all files
         """
 
-        # Initialize counters for the cycle numbers, etc.. Used to determine offsets for the
+        # Initialize counters for the cycle numbers, etc., Used to determine offsets for the files read
         start_cycle = 0
         start_time = 0
 
@@ -113,7 +136,7 @@ def parse_to_dataframe(self, group: List[str], metadata: Optional[Union[BatteryM
         output_dfs = []
         for file_number, file in enumerate(group):
             # Read the file
-            df_out = self.generate_dataframe(file, file_number, start_cycle, start_time)
+            df_out = self.read_file(file, file_number, start_cycle, start_time)
             output_dfs.append(df_out)
 
             # Increment the start cycle and time to determine starting point of next file
@@ -125,3 +148,18 @@ def parse_to_dataframe(self, group: List[str], metadata: Optional[Union[BatteryM
 
         # Attach the metadata and return the data
         return CellDataset(raw_data=df_out, metadata=metadata)
+
+
+class DatasetWriter:
+    """Tool which exports data from a :class:`~battdat.data.BatteryDataset` to disk in a specific format"""
+
+    def export(self, dataset: BatteryDataset, path: Path):
+        """Write the dataset to disk in a specific path
+
+        All files from the dataset must be placed in the provided directory
+
+        Args:
+            dataset: Dataset to be exported
+            path: Output path
+        """
+        raise NotImplementedError()
diff --git a/battdat/extractors/batterydata.py b/battdat/io/batterydata.py
similarity index 95%
rename from battdat/extractors/batterydata.py
rename to battdat/io/batterydata.py
index 846f2a3..1a16f8e 100644
--- a/battdat/extractors/batterydata.py
+++ b/battdat/io/batterydata.py
@@ -9,9 +9,9 @@
 
 import pandas as pd
 
-from .base import BatteryDataExtractor
-from ..data import CellDataset
-from ..schemas import BatteryMetadata, BatteryDescription
+from battdat.io.base import DatasetFileReader
+from battdat.data import CellDataset
+from battdat.schemas import BatteryMetadata, BatteryDescription
 
 _fname_match = re.compile(r'(?P<name>[-\w]+)-(?P<type>summary|raw)\.csv')
 
@@ -165,7 +165,7 @@ def convert_eis_data(input_df: pd.DataFrame) -> pd.DataFrame:
 
 
 @dataclass
-class BDExtractor(BatteryDataExtractor):
+class BDReader(DatasetFileReader):
     """Read data from the batterydata.energy.gov CSV format
 
     Every cell in batterydata.energy.gov is stored as two separate CSV files for each battery,
@@ -187,8 +187,8 @@ def group(self, files: Union[str, List[str]], directories: List[str] = None,
 
         yield from groups.values()
 
-    def parse_to_dataframe(self, group: List[str],
-                           metadata: Optional[Union[BatteryMetadata, dict]] = None) -> CellDataset:
+    def read_dataset(self, group: List[str],
+                     metadata: Optional[Union[BatteryMetadata, dict]] = None) -> CellDataset:
         # Make an empty metadata if none available
         if metadata is None:
             metadata = BatteryMetadata()
@@ -219,5 +219,4 @@ def parse_to_dataframe(self, group: List[str],
                 raise ValueError(f'Data type unrecognized: {data_type}')
 
         # Separate out the EIS data, if possible
-
         return CellDataset(raw_data=raw_data, cycle_stats=cycle_stats, eis_data=eis_data, metadata=metadata)
diff --git a/battdat/extractors/maccor.py b/battdat/io/maccor.py
similarity index 89%
rename from battdat/extractors/maccor.py
rename to battdat/io/maccor.py
index e0027f4..a72cb42 100644
--- a/battdat/extractors/maccor.py
+++ b/battdat/io/maccor.py
@@ -5,14 +5,14 @@
 import pandas as pd
 import numpy as np
 
-from battdat.extractors.base import BatteryDataExtractor
+from battdat.io.base import DatasetFileReader, CycleTestReader
 from battdat.schemas.column import ChargingState
 from battdat.postprocess.tagging import AddMethod, AddSteps, AddSubSteps
 from battdat.utils import drop_cycles
 
 
-class MACCORExtractor(BatteryDataExtractor):
-    """Parser for reading from Arbin-format files
+class MACCORReader(CycleTestReader, DatasetFileReader):
+    """Parser for reading from MACCOR-format files
 
     Expects the files to be ASCII files with a .### extension.
     The :meth:`group` operation will consolidate files such that all with
@@ -35,8 +35,8 @@ def group(self, files: Union[str, List[str]], directories: List[str] = None,
         for prefix, group in itertools.groupby(split_filenames, key=lambda x: x[0]):
             yield tuple('.'.join(x) for x in group)
 
-    def generate_dataframe(self, file: str, file_number: int = 0, start_cycle: int = 0,
-                           start_time: int = 0) -> pd.DataFrame:
+    def read_file(self, file: str, file_number: int = 0, start_cycle: int = 0,
+                  start_time: int = 0) -> pd.DataFrame:
 
         # Read in the ASCII file (I found this notation works)
         df = pd.read_csv(file, skiprows=1, engine='python', sep='\t', index_col=False, encoding="ISO-8859-1")
diff --git a/docs/source/exporters.rst b/docs/source/exporters.rst
deleted file mode 100644
index ee1c225..0000000
--- a/docs/source/exporters.rst
+++ /dev/null
@@ -1,24 +0,0 @@
-Exporters (``battdat.exporters``)
-=================================
-
-.. automodule:: battdat.exporters
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-Base Class (``b.exporters.base``)
----------------------------------
-
-.. automodule:: battdat.exporters.base
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-
-Battery Archive (``b.exporters.ba``)
-------------------------------------
-
-.. automodule:: battdat.exporters.ba
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/source/extractors.rst b/docs/source/extractors.rst
deleted file mode 100644
index ad7676c..0000000
--- a/docs/source/extractors.rst
+++ /dev/null
@@ -1,31 +0,0 @@
-Extractors (``battdat.extractors``)
-===================================
-
-.. automodule:: battdat.extractors
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-Base Class (``b.extractors.base``)
-----------------------------------
-
-.. automodule:: battdat.extractors.base
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-Arbin (``b.extractors.arbin``)
-------------------------------
-
-.. automodule:: battdat.extractors.arbin
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-Battery Data Hub (``b.extractors.batterydata``)
------------------------------------------------
-
-.. automodule:: battdat.extractors.batterydata
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/source/io.rst b/docs/source/io.rst
new file mode 100644
index 0000000..830a444
--- /dev/null
+++ b/docs/source/io.rst
@@ -0,0 +1,48 @@
+Extractors (``battdat.io``)
+===========================
+
+.. automodule:: battdat.io
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Base Classes (``b.io.base``)
+----------------------------
+
+.. automodule:: battdat.io.base
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Arbin (``b.io.arbin``)
+----------------------
+
+.. automodule:: battdat.io.arbin
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Battery Archive (``b.io.ba``)
+-----------------------------
+
+.. automodule:: battdat.io.ba
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+
+Battery Data Hub (``b.io.batterydata``)
+---------------------------------------
+
+.. automodule:: battdat.io.batterydata
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+MACCOR (``b.io.maccor``)
+------------------------
+
+.. automodule:: battdat.io.maccor
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/modules.rst b/docs/source/modules.rst
index 12ec859..e9653af 100644
--- a/docs/source/modules.rst
+++ b/docs/source/modules.rst
@@ -9,7 +9,6 @@ API documentation for each module
 
    data
    schemas
+   io
    postprocess
-   extractors
-   exporters
    streaming
diff --git a/docs/user-guide/index.rst b/docs/user-guide/index.rst
index 6ec5010..58b531b 100644
--- a/docs/user-guide/index.rst
+++ b/docs/user-guide/index.rst
@@ -11,6 +11,7 @@ or post-processing if using already-existing data.
    :caption: Contents:
 
    dataset
+   io
    formats
    schemas/index
    post-processing/index
diff --git a/docs/user-guide/io.rst b/docs/user-guide/io.rst
new file mode 100644
index 0000000..d688e9e
--- /dev/null
+++ b/docs/user-guide/io.rst
@@ -0,0 +1,64 @@
+Reading and Writing Datasets
+============================
+
+The :mod:`battdat.io` module provides tools to read and write from :class:`~battdat.data.BatteryDataset` objects.
+
+.. list-table::
+   :align: center
+   :header-rows: 1
+
+   * - Format
+     - Module
+     - Reading
+     - Writing
+   * - Arbin
+     - :mod:`~battdat.io.arbin`
+     - ✔️
+     - ✖️
+   * - Battery Archive (https://www.batteryarchive.org)
+     - :mod:`~battdat.io.ba`
+     - ✖️
+     - ✔️
+   * - Battery Data Hub (https://batterydata.energy.gov)
+     - :mod:`~battdat.io.batterydata`
+     - ✔️
+     - ✖️
+   * - MACCOR
+     - :mod:`~battdat.io.maccor`
+     - ✔️
+     - ✖️
+
+Reading Data
+------------
+
+:class:`~battdat.io.base.DatasetReader` classes provide the ability to create a dataset
+through the :class:`~battdat.io.base.DatasetReader.read_dataset` method.
+The inputs to ``read_dataset`` always include a :class:`~battdat.schemas.BatteryMetadata` object
+containing information beyond what is available in the files.
+
+Most :class:`~battdat.io.base.DatasetReader` read data from a filesystem and are based on :class:`~battdat.io.base.DatasetFileReader`.
+These readers take list of paths to data files alongside the metadata and also include methods (e.g., :meth:`~battdat.io.base.DatasetFileReader.group`) to
+find files:
+
+.. code-block:: python
+
+    from battdat.io.batterydata import BDReader
+
+    extractor = BDReader(store_all=True)
+    group = next(extractor.identify_files('./example-path/'))
+    dataset = extractor.read_dataset(group)
+
+Writing Data
+------------
+
+:class:`~battdat.io.base.DatasetWriter` classes write :class:`battdat.data.BatteryDataset` objects into forms usable by other tools.
+
+For example, the :class:`~battdat.io.ba.BatteryArchiveWriter` converts the metadata into the schema used by `Battery Archive <https://www.batteryarchive.org>`_
+and writes the data into the preferred format: CSV files no longer than 100k rows.
+
+
+.. code-block:: python
+
+    from battdat.io.ba import BatteryArchiveWriter
+    exporter = BatteryArchiveWriter()
+    exporter.export(example_data, './to-upload')


Test patch:
diff --git a/tests/exporters/test_ba.py b/tests/exporters/test_ba.py
index 7be6318..f7410a7 100644
--- a/tests/exporters/test_ba.py
+++ b/tests/exporters/test_ba.py
@@ -4,7 +4,7 @@
 
 import pandas as pd
 
-from battdat.exporters.ba import BatteryArchiveExporter
+from battdat.io.ba import BatteryArchiveWriter
 from battdat.schemas import BatteryMetadata, BatteryDescription
 from battdat.schemas.battery import ElectrodeDescription
 
@@ -23,7 +23,7 @@ def test_export(example_data, tmpdir):
 
     tmpdir = Path(tmpdir)
     tmpdir.mkdir(exist_ok=True)
-    exporter = BatteryArchiveExporter()
+    exporter = BatteryArchiveWriter()
     exporter.export(example_data, tmpdir)
 
     # Make sure the time series loaded correctly
diff --git a/tests/extractors/test_batterydata.py b/tests/extractors/test_batterydata.py
deleted file mode 100644
index 8999f8a..0000000
--- a/tests/extractors/test_batterydata.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from datetime import datetime
-
-import requests
-from pytest import fixture
-from pydantic import AnyUrl
-
-from battdat.extractors.batterydata import BDExtractor, generate_metadata
-
-
-@fixture()
-def test_files(file_path):
-    return file_path / 'batterydata'
-
-
-def test_detect_then_convert(test_files):
-    # Find two files
-    extractor = BDExtractor(store_all=False)
-    group = next(extractor.identify_files(test_files))
-    assert len(group) == 2
-
-    # Parse them
-    data = extractor.parse_to_dataframe(group)
-    assert data.metadata.name == 'p492-13'
-
-    # Test a few of columns which require conversion
-    assert data.raw_data['cycle_number'].max() == 8
-    first_measurement = datetime.fromtimestamp(data.raw_data['time'].iloc[0])
-    assert first_measurement.year == 2020
-    assert first_measurement.day == 3
-
-    # Ensure it validates
-    data.validate()
-
-
-def test_store_all(test_files):
-    """Make sure we get exactly one copy of all columns"""
-
-    # Find two files
-    extractor = BDExtractor(store_all=True)
-    group = next(extractor.identify_files(test_files))
-    data = extractor.parse_to_dataframe(group)
-
-    # Make sure we only have the renamed `cycle_number` and not original `Cycle_Index`
-    for df in [data.raw_data, data.cycle_stats]:
-        assert 'cycle_number' in df.columns
-        assert 'Cycle_Index' not in df.columns
-
-    # Make sure NREL-specific columns are stored
-    assert 'datenum_d' in data.cycle_stats.columns
-    assert 'Charge_Throughput_Ah' in data.raw_data.columns
-
-
-def test_metadata():
-    source = 'kokam-nmc-gr-75ah-accelerated-aging'  # TODO (wardlt): Spoof a dataset if this changes
-    description = requests.get(f'https://batterydata.energy.gov/api/3/action/package_show?id={source}', timeout=15).json()
-    metadata = generate_metadata(description, ('https://test.url/',))
-    assert AnyUrl('https://test.url/') in metadata.associated_ids
-    assert metadata.battery.cathode.name == 'NMC111'
diff --git a/tests/extractors/test_arbin.py b/tests/io/test_arbin.py
similarity index 63%
rename from tests/extractors/test_arbin.py
rename to tests/io/test_arbin.py
index 39d55a1..5e7638d 100644
--- a/tests/extractors/test_arbin.py
+++ b/tests/io/test_arbin.py
@@ -1,11 +1,11 @@
 """Tests related to the Arbin parser"""
 
-from battdat.extractors.arbin import ArbinExtractor
+from battdat.io.arbin import ArbinReader
 
 
 def test_validation(file_path):
     """Make sure the parser generates valid outputs"""
-    arbin = ArbinExtractor()
+    arbin = ArbinReader()
     test_file = file_path / 'arbin_example.csv'
-    data = arbin.parse_to_dataframe([test_file])
+    data = arbin.read_dataset([test_file])
     data.validate_columns(allow_extra_columns=False)
diff --git a/tests/io/test_batterydata.py b/tests/io/test_batterydata.py
new file mode 100644
index 0000000..58028e4
--- /dev/null
+++ b/tests/io/test_batterydata.py
@@ -0,0 +1,101 @@
+from datetime import datetime
+
+from pytest import fixture
+from pydantic import AnyUrl
+
+from battdat.io.batterydata import BDReader, generate_metadata
+
+example_metadata = {'cell_type': ['Pouch cell'],
+                    'creator_user_id': 'a853d711-0e37-44c9-80c9-a41d450c2da4',
+                    'date_dataset_created': '2018-08-16',
+                    'electrolyte_class_dataset': ['Organic liquid'],
+                    'id': 'ef9dec93-17a2-445a-b58e-dc3eadb1f79d',
+                    'isopen': False,
+                    'manufacturer_supplier': 'CAMP',
+                    'maximum_voltage': '4.1',
+                    'metadata_created': '2024-04-19T21:18:38.938069',
+                    'metadata_modified': '2024-04-20T00:45:59.866451',
+                    'minimum_voltage': '3',
+                    'name': 'xcel-round-2-slpc_reupload_2',
+                    'negative_electrode': ['Graphite'],
+                    'nominal_cell_capacity': '0.037',
+                    'notes': 'Single layer pouch cell from CAMP (2.5mAh/cm2) at various charge protocols (CCCV and Multi-step).',
+                    'num_resources': 35,
+                    'num_tags': 9,
+                    'onec_cell_capacity': '0.032',
+                    'organization': {'id': '67de8624-a528-43df-9b63-a65a410920bb',
+                                     'name': 'xcel',
+                                     'title': 'XCEL',
+                                     'type': 'project',
+                                     'description': 'XCEL Project ',
+                                     'image_url': '',
+                                     'created': '2023-06-08T17:38:37.007623',
+                                     'is_organization': True,
+                                     'approval_status': 'approved',
+                                     'state': 'active'},
+                    'owner_org': '67de8624-a528-43df-9b63-a65a410920bb',
+                    'poc_email_address': 'Sangwook.Kim@inl.gov',
+                    'poc_institution': ['INL'],
+                    'poc_name': 'skim',
+                    'positive_electrode': ['NMC532'],
+                    'private': False,
+                    'reference_electrode': ['No'],
+                    'separator_class': ['PP polymer'],
+                    'state': 'active',
+                    'technology': ['Li-ion'],
+                    'title': 'XCEL Round 2 SLPC',
+                    'type': 'dataset',
+                    'tags': [{'display_name': 'fast charge',
+                              'id': '04f1dafd-24f0-496e-b263-96038a9da8f8',
+                              'name': 'fast charge',
+                              'state': 'active',
+                              'vocabulary_id': None}]}
+
+
+@fixture()
+def test_files(file_path):
+    return file_path / 'batterydata'
+
+
+def test_detect_then_convert(test_files):
+    # Find two files
+    extractor = BDReader(store_all=False)
+    group = next(extractor.identify_files(test_files))
+    assert len(group) == 2
+
+    # Parse them
+    data = extractor.read_dataset(group)
+    assert data.metadata.name == 'p492-13'
+
+    # Test a few of columns which require conversion
+    assert data.raw_data['cycle_number'].max() == 8
+    first_measurement = datetime.fromtimestamp(data.raw_data['time'].iloc[0])
+    assert first_measurement.year == 2020
+    assert first_measurement.day == 3
+
+    # Ensure it validates
+    data.validate()
+
+
+def test_store_all(test_files):
+    """Make sure we get exactly one copy of all columns"""
+
+    # Find two files
+    extractor = BDReader(store_all=True)
+    group = next(extractor.identify_files(test_files))
+    data = extractor.read_dataset(group)
+
+    # Make sure we only have the renamed `cycle_number` and not original `Cycle_Index`
+    for df in [data.raw_data, data.cycle_stats]:
+        assert 'cycle_number' in df.columns
+        assert 'Cycle_Index' not in df.columns
+
+    # Make sure NREL-specific columns are stored
+    assert 'datenum_d' in data.cycle_stats.columns
+    assert 'Charge_Throughput_Ah' in data.raw_data.columns
+
+
+def test_metadata():
+    metadata = generate_metadata(example_metadata, ('https://test.url/',))
+    assert AnyUrl('https://test.url/') in metadata.associated_ids
+    assert metadata.battery.cathode.name == 'NMC532'
diff --git a/tests/extractors/test_maccor.py b/tests/io/test_maccor.py
similarity index 85%
rename from tests/extractors/test_maccor.py
rename to tests/io/test_maccor.py
index 7860c4b..6067722 100644
--- a/tests/extractors/test_maccor.py
+++ b/tests/io/test_maccor.py
@@ -1,7 +1,7 @@
 """Tests related to the MACCOR parser"""
 from pytest import fixture
 
-from battdat.extractors.maccor import MACCORExtractor
+from battdat.io.maccor import MACCORReader
 
 
 @fixture()
@@ -11,12 +11,12 @@ def test_file(file_path):
 
 @fixture()
 def extractor():
-    return MACCORExtractor()
+    return MACCORReader()
 
 
 def test_validation(extractor, test_file):
     """Make sure the parser generates valid outputs"""
-    data = extractor.parse_to_dataframe([test_file])
+    data = extractor.read_dataset([test_file])
     data.validate_columns(allow_extra_columns=False)
 
 
diff --git a/tests/postprocess/test_integral.py b/tests/postprocess/test_integral.py
index ac99be4..4bfead0 100644
--- a/tests/postprocess/test_integral.py
+++ b/tests/postprocess/test_integral.py
@@ -5,7 +5,7 @@
 import numpy as np
 
 from battdat.data import CellDataset
-from battdat.extractors.batterydata import BDExtractor
+from battdat.io.batterydata import BDReader
 from battdat.postprocess.integral import CapacityPerCycle, StateOfCharge
 
 
@@ -76,7 +76,7 @@ def test_against_battery_data_gov(file_path):
     """See if our capacities are similar to those computed in BatteryData.Energy.Gov"""
 
     cyc_id = 8
-    data = BDExtractor().parse_to_dataframe(list((file_path / 'batterydata').glob('p492*')))
+    data = BDReader().read_dataset(list((file_path / 'batterydata').glob('p492*')))
     orig_data = \
         data.cycle_stats[
             ['capacity_discharge', 'capacity_charge', 'energy_discharge', 'energy_charge']
diff --git a/tests/test_stream.py b/tests/test_stream.py
index 743aad0..8bc72ed 100644
--- a/tests/test_stream.py
+++ b/tests/test_stream.py
@@ -7,14 +7,14 @@
 from pytest import fixture, mark
 
 from battdat.data import CellDataset
-from battdat.extractors.batterydata import BDExtractor
+from battdat.io.batterydata import BDReader
 from battdat.streaming import iterate_records_from_file, iterate_cycles_from_file
 from battdat.streaming.hdf5 import HDF5Writer
 
 
 @fixture()
 def example_dataset(file_path):
-    data = BDExtractor().parse_to_dataframe([file_path / 'batterydata' / 'p492-13-raw.csv'])
+    data = BDReader().read_dataset([file_path / 'batterydata' / 'p492-13-raw.csv'])
     data.metadata.name = 'test_name'
     return data