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

Issue:
Change attributes for column data to be single values and not lists
Makes it possible to validate individual records when working with streaming data
Permit multiple time series per datasets
We may want to have a time series for each component of a large assembly, such as different cells in a module or the power conversion whatnot. 

This means we should
- Allow the battery datasets to include more than a few attributes
- Allow the HDF5/parquet formats to store metadata _per timeseries_. That will include the "what component is this" and the "what do the columns mean"


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 2beef0a..1169a85 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -10,10 +10,10 @@ jobs:
 
     steps:
       - uses: actions/checkout@v2
-      - name: Set up Python 3.9
+      - name: Set up Python 3.10
         uses: actions/setup-python@v2
         with:
-          python-version: 3.9
+          python-version: '3.10'
       - name: Install package
         run: |
           pip install -e .[test]
diff --git a/batdata/data.py b/batdata/data.py
index ba81c87..7662f0f 100644
--- a/batdata/data.py
+++ b/batdata/data.py
@@ -4,27 +4,27 @@
 import warnings
 from pathlib import Path
 from datetime import datetime
-from typing import Union, Optional, Collection, List, Dict, Type, Set, Iterator, Tuple
+from typing import Union, Optional, Collection, List, Dict, Set, Iterator, Tuple
 
 from pandas import HDFStore
-from pandas.io.common import stringify_path
 from pydantic import BaseModel, ValidationError
 from pyarrow import parquet as pq
 from pyarrow import Table
+from tables import Group
 import pandas as pd
 import h5py
 
 from batdata.schemas import BatteryMetadata
-from batdata.schemas.cycling import RawData, CycleLevelData, ColumnSchema
+from batdata.schemas.column import RawData, CycleLevelData, ColumnSchema
 from batdata.schemas.eis import EISData
 from batdata import __version__
 
-_subsets: Dict[str, Type[ColumnSchema]] = {
-    'raw_data': RawData,
-    'cycle_stats': CycleLevelData,
-    'eis_data': EISData
+_default_schemas = {
+    'raw_data': RawData(),
+    'cycle_stats': CycleLevelData(),
+    'eis_data': EISData()
 }
-"""Mapping between subset and schema"""
+"""Mapping between pre-defined datasets and schema"""
 
 logger = logging.getLogger(__name__)
 
@@ -65,11 +65,14 @@ class BatteryDataset:
 
     metadata: BatteryMetadata
     """Metadata for the battery construction and testing"""
+    schemas: Dict[str, ColumnSchema]
+    """Schemas for the data in each of the constituent data frames"""
 
     def __init__(self, metadata: Union[BatteryMetadata, dict] = None,
                  raw_data: Optional[pd.DataFrame] = None,
                  cycle_stats: Optional[pd.DataFrame] = None,
-                 eis_data: Optional[pd.DataFrame] = None):
+                 eis_data: Optional[pd.DataFrame] = None,
+                 schemas: Optional[Dict[str, ColumnSchema]] = None):
         """
 
         Args:
@@ -77,12 +80,16 @@ def __init__(self, metadata: Union[BatteryMetadata, dict] = None,
             raw_data: Time-series data of the battery state
             cycle_stats: Summaries of each cycle
             eis_data: EIS data taken at multiple times
+            schemas: Schemas describing each of the tabular datasets
         """
         if metadata is None:
             metadata = {}
         elif isinstance(metadata, BaseModel):
             metadata = metadata.model_dump()
 
+        # Provide schemas for each of the columns
+        self.schemas = _default_schemas if schemas is None else schemas
+
         # Warn if the version of the metadata is different
         version_mismatch = False
         if (supplied_version := metadata.get('version', __version__)) != __version__:
@@ -110,7 +117,7 @@ def validate_columns(self, allow_extra_columns: bool = True):
         Raises
             (ValueError): If the dataset fails validation
         """
-        for attr_name, schema in _subsets.items():
+        for attr_name, schema in self.schemas.items():
             data = getattr(self, attr_name)
             if data is not None:
                 schema.validate_dataframe(data, allow_extra_columns)
@@ -128,20 +135,11 @@ def validate(self) -> List[str]:
         self.validate_columns()
         output = []
 
-        # Check whether there are undocumented columns
-        def _find_undefined_columns(data: pd.DataFrame, column_schema: Type[ColumnSchema]) -> List[str]:
-            """Get the list of columns which are not defined in the schema"""
-
-            cols = set(data.columns).difference(column_schema.model_fields)
-            return list(cols)
-
-        for attr_name, schema in _subsets.items():
+        for attr_name, schema in self.schemas.items():
             data = getattr(self, attr_name)
-            defined_columns = getattr(self.metadata, f'{attr_name}_columns')
             if data is not None:
-                new_cols = _find_undefined_columns(data, schema)
-                undefined = set(new_cols).difference(defined_columns.keys())
-                output.extend([f'Undefined column, {u}, in {attr_name}. Add a description into metadata.{attr_name}_columns'
+                undefined = set(data.columns).difference(schema.column_names)
+                output.extend([f'Undefined column, {u}, in {attr_name}. Add a description into schemas.{attr_name}.extra_columns'
                                for u in undefined])
 
         return output
@@ -169,38 +167,43 @@ def to_batdata_hdf(self,
         if isinstance(path_or_buf, (str, Path)) and (Path(path_or_buf).is_file() and not append):
             Path(path_or_buf).unlink()
 
-        # Store the various datasets
-        #  Note that we use the "table" format to allow for partial reads / querying
-        for key in _subsets:
-            data = getattr(self, key)
-            if data is not None:
-                if prefix is not None:
-                    key = f'{prefix}_{key}'
-                data.to_hdf(path_or_buf, key, complevel=complevel,
-                            complib=complib, append=False, format='table',
-                            index=False)
-
         # Create logic for adding metadata
-        def add_metadata(f: HDFStore):
+        def add_metadata(f: Group, m: BaseModel):
             """Put the metadata in a standard location at the root of the HDF file"""
-            metadata = self.metadata.model_dump_json()
-            if append and 'metadata' in f.root._v_attrs:
-                existing_metadata = f.root._v_attrs.metadata
+            metadata = m.model_dump_json()
+            if append and 'metadata' in f._v_attrs:
+                existing_metadata = f._v_attrs.metadata
                 if metadata != existing_metadata:
                     warnings.warn('Metadata already in HDF5 differs from new metadata')
-            f.root._v_attrs.metadata = metadata
-            f.root._v_attrs.schema = self.metadata.model_json_schema()
+            f._v_attrs.metadata = metadata
+            f._v_attrs.json_schema = m.model_json_schema()
 
-        # Apply the metadata addition function
-        path_or_buf = stringify_path(path_or_buf)
-        if isinstance(path_or_buf, (str, Path)):
-            with HDFStore(
-                    path_or_buf, mode='a', complevel=complevel, complib=complib
-            ) as store:
-                add_metadata(store)
-                store.flush()
+        # Open the store
+        if is_store := isinstance(path_or_buf, HDFStore):
+            store = path_or_buf
         else:
-            add_metadata(path_or_buf)
+            store = HDFStore(path_or_buf, complevel=complevel, complib=complib)
+
+        try:
+            # Store the various datasets
+            #  Note that we use the "table" format to allow for partial reads / querying
+            for key, schema in self.schemas.items():
+                data = getattr(self, key)
+                if data is not None:
+                    if prefix is not None:
+                        key = f'{prefix}_{key}'
+                    data.to_hdf(path_or_buf, key=key, complevel=complevel,
+                                complib=complib, append=False, format='table',
+                                index=False)
+
+                    # Write the schema
+                    add_metadata(store.root[key], schema)
+
+            # Store the high-level metadata
+            add_metadata(store.root, self.metadata)
+        finally:
+            if not is_store:
+                store.close()  # Close the store if we opened it
 
     @classmethod
     def from_batdata_hdf(cls,
@@ -223,47 +226,54 @@ def from_batdata_hdf(cls,
         # Determine which datasets to read
         read_all = False
         if subsets is None:
-            subsets = _subsets
+            subsets = _default_schemas
             read_all = True
 
-        # Determine which prefix to read, if an int is provided
-        if isinstance(prefix, int):
-            _, prefixes = cls.inspect_batdata_hdf(path_or_buf)
-            prefix = sorted(prefixes)[prefix]
+        # Open the store
+        if is_store := isinstance(path_or_buf, HDFStore):
+            store = path_or_buf
+        else:
+            store = HDFStore(path_or_buf, mode='r')
 
-        data = {}
-        for subset in subsets:
-            # Throw error if user provides an unknown subset name
-            if subset not in _subsets:
-                raise ValueError(f'Unknown subset: {subset}')
+        try:
+            # Determine which prefix to read, if an int is provided
+            if isinstance(prefix, int):
+                _, prefixes = cls.inspect_batdata_hdf(path_or_buf)
+                prefix = sorted(prefixes)[prefix]
+
+            data = {}
+            schemas = {}
+            for subset in subsets:
+                # Prepend the prefix
+                if prefix is not None:
+                    key = f'{prefix}_{subset}'
+                else:
+                    key = subset
 
-            # Prepend the prefix
-            if prefix is not None:
-                key = f'{prefix}_{subset}'
-            else:
-                key = subset
+                try:
+                    data[subset] = pd.read_hdf(path_or_buf, key)
+                except KeyError as exc:
+                    if read_all:
+                        continue
+                    else:
+                        raise ValueError(f'File does not contain {key}') from exc
 
-            try:
-                data[subset] = pd.read_hdf(path_or_buf, key)
-            except KeyError as exc:
-                if read_all:
-                    continue
-                else:
-                    raise ValueError(f'File does not contain {key}') from exc
+                # Read the schema
+                group = store.root[key]
+                schemas[subset] = ColumnSchema.from_json(group._v_attrs.metadata)
 
-        # If no data with this prefix is found, report which ones are found in the file
-        if len(data) == 0:
-            raise ValueError(f'No data available for prefix "{prefix}". '
-                             'Call `BatteryDataset.inspect_batdata_hdf` to gather a list of available prefixes.')
+            # If no data with this prefix is found, report which ones are found in the file
+            if len(data) == 0:
+                raise ValueError(f'No data available for prefix "{prefix}". '
+                                 'Call `BatteryDataset.inspect_batdata_hdf` to gather a list of available prefixes.')
 
-        # Read out the battery metadata
-        if isinstance(path_or_buf, (str, Path)):
-            with h5py.File(path_or_buf, 'r') as f:
-                metadata = BatteryMetadata.model_validate_json(f.attrs['metadata'])
-        else:
-            metadata = BatteryMetadata.model_validate_json(path_or_buf.root._v_attrs.metadata)
+            # Read out the battery metadata
+            metadata = BatteryMetadata.model_validate_json(store.root._v_attrs.metadata)
+        finally:
+            if not is_store:
+                store.close()
 
-        return cls(**data, metadata=metadata)
+        return cls(**data, metadata=metadata, schemas=schemas)
 
     @classmethod
     def all_cells_from_batdata_hdf(cls, path: Union[str, Path], subsets: Optional[Collection[str]] = None) -> Iterator[Tuple[str, 'BatteryDataset']]:
@@ -307,7 +317,7 @@ def inspect_batdata_hdf(path_or_buf: Union[str, Path, HDFStore]) -> tuple[Batter
         # Get the names by gathering all names before the "-" in group names
         names = set()
         for key in keys:
-            for subset in _subsets:
+            for subset in _default_schemas:
                 if key.endswith(subset):
                     name = key[:-len(subset) - 1]
                     if len(name) == 0:
@@ -339,7 +349,7 @@ def to_batdata_dict(self) -> dict:
         """
 
         output = {'metadata': self.metadata.model_dump()}
-        for key in _subsets:
+        for key in _default_schemas:
             data = getattr(self, key)
             if data is not None:
                 output[key] = data.to_dict('list')
@@ -352,14 +362,16 @@ def from_batdata_dict(cls, d):
 
         # Convert the keys to a dataframe
         inputs = d.copy()
-        for k in _subsets:
+        for k in _default_schemas:
             if k in inputs:
                 inputs[k] = pd.DataFrame(d[k])
         return cls(**inputs)
 
-    def to_batdata_parquet(self, path: Union[Path, str], overwrite: bool = True) -> Dict[str, Path]:
+    def to_batdata_parquet(self, path: Union[Path, str], overwrite: bool = True, **kwargs) -> Dict[str, Path]:
         """Write battery data to a directory of Parquet files
 
+        Keyword arguments are passed to :func:`~pyarrow.parquet.write_table`.
+
         Args:
             path: Path in which to write to
             overwrite: Whether to overwrite an existing directory
@@ -377,20 +389,20 @@ def to_batdata_parquet(self, path: Union[Path, str], overwrite: bool = True) ->
         # Make the output directory, then write each Parquet file
         path.mkdir(parents=True, exist_ok=False)
         my_metadata = {
-            'battery_metadata': self.metadata.model_dump_json(exclude_defaults=True),
+            'battery_metadata': self.metadata.model_dump_json(exclude_none=True),
             'write_date': datetime.now().isoformat()
         }
         written = {}
-        for key in _subsets:
+        for key, schema in self.schemas.items():
             if (data := getattr(self, key)) is None:
                 continue
-            # Put the metadata for the battery into the table's schema
-            #  TODO (wardlt): Figure out if it can go in the file-metadata
+            # Put the metadata for the battery and this specific table into the table's schema in the FileMetaData
             data_path = path / f'{key}.parquet'
+            my_metadata['table_metadata'] = schema.model_dump_json()
             table = Table.from_pandas(data, preserve_index=False)
             new_schema = table.schema.with_metadata({**my_metadata, **table.schema.metadata})
             table = table.cast(new_schema)
-            pq.write_table(table, where=data_path)
+            pq.write_table(table, where=data_path, **kwargs)
 
             written[key] = data_path
         return written
@@ -415,24 +427,33 @@ def from_batdata_parquet(cls, path: Union[str, Path], subsets: Optional[Collecti
         # Load each subset
         metadata = None
         data = {}
+        schemas = {}
         for subset in subsets:
             data_path = path / f'{subset}.parquet'
             table = pq.read_table(data_path)
-            data[subset] = table
 
             # Load or check the metadata
             if b'battery_metadata' not in table.schema.metadata:
-                warnings.warn(f'Metadata not found in {data_path}')
-                continue
+                warnings.warn(f'Battery metadata not found in {data_path}')
+            else:
+                # Load the metadata for the whole cell
+                my_metadata = table.schema.metadata[b'battery_metadata']
+                if metadata is None:
+                    metadata = my_metadata
+                elif my_metadata != metadata:
+                    warnings.warn(f'Battery data different for files in {path}')
+
+            # Load the batdata schema for the table
+            if b'table_metadata' not in table.schema.metadata:
+                warnings.warn(f'Column schema not found in {data_path}')
+            schemas[subset] = ColumnSchema.from_json(table.schema.metadata[b'table_metadata'])
 
-            my_metadata = table.schema.metadata[b'battery_metadata']
-            if metadata is None:
-                metadata = my_metadata
-            elif my_metadata != metadata:
-                warnings.warn(f'Battery data different for files in {path}')
+            # Read it to a dataframe
+            data[subset] = table.to_pandas()
 
         return cls(
             metadata=BatteryMetadata.model_validate_json(metadata),
+            schemas=schemas,
             **data
         )
 
diff --git a/batdata/extractors/arbin.py b/batdata/extractors/arbin.py
index 0e69024..2581cc5 100644
--- a/batdata/extractors/arbin.py
+++ b/batdata/extractors/arbin.py
@@ -5,7 +5,7 @@
 import pandas as pd
 
 from batdata.extractors.base import BatteryDataExtractor
-from batdata.schemas.cycling import ChargingState
+from batdata.schemas.column import ChargingState
 from batdata.utils import drop_cycles
 from batdata.postprocess.tagging import AddMethod, AddSteps, AddSubSteps
 
diff --git a/batdata/extractors/maccor.py b/batdata/extractors/maccor.py
index 7179138..290f262 100644
--- a/batdata/extractors/maccor.py
+++ b/batdata/extractors/maccor.py
@@ -6,7 +6,7 @@
 import numpy as np
 
 from batdata.extractors.base import BatteryDataExtractor
-from batdata.schemas.cycling import ChargingState
+from batdata.schemas.column import ChargingState
 from batdata.postprocess.tagging import AddMethod, AddSteps, AddSubSteps
 from batdata.utils import drop_cycles
 
diff --git a/batdata/extractors/tIVT.py b/batdata/extractors/tIVT.py
index 567b850..f48d33e 100644
--- a/batdata/extractors/tIVT.py
+++ b/batdata/extractors/tIVT.py
@@ -6,7 +6,7 @@
 import pandas as pd
 
 from batdata.extractors.base import BatteryDataExtractor
-from batdata.schemas.cycling import ChargingState
+from batdata.schemas.column import ChargingState
 from batdata.utils import drop_cycles
 from batdata.postprocess.tagging import add_steps, add_method, add_substeps
 from batdata.postprocess.timing import compute_capacity_energy
diff --git a/batdata/postprocess/tagging.py b/batdata/postprocess/tagging.py
index f7d8caa..105d647 100644
--- a/batdata/postprocess/tagging.py
+++ b/batdata/postprocess/tagging.py
@@ -9,7 +9,7 @@
 from scipy.interpolate import interp1d
 from scipy.signal import find_peaks, savgol_filter
 
-from batdata.schemas.cycling import ChargingState, ControlMethod
+from batdata.schemas.column import ChargingState, ControlMethod
 from .base import RawDataEnhancer
 
 logger = logging.getLogger(__name__)
diff --git a/batdata/schemas/__init__.py b/batdata/schemas/__init__.py
index cc3288d..bfdf7a2 100644
--- a/batdata/schemas/__init__.py
+++ b/batdata/schemas/__init__.py
@@ -1,6 +1,6 @@
 """Schemas for battery data and metadata"""
 from datetime import date
-from typing import List, Tuple, Optional, Dict
+from typing import List, Tuple, Optional
 
 from pydantic import BaseModel, Field, AnyUrl
 
@@ -44,8 +44,3 @@ class BatteryMetadata(BaseModel, extra='allow'):
     associated_ids: Optional[List[AnyUrl]] = Field(None, description="Any identifiers associated with this data file."
                                                                      " Identifiers can be any URI, such as DOIs of associated"
                                                                      " paper or HTTP addresses of associated websites")
-
-    # Description of additional columns
-    raw_data_columns: Dict[str, str] = Field(default_factory=dict, description='Descriptions of non-standard columns in the raw data')
-    cycle_stats_columns: Dict[str, str] = Field(default_factory=dict, description='Descriptions of non-standard columns in the cycle stats')
-    eis_data_columns: Dict[str, str] = Field(default_factory=dict, description='Descriptions of non-standard columns in the EIS data')
diff --git a/batdata/schemas/column.py b/batdata/schemas/column.py
new file mode 100644
index 0000000..3005697
--- /dev/null
+++ b/batdata/schemas/column.py
@@ -0,0 +1,260 @@
+"""Schemas related to describing cycling data"""
+import json
+from enum import Enum
+from typing import List, Dict, Optional, Union, Any
+
+from pydantic import BaseModel, Field, model_validator, create_model
+from pandas import DataFrame
+
+
+class ChargingState(str, Enum):
+    """Potential charging states of the battery"""
+
+    charging = "charging"
+    hold = "hold"
+    discharging = "discharging"
+    unknown = "unknown"
+
+
+class ControlMethod(str, Enum):
+    """Method used to control battery during a certain step"""
+
+    short_rest = "short_rest"
+    """A very short rest period. Defined as a step with 4 or fewer measurements with near-zero current"""
+    rest = "rest"
+    """An extended period of neither charging nor discharging"""
+    short_nonrest = "short_nonrest"
+    """A very short period of charging or discharging. Defined as a step with 4 or fewer measurements with at least one non-zero current."""
+    constant_current = "constant_current"
+    """A step where the current is held constant"""
+    constant_voltage = "constant_voltage"
+    """A step where the voltage is held constant"""
+    constant_power = "constant_power"
+    """A step where the power is held constant"""
+    pulse = "pulse"
+    """A short period of a large current"""
+    other = "other"
+
+
+class DataType(str, Enum):
+    """Types available for columns"""
+    FLOAT = "float"
+    INTEGER = "integer"
+    CONTROL = "control"
+    STATE = "state"
+    OTHER = "other"
+
+
+class ColumnInfo(BaseModel):
+    """Description of a column for a schema"""
+
+    required: bool = False
+    """Whether the column is required"""
+    type: Union[DataType] = DataType.OTHER
+    """Python type of the field (e.g., ``float``)"""
+    description: str = ...
+    """Human-readable description of the column"""
+    units: Optional[str] = None
+    """Units for the value, if applicable"""
+    monotonic: bool = False
+    """Whether the values should increase or remain constant between subsequent rows"""
+
+
+class ColumnSchema(BaseModel, frozen=True):
+    """Base class for schemas that describe the columns of a tabular dataset
+
+    Implement a schema to be re-used across multiple datasets by creating a subclass and
+    adding attributes for each expected column. The type of each attribute must be a :class:`ColumnInfo`
+    and have a default value.
+
+    Save a Schema to disk in JSON format using :meth:`model_dump_json`.
+    Load it using the :meth:`model_validate_json` method of the appropriate subclass,
+    if you know the subclass, or :meth:`from_json`, if you do not.
+    """
+
+    extra_columns: Dict[str, ColumnInfo] = Field(default_factory=dict)
+    """Descriptions of columns beyond those defined in the batdata schema"""
+
+    @property
+    def columns(self) -> Dict[str, ColumnInfo]:
+        """Map of name to description for all columns"""
+        specified = dict((k, getattr(self, k)) for k in self.model_fields if k != "extra_columns")
+        specified.update(self.extra_columns)
+        return specified
+
+    @property
+    def column_names(self) -> List[str]:
+        """Names of all columns defined in this schema"""
+        specified = [x for x in self.model_fields if x != "extra_columns"]
+        specified.extend(self.extra_columns)
+        return specified
+
+    @model_validator(mode='before')
+    def _check_attributes(cls, d: Any):
+        for field_name, field_info in cls.model_fields.items():
+            if field_name == "extra_columns":
+                continue
+            elif field_info.annotation != ColumnInfo:
+                raise ValueError('The subclass is incorrect. All fields must be `ColumnSchema`')
+            elif field_info.default is None:
+                raise ValueError('The subclass is incorrect. All fields must have a default value')
+        return d
+
+    def validate_dataframe(self, data: DataFrame, allow_extra_columns: bool = True):
+        """Check whether a dataframe matches this schema
+
+        Args:
+            data: DataFrame to be assessed
+            allow_extra_columns: Whether to raise an error if the dataframe contains
+                columns which are not defined in this schema.
+        Raises:
+            (ValueError) If the dataframe does not adhere
+        """
+        # Get the columns from the dataframe and their types
+        data_columns = data.dtypes.to_dict()
+
+        # If needed, check for extra columns
+        if not allow_extra_columns:
+            extra_cols = set(data_columns.keys()).difference(self.model_fields.keys()).difference(self.extra_columns.keys())
+            if len(extra_cols) > 0:
+                raise ValueError(f'Dataset contains extra columns: {" ".join(extra_cols)}')
+
+        # Check each of the columns that match
+        for column, col_schema in self.model_fields.items():
+            col_schema = col_schema.default
+            if column == 'extra_columns':
+                continue
+
+            # Check if column is missing
+            if column not in data_columns:
+                if col_schema.required:
+                    raise ValueError(f'Dataset is missing a required column: {column}')
+                continue
+
+            # Check numeric data types
+            actual_type = data_columns[column]
+            col_type = col_schema.type
+            if col_type == DataType.FLOAT:
+                if actual_type.kind not in ['f', 'c']:
+                    raise ValueError(f'Column {column} is a {actual_type} and not a floating point number')
+            elif col_type == DataType.INTEGER:
+                if actual_type.kind not in ['i', 'u']:
+                    raise ValueError(f'Column {column} is a {actual_type} and not an integer')
+            elif col_type in [DataType.CONTROL, DataType.STATE]:
+                my_enum = {
+                    DataType.CONTROL: ControlMethod,
+                    DataType.STATE: ChargingState
+                }[col_type]
+                is_enum = data[column].isin(list(my_enum))
+                bad = data[~is_enum][column]
+                if len(bad) > 0:
+                    raise ValueError(f'Column {column} contains values not in {my_enum}: {set(bad)}')
+            elif col_type == DataType.OTHER:
+                continue
+            else:
+                raise ValueError(f'No type checking for {col_type}')
+
+            # Check if increasing
+            if col_schema.monotonic:
+                is_monotonic = all(y >= x for x, y in zip(data[column], data[column].iloc[1:]))
+                if not is_monotonic:
+                    raise ValueError(f'Column {column} is not monotonically increasing')
+
+    @classmethod
+    def from_json(cls, buf: str) -> 'ColumnSchema':
+        """Read a JSON description of a column schema into an object
+
+        The object will not have the same class as the original, but will
+        have the same column information.
+
+        Args:
+            buf: JSON version of this class
+        Returns:
+            Model as a subclass of :class:`ColumnSchema`
+        """
+
+        data = json.loads(buf)
+        extra_cols = dict((k, ColumnInfo.model_validate(v)) for k, v in data.pop('extra_columns').items())
+        my_cols = dict((k, (ColumnInfo, ColumnInfo.model_validate(v))) for k, v in data.items())
+
+        return create_model(
+            'ParsedColumnSchema',
+            **my_cols,
+            extra_columns=(Dict[str, ColumnInfo], extra_cols),
+            __base__=cls,
+        )()
+
+
+class RawData(ColumnSchema):
+    """Data describing measurements of a single cell"""
+
+    # Related to testing protocols
+    file_number: ColumnInfo = ColumnInfo(description="Which file a row came from, if the data was originally split into multiple files",
+                                         type=DataType.INTEGER, monotonic=True)
+
+    # Control methods
+    state: ColumnInfo = ColumnInfo(description="Whether the battery is being charged, discharged or otherwise.", type=DataType.STATE)
+    method: ColumnInfo = ColumnInfo(description="Method to control the charge or discharge", type=DataType.CONTROL)
+    cycle_number: ColumnInfo = ColumnInfo(description="Index of the testing cycle, starting at 0.", monotonic=True, type=DataType.INTEGER)
+    step_index: ColumnInfo = ColumnInfo(description="Index of the step number within a testing cycle. A step change"
+                                                    " is defined by a change states between charging, discharging, or resting.",
+                                        type=DataType.INTEGER)
+    substep_index: ColumnInfo = ColumnInfo(description="Change of the control method within a cycle.", type=DataType.INTEGER)
+
+    # 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.",
+                                     units='A', required=True, type=DataType.FLOAT)
+    internal_resistance: ColumnInfo = ColumnInfo(description="Internal resistance of the battery.", units="ohm", type=DataType.FLOAT)
+
+    # Other measurement data
+    time: ColumnInfo = ColumnInfo(description="Time as a UNIX timestamp.", units='s', monotonic=True, type=DataType.FLOAT)
+    temperature: ColumnInfo = ColumnInfo(description="Temperature of the battery", units='C', type=DataType.FLOAT)
+
+    # Derived data
+    cycle_time: ColumnInfo = ColumnInfo(description="Time from the beginning of a cycle", units='s', monotonic=True, type=DataType.FLOAT)
+    cycle_capacity: ColumnInfo = ColumnInfo(description="Cumulative change in amount of charge transferred from a battery since the start of a cycle. "
+                                                        "Positive values indicate the battery has discharged since the start of the cycle.",
+                                            type=DataType.FLOAT, units='A-hr')
+    cycle_energy: ColumnInfo = ColumnInfo(description="Cumulative change in amount of energy transferred from a battery since the start of a cycle. "
+                                                      "Positive values indicate the battery has discharged since the start of the cycle.",
+                                          type=DataType.FLOAT, units='J')
+    cycle_capacity_charge: ColumnInfo = ColumnInfo(description="Cycle capacity computed only during the 'charging' phase of a cycle",
+                                                   units='A-hr', type=DataType.FLOAT)
+    cycle_capacity_discharge: ColumnInfo = ColumnInfo(description="Cycle capacity computed only during the 'discharging' phase of a cycle",
+                                                      units='A-hr', type=DataType.FLOAT)
+
+
+class CycleLevelData(ColumnSchema):
+    """Statistics about the performance of a cell over entire cycles"""
+
+    # Related to time
+    cycle_number: ColumnInfo = ColumnInfo(description='Index of the cycle', monotonic=True, type=DataType.INTEGER, required=True)
+    cycle_start: ColumnInfo = ColumnInfo(description='Time since the first data point recorded for this battery for the start of this cycle',
+                                         units="s", monotonic=True, type=DataType.FLOAT)
+    cycle_duration: ColumnInfo = ColumnInfo(description='Duration of this cycle', units='s', type=DataType.FLOAT)
+
+    # Related to the total amount of energy or electrons moved
+    capacity_discharge: ColumnInfo = ColumnInfo(description='Total amount of electrons released during discharge', units='A-hr', type=DataType.FLOAT)
+    energy_discharge: ColumnInfo = ColumnInfo(description='Total amount of energy released during discharge', units='W-hr', type=DataType.FLOAT)
+    capacity_charge: ColumnInfo = ColumnInfo(description='Total amount of electrons stored during charge', units='A-hr', type=DataType.FLOAT)
+    energy_charge: ColumnInfo = ColumnInfo(description='Total amount of energy stored during charge', units='W-hr', type=DataType.FLOAT)
+    coulomb_efficiency: ColumnInfo = ColumnInfo(description='Fraction of electric charge that is lost during charge and recharge',
+                                                units='%', type=DataType.FLOAT)
+    energy_efficiency: ColumnInfo = ColumnInfo(description='Amount of energy lost during charge and discharge', type=DataType.FLOAT)
+
+    # Related to voltage
+    discharge_V_average: ColumnInfo = ColumnInfo(description='Average voltage during discharging', units='V', type=DataType.FLOAT)
+    charge_V_average: ColumnInfo = ColumnInfo(description='Average voltage during charge', units='V', type=DataType.FLOAT)
+    V_maximum: ColumnInfo = ColumnInfo(description='Maximum voltage during cycle', units='V', type=DataType.FLOAT)
+    V_minimum: ColumnInfo = ColumnInfo(description='Minimum voltage during cycle', units='V', type=DataType.FLOAT)
+
+    # Related to current
+    discharge_I_average: ColumnInfo = ColumnInfo(description='Average current during discharge', units='A', type=DataType.FLOAT)
+    charge_I_average: ColumnInfo = ColumnInfo(description='Average current during charge', units='A', type=DataType.FLOAT)
+
+    # Temperature
+    temperature_minimum: ColumnInfo = ColumnInfo(description='Minimum observed battery temperature during cycle', units='C', type=DataType.FLOAT)
+    temperature_maximum: ColumnInfo = ColumnInfo(description='Maximum observed battery temperature during cycle', units='C', type=DataType.FLOAT)
+    temperature_average: ColumnInfo = ColumnInfo(description='Average observed battery temperature during cycle', units='C', type=DataType.FLOAT)
diff --git a/batdata/schemas/cycling.py b/batdata/schemas/cycling.py
deleted file mode 100644
index b00578f..0000000
--- a/batdata/schemas/cycling.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""Schemas related to describing cycling data"""
-from enum import Enum
-from typing import List
-
-from pydantic import BaseModel, Field
-from pandas import DataFrame
-
-
-class ChargingState(str, Enum):
-    """Potential charging states of the battery:
-
-    - ``charging``: Battery is being charged
-    - ``hold``: Battery is neither charging nor discharging
-    - ``discharging``: Battery is being discharged
-    - ``unknown``: State was unable to be determined
-    """
-
-    charging = "charging"
-    hold = "hold"
-    discharging = "discharging"
-    unknown = "unknown"
-
-
-class ControlMethod(str, Enum):
-    """Method used to control battery during a certain step
-
-    - ``short_rest``: A very short rest period. Defined as a step with 4 or fewer measurements with near-zero current
-    - ``rest``: An extended period of neither charging nor discharging
-    - ``short_nonrest``: A very short period of charging or discharging. Defined as a step with 4 or fewer measurements
-        with at least one non-zero current.
-    - ``constant_current``: A step where the current is held constant
-    - ``constant_voltage``: A step where the voltage is held constant
-    - ``other``: A step that does not fit into any of the predefined categories
-    """
-
-    short_rest = "short_rest"
-    rest = "rest"
-    short_nonrest = "short_nonrest"
-    constant_current = "constant_current"
-    constant_voltage = "constant_voltage"
-    constant_power = "constant_power"
-    pulse = "pulse"
-    other = "other"
-
-
-class ColumnSchema(BaseModel):
-    """Base class for schemas that describe the columns of a tabular dataset"""
-
-    @classmethod
-    def validate_dataframe(cls, data: DataFrame, allow_extra_columns: bool = True):
-        # Get the columns from the schema
-        schema = cls.model_json_schema()
-        schema_columns = schema['properties']
-        required_cols = schema['required']
-
-        # Get the columns from the dataframe and their types
-        data_columns = data.dtypes.to_dict()
-
-        # If needed, check for extra columns
-        if not allow_extra_columns:
-            extra_cols = set(data_columns.keys()).difference(schema_columns.keys())
-            if len(extra_cols) > 0:
-                raise ValueError(f'Dataset contains extra columns: {" ".join(extra_cols)}')
-
-        # Check each of the columns that match
-        for column, col_schema in schema_columns.items():
-            # Check if column is missing
-            if column not in data_columns:
-                if column in required_cols:
-                    raise ValueError(f'Dataset is missing a required column: {column}')
-                continue
-
-            # Get the data type for the column
-            if '$ref' in col_schema['items']:
-                ref_name = col_schema['items']['$ref'].split("/")[-1]
-                col_type = schema['$defs'][ref_name]['type']
-            else:
-                col_type = col_schema['items']['type']
-
-            # Check data types
-            actual_type = data_columns[column]
-            if col_type == "number":
-                if actual_type.kind not in ['f', 'c']:
-                    raise ValueError(f'Column {column} is a {actual_type} and not a floating point number')
-            elif col_type == "integer":
-                if actual_type.kind not in ['i', 'u']:
-                    raise ValueError(f'Column {column} is a {actual_type} and not an integer')
-            elif col_type == "string":
-                if actual_type.kind not in ['S', 'U', 'O']:
-                    raise ValueError(f'Column {column} is a {actual_type} and not a string')
-
-            # Check enums
-            enum_values = col_schema['items'].get('enum', None)
-            if enum_values is not None:
-                is_enum = data[column].isin(enum_values)
-                bad = data[~is_enum][column]
-                if len(bad) > 0:
-                    raise ValueError(f'Column {column} contains values not in enum: {set(bad)}')
-
-            # Check if increasing
-            if col_schema.get('monotonic', False):
-                is_monotonic = all(y >= x for x, y in zip(data[column], data[column].iloc[1:]))
-                if not is_monotonic:
-                    raise ValueError(f'Column {column} is not monotonically increasing')
-
-
-class RawData(ColumnSchema):
-    """Schema for the time series data."""
-
-    cycle_number: List[int] = Field(None, description="Index of the testing cycle, starting at 0. All indices should be"
-                                                      " nonnegative and be monotonically increasing", monotonic=True)
-    step_index: List[int] = Field(None, description="Index of the step number within a testing cycle. A step change"
-                                                    " is defined by a change states between charging, discharging,"
-                                                    " or resting.")
-    file_number: List[int] = Field(None, description="Used if test data is stored in multiple files. Number represents "
-                                                     "the index of the file. All indices should be nonnegative and "
-                                                     "monotonically increasing", monotonic=True)
-    test_time: List[float] = Field(..., description="Time from the beginning of the cycling test. Times must be "
-                                                    "nonnegative and monotonically increasing. Units: s",
-                                   monotonic=True)
-    cycle_time: List[float] = Field(None, description="Time from the beginning of a cycle. Units: s")
-    time: List[float] = Field(None, description="Time as a UNIX timestamp. Assumed to be in UTC")
-    voltage: List[float] = Field(..., description="Measured voltage of the system. Units: V")
-    current: List[float] = Field(..., description="Measured current of the system. Positive current represents "
-                                                  "the battery discharging and negative represents the battery"
-                                                  "charging. Units: A")
-    state: List[ChargingState] = Field(None, description="Determination of whether the battery is being charged, "
-                                                         "discharged or held at a constant charge")
-    method: List[ControlMethod] = Field(None, description="List of the method used to control "
-                                                          "the battery system")
-    temperature: List[float] = Field(None, description="Temperature of the battery. Units: C")
-    internal_resistance: List[float] = Field(None, description="Internal resistance of the battery. Units: ohm")
-    substep_index: List[int] = Field(None, description="Index of the substep within a testing cycle. A substep"
-                                                       " change is defined by a change of the charging or discharging"
-                                                       " method, such as change from constant voltage to"
-                                                       " constant current")
-
-    # Derived quantities
-    cycle_capacity: List[float] = Field(None,
-                                        description="Cumulative change in amount of charge transferred from a battery since the start of a cycle. "
-                                                    "Positive values indicate the battery has discharged since the start of the cycle.")
-    cycle_energy: List[float] = Field(None,
-                                      description="Cumulative change in amount of energy transferred from a battery the start of a cycle. "
-                                                  "Positive values indicate the battery has discharged more than since the start of the cycle.")
-    cycle_capacity_charge: List[float] = Field(None, description="Cycle capacity computed only during the 'charging' phase of a cycle")
-    cycle_capacity_discharge: List[float] = Field(None, description="Cycle capacity computed only during the 'discharging' phase of a cycle")
-
-
-class CycleLevelData(ColumnSchema):
-    """Statistics about the performance of a cell over a certain cycle"""
-
-    # Related to time
-    cycle_number: List[int] = Field(..., description='Index of the cycle', monotonic=True)
-    cycle_start: List[float] = Field(None, description='Time since the first data point recorded for this battery for the start of this cycle. Units: s')
-    cycle_duration: List[float] = Field(None, description='Duration of this cycle. Units: s')
-
-    # Related to the total amount of energy or electrons moved
-    capacity_discharge: List[float] = Field(None, description='Total amount of electrons released during discharge. Units: A-hr')
-    energy_discharge: List[float] = Field(None, description='Total amount of energy released during discharge. Units: W-hr')
-    capacity_charge: List[float] = Field(None, description='Total amount of electrons stored during charge. Units: A-hr')
-    energy_charge: List[float] = Field(None, description='Total amount of energy stored during charge. Units: W-hr')
-    coulomb_efficiency: List[float] = Field(None, description='Fraction of electric charge that is lost during charge and recharge. Units: %')
-    energy_efficiency: List[float] = Field(None, description='Amount of energy lost during charge and discharge')
-
-    # Related to voltage
-    discharge_V_average: List[float] = Field(None, description='Average voltage during discharging. Units: V')
-    charge_V_average: List[float] = Field(None, description='Average voltage during charge. Units: V')
-    V_maximum: List[float] = Field(None, description='Maximum voltage during cycle. Units: V')
-    V_minimum: List[float] = Field(None, description='Minimum voltage during cycle. Units: V')
-
-    # Related to current
-    discharge_I_average: List[float] = Field(None, description='Average current during discharge. Units: A')
-    charge_I_average: List[float] = Field(None, description='Average current during charge. Units: A')
-
-    # Temperature
-    temperature_minimum: List[float] = Field(None, description='Minimum observed battery temperature during cycle. Units: C')
-    temperature_maximum: List[float] = Field(None, description='Maximum observed battery temperature during cycle. Units: C')
-    temperature_average: List[float] = Field(None, description='Average observed battery temperature during cycle. Units: C')
diff --git a/batdata/schemas/eis.py b/batdata/schemas/eis.py
index 7577805..6622c08 100644
--- a/batdata/schemas/eis.py
+++ b/batdata/schemas/eis.py
@@ -1,30 +1,23 @@
 """Schemas associated with Electrochemical Impedance Spectroscopy"""
-
-from typing import List
-
 from pandas import DataFrame
-from pydantic import Field
 import numpy as np
 
-from .cycling import ColumnSchema
+from .column import ColumnSchema, ColumnInfo, DataType
 
 
 class EISData(ColumnSchema):
     """Measurements for a specific EIS test"""
 
-    test_id: List[int] = Field(..., description='Integer used to identify rows belonging to the same experiment.')
-    test_time: List[float] = Field(None, description="Time from the beginning of the cycling test. Times must be "
-                                                     "nonnegative and monotonically increasing. Units: s",
-                                   monotonic=True)
-    time: List[float] = Field(None, description="Time as a UNIX timestamp. Assumed to be in UTC")
-    frequency: List[float] = Field(..., description="Applied frequency. Units: Hz")
-    z_real: List[float] = Field(..., description="Real component of impedance. Units: Ohm")
-    z_imag: List[float] = Field(..., description="Imaginary component of impedance. Units: Ohm")
-    z_mag: List[float] = Field(..., description="Magnitude of impedance. Units: Ohm")
-    z_phase: List[float] = Field(..., description="Phase angle of the impedance. Units: Degree")
+    test_id: ColumnInfo = ColumnInfo(description='Integer used to identify rows belonging to the same experiment.', required=True, type=DataType.INTEGER)
+    test_time: ColumnInfo = ColumnInfo(description="Time from the beginning of measurements.", units="s", monotonic=True, type=DataType.FLOAT)
+    time: ColumnInfo = ColumnInfo(description="Time as a UNIX timestamp. Assumed to be in UTC", type=DataType.FLOAT)
+    frequency: ColumnInfo = ColumnInfo(description="Applied frequency", units="Hz", required=True, type=DataType.FLOAT)
+    z_real: ColumnInfo = ColumnInfo(description="Real component of impedance", units="Ohm", required=True, type=DataType.FLOAT)
+    z_imag: ColumnInfo = ColumnInfo(description="Imaginary component of impedance", units="Ohm", required=True, type=DataType.FLOAT)
+    z_mag: ColumnInfo = ColumnInfo(description="Magnitude of impedance", units="Ohm", required=True, type=DataType.FLOAT)
+    z_phase: ColumnInfo = ColumnInfo(description="Phase angle of the impedance", units="Degree", required=True, type=DataType.FLOAT)
 
-    @classmethod
-    def validate_dataframe(cls, data: DataFrame, allow_extra_columns: bool = True):
+    def validate_dataframe(self, data: DataFrame, allow_extra_columns: bool = True):
         # Check that the schema is supported
         super().validate_dataframe(data, allow_extra_columns)
 
diff --git a/batdata/streaming.py b/batdata/streaming/__init__.py
similarity index 100%
rename from batdata/streaming.py
rename to batdata/streaming/__init__.py
diff --git a/batdata/streaming/hdf5.py b/batdata/streaming/hdf5.py
new file mode 100644
index 0000000..b141a5d
--- /dev/null
+++ b/batdata/streaming/hdf5.py
@@ -0,0 +1,101 @@
+"""Streaming tools related to the HDF5 format"""
+from typing import Union, Dict, Optional, List
+from contextlib import AbstractContextManager
+from dataclasses import field, dataclass
+from pathlib import Path
+
+import pandas as pd
+from tables.group import RootGroup
+
+from batdata.schemas import BatteryMetadata
+from batdata.schemas.column import ColumnSchema, RawData
+
+
+@dataclass
+class HDF5Writer(AbstractContextManager):
+    """Tool to write raw time series data to an HDF5 file incrementally
+
+    Args:
+        hdf5_output: Path to a location on disk or the root group in an HDF5 file in which to write data.
+        storage_key: Name of the group within the file to store all states
+    """
+
+    # Attributes defining where and how to write
+    hdf5_output: Union[Path, str, pd.HDFStore]
+    """File or already-open HDF5 file in which to store data"""
+    metadata: BatteryMetadata = field(default_factory=BatteryMetadata)
+    """Metadata describing the cell"""
+    schema: ColumnSchema = field(default_factory=RawData)
+    """Schema describing columns of the cell"""
+    complevel: Optional[int] = None
+    """Compression level. Can be between 0 (no compression) and 9 (maximum compression). Ignored if :attr:`hdf5_output` is a ``HDFStore``."""
+    complib: str = 'zlib'
+    """Compression algorithm. Consult :func:`~pandas.read_hdf` for available options. Ignored if :attr:`hdf5_output` is a ``HDFStore``."""
+    key: str = ''
+    """Name of the root group in which to store the data. Ignored if :attr:`hdf5_output` is a ``HDFStore``."""
+    buffer_size: int = 32768
+    """Number of rows to collect in memory before writing to disk"""
+
+    # State used only while in writing mode
+    _store: Optional[pd.HDFStore] = None
+    """Handle to an open file"""
+    _write_buffer: Optional[List[Dict]] = None
+    """Index of the next step to be written"""
+
+    def __enter__(self):
+        self._write_buffer = list()
+
+        # Open the store, if needed
+        if isinstance(self.hdf5_output, pd.HDFStore):
+            self._store = self.hdf5_output
+        else:
+            self._store = pd.HDFStore(
+                self.hdf5_output,
+                complevel=self.complevel,
+                complib=self.complib,
+                root_uep=f'{self.key}',
+            )
+
+        # Write metadata to the store's root's attributes
+        self._store.root._v_attrs.metadata = self.metadata.model_dump_json(exclude_none=True)
+        self._store.root._v_attrs.json_schema = self.metadata.model_json_schema()
+
+        # TODO (wardlt): Figure out how to avoid private methods if we don't abandon pytables
+        self._store._create_nodes_and_group('raw_data')
+        group: RootGroup = self._store.root['raw_data']
+        group._v_attrs.metadata = self.schema.model_dump_json()
+        group._v_attrs.json_schema = self.schema.model_json_schema()
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if len(self._write_buffer) > 0:  # Ensure last rows are written
+            self.flush()
+        if not isinstance(self.hdf5_output, pd.HDFStore):  # Close file if a path was provided
+            self._store.close()
+        self._write_buffer = None
+
+    def write_row(self, row: Dict[str, Union[str, float, int]]) -> int:
+        """Add a row to the data file
+
+        Args:
+            row: Row to be added to the HDF5 file
+        Returns:
+            Number of rows written to file. Writes only occur when a write buffer has filled
+        """
+        self._write_buffer.append(row.copy())
+        if len(self._write_buffer) >= self.buffer_size:
+            return self.flush()
+        return 0
+
+    def flush(self) -> int:
+        """Write the current row buffer to the file
+
+        Returns:
+            Number of rows written
+        """
+
+        # Append rows to the "raw_data" key
+        to_write = pd.DataFrame(self._write_buffer)
+        self._write_buffer.clear()
+        self._store.append('raw_data', to_write)
+        return len(to_write)
diff --git a/dev/environment.yml b/dev/environment.yml
index ca11186..3432cf6 100644
--- a/dev/environment.yml
+++ b/dev/environment.yml
@@ -5,10 +5,17 @@ channels:
 dependencies:
   - python==3.9.*
 
+  # Install PyData libraries
+  - pytables
+  - scipy
+  - pandas
+  - h5py
+  - pyarrow
+
   # For now, use Pip for everything major
   - pip
   - pip:
     # Install jupyter
     - jupyterlab
     - matplotlib
-    - -e ..[test]
+    - -e ..[test,docs]
diff --git a/docs/conf.py b/docs/conf.py
index 26fec0d..0099210 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -48,8 +48,6 @@
     'sphinxcontrib.autodoc_pydantic'
 ])
 
-autodoc_mock_imports = ["django"]
-
 autodoc_pydantic_model_show_json = False
 autodoc_pydantic_settings_show_json = False
 
@@ -58,4 +56,6 @@
 intersphinx_mapping = {
     'python': ('https://docs.python.org/3/', None),
     'pandas': ('https://pandas.pydata.org/docs/', None),
+    'pyarrow': ('https://arrow.apache.org/docs/', None),
+    'h5py': ('https://docs.h5py.org/en/stable/', None),
 }
diff --git a/docs/source/schemas.rst b/docs/source/schemas.rst
index a6f42c2..5fe6c98 100644
--- a/docs/source/schemas.rst
+++ b/docs/source/schemas.rst
@@ -22,10 +22,10 @@ Metadata: Computation (``b.schemas.modeling``)
    :undoc-members:
    :show-inheritance:
 
-Data: Time Series (``b.schemas.cycling``)
-------------------------------------------
+Data: Time Series (``b.schemas.column``)
+----------------------------------------
 
-.. automodule:: batdata.schemas.cycling
+.. automodule:: batdata.schemas.column
    :members:
    :undoc-members:
    :show-inheritance:
diff --git a/docs/source/streaming.rst b/docs/source/streaming.rst
index 0ab5325..b98c39d 100644
--- a/docs/source/streaming.rst
+++ b/docs/source/streaming.rst
@@ -5,3 +5,11 @@ Streaming (``batdata.streaming``)
    :members:
    :undoc-members:
    :show-inheritance:
+
+HDF5 Streaming (``b.streaming.hdf5``)
+-------------------------------------
+
+.. automodule:: batdata.streaming.hdf5
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/user-guide/dataset.rst b/docs/user-guide/dataset.rst
index 2c5114d..2171868 100644
--- a/docs/user-guide/dataset.rst
+++ b/docs/user-guide/dataset.rst
@@ -21,52 +21,10 @@ Loading and Saving
 
 The battery data and metadata can be saved in a few different styles, each with different advantages.
 
-.. _hdf5:
+Functions to save are named ``to_batdata_[format]`` and
+functions for loading data are named ``from_batdata_[format]``.
 
-HDF5
-++++
+See the `formats <formats.html>`_ documentation page for more detail.
 
-The HDF5 file format stores all data about a battery in a single file.
-
-.. note::
-
-    Explain that we use ``pytables`` now, but that may change in the future
-
-Multiple Batteries per File
-~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Battery Data Toolkit supports storing data from multiple, related batteries in the same HDF5 file 
-as long as they share the same metadata.
-
-Add multiple batteries into an HDF5 file by providing a "prefix" to name each cell.
-
-.. code-block:: python
-
-    test_a.to_batdata_hdf('test.h5', prefix='a')
-    test_b.to_batdata_hdf('test.h5', prefix='b', append=True)  # Append is mandatory
-
-
-Load a specific cell by providing a specific prefix on load
-
-.. code-block:: python
-
-    test_a = BatteryDataset.from_batdata_hdf('test.h5', prefix='a')
-
-
-or load any of the included cells by providing an index
-
-.. code-block:: python
-
-    test_a = BatteryDataset.from_batdata_hdf('test.h5', prefix=0)
-
-Load all cells by iterating over them:
-
-.. code-block::
-
-    for name, cell in BatteryDataset.all_cells_from_batdata_hdf('test.h5'):
-        do_some_processing(cell)
-
-Parquet
-+++++++
-
-TBD.
+Loading functions loads the entire dataset. See `streaming <streaming.html>`_ for
+how to load large datasets incrementally.
diff --git a/docs/user-guide/export-schemas.py b/docs/user-guide/export-schemas.py
index dd62668..65afe50 100644
--- a/docs/user-guide/export-schemas.py
+++ b/docs/user-guide/export-schemas.py
@@ -3,25 +3,27 @@
 
 from pydantic import BaseModel
 
-from batdata.schemas.cycling import RawData, CycleLevelData
+from batdata.schemas.column import RawData, CycleLevelData
 from batdata.schemas import BatteryMetadata
 
 print('Exporting column schemas to RST...')
 
 with open('column-schema.rst', 'w') as fp:
-    for data_type in [RawData, CycleLevelData]:
-        class_name = data_type.__name__
+    for data_type in [RawData(), CycleLevelData()]:
+        class_name = data_type.__class__.__name__
         print(f'``{class_name}``\n++{"+" * len(class_name)}++', file=fp)
-        print(f'\n**Source Object**: :class:`{data_type.__module__}.{data_type.__name__}`\n', file=fp)
+        print(f'\n**Source Object**: :class:`{data_type.__module__}.{class_name}`\n', file=fp)
         print(f'\n{data_type.__doc__}\n', file=fp)
 
         print('.. list-table::', file=fp)
         print('   :header-rows: 1\n', file=fp)
         print('   * - Column', file=fp)
         print('     - Description', file=fp)
-        for name, field in data_type.model_fields.items():
+        print('     - Units', file=fp)
+        for name, field in data_type.columns.items():
             print(f'   * - {name}', file=fp)
             print(f'     - {field.description}', file=fp)
+            print(f'     - {field.units}', file=fp)
         print(file=fp)
 
 
diff --git a/docs/user-guide/formats.rst b/docs/user-guide/formats.rst
new file mode 100644
index 0000000..16735b6
--- /dev/null
+++ b/docs/user-guide/formats.rst
@@ -0,0 +1,97 @@
+File Formats
+============
+
+The battery data toolkit stores data and metadata in two formats:
+
+- *HDF5*: A format for saving all available information about a battery into a single file
+- *Parquet*: A format optimized for storing column data, but requires saving separate files for each type of data (cycle vs raw)
+
+.. contents::
+  :local:
+  :depth: 1
+
+.. _hdf5:
+
+HDF5
+----
+
+The `HDF5 format <https://support.hdfgroup.org/documentation/hdf5/latest/>`_ stores array data as a nested series of dictionaries.
+``batdata`` stores each type of data known about a battery in separate groups
+and the metadata for the battery as the metadata.
+
+.. code-block:: python
+
+    import h5py
+    import json
+
+    with h5py.File('example.h5') as f:
+        metadata = json.loads(f.attrs['metadata'])  # Data describing the cell and how it was tested
+        raw_data = f['raw_data']  # HDF5 group holding raw data
+        schema = raw_data.attrs['metadata']  # Description of each column
+
+The internal structure of each group (e.g., ``f['raw_data']``) are in `the PyTables layout <https://www.pytables.org/usersguide/file_format.html>`_.
+The format is readable through pytables and Pandas's :func:`~pandas.read_hdf` function, but readers for other languages do not yet exist.
+
+.. note::
+
+    We may change to a simplified HDF5 layout for simpler cross-language compatibility.
+
+
+Multiple Batteries per File
++++++++++++++++++++++++++++
+
+Data from multiple batteries can share a single HDF5 file as long as they share the same metadata.
+
+Add multiple batteries into an HDF5 file by providing a "prefix" to name each cell.
+
+.. code-block:: python
+
+    test_a.to_batdata_hdf('test.h5', prefix='a')
+    test_b.to_batdata_hdf('test.h5', prefix='b', append=True)  # Append is mandatory
+
+
+Load a specific cell by providing a specific prefix on load
+
+.. code-block:: python
+
+    test_a = BatteryDataset.from_batdata_hdf('test.h5', prefix='a')
+
+
+or load any of the included cells by providing an index
+
+.. code-block:: python
+
+    test_a = BatteryDataset.from_batdata_hdf('test.h5', prefix=0)
+
+Load all cells by iterating over them:
+
+.. code-block:: python
+
+    for name, cell in BatteryDataset.all_cells_from_batdata_hdf('test.h5'):
+        do_some_processing(cell)
+
+Parquet
+-------
+
+The `Apache Parquet format <https://en.wikipedia.org/wiki/Apache_Parquet>`_ is designed for high performance I/O of tabular data.
+``batdata`` stores each type of data in a separate file and the metadata in `file-level metadata <https://parquet.apache.org/docs/file-format/metadata/>`_
+of the file.
+
+.. code-block:: python
+
+    from pyarrow import parquet as pq
+    import json
+
+
+    # Reading the metadata
+    file_metadata = pq.read_metadata('raw_data.parquet')  # Parquet metadata
+    metadata = json.loads(file_metadata.metadata[b'battery_metadata'])  # For the battery
+    schema = json.loads(file_metadata.metadata[b'table_metadata'])  # For the columns
+
+    # Reading the data
+    table = pq.read_table('raw_data.parquet')  # In pyarrow's native Table format
+    df = table.to_pandas()  # As a dataframe
+
+The internal structure of a Parquet file saved by ``batdata`` has column names and data types which match those provided when saving the file.
+Any numeric types will be the same format (e.g., ``float32`` vs ``float64``)
+and times are stored as floating point numbers, rather than Parquet's time format.
diff --git a/docs/user-guide/index.rst b/docs/user-guide/index.rst
index 869c9f6..96efea3 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
+   formats
    schemas
    post-processing/index
    export
diff --git a/docs/user-guide/schemas.rst b/docs/user-guide/schemas.rst
index 8ac7d2c..e1f4417 100644
--- a/docs/user-guide/schemas.rst
+++ b/docs/user-guide/schemas.rst
@@ -1,21 +1,35 @@
-Battery Data Schemas
-====================
+Describing Battery Data
+=======================
 
-The metadata schemas used by ``batdata`` standardize how we describe the source of battery datasets.
-Metadata are held as part of the ``BatteryDataset`` object and saved within the file formats
+The metadata schemas used by ``batdata`` standardize how we describe the source of battery datasets
+and annotate what the data are.
+Metadata are held as part of the :class:`batdata.data.BatteryDataset` object and saved within the file formats
 produced by ``batdata`` to ensure that the provenance of a dataset is kept alongside the actual data.
 
+Descriptions are defined in two parts:
 
-Understanding the Metadata
---------------------------
+.. contents::
+   :local:
+   :depth: 2
 
-The metadata we employ in ``batdata`` follows the style of the JSON or XML data structures which are ubiquitous
-in scientific computation and data infrastructure.
-Each record is composed of a single document that has a hierarchical set of fields which can correspond
-to single values or collections of values.
+Source Metadata
+---------------
+
+''Source Metadata'' captures high-level information about a battery dataset
+in the :class:`~batdata.schemas.BatteryMetadata` object.
+Information included in ``BatteryMetadata``, in contrast to `Column Schemas`_, are relevant to
+all measurements performed on a battery, such as:
+
+1. The type of battery (e.g., NMC Li-ion, Pb acid)
+2. The simulation code used, if the data is from a model
+3. How the battery was cycled
+4. The authors of the data and any related publications
+
+:class:`~batdata.schemas.BatteryMetadata` objects have a hierarchical structure where
+each record is composed of a single document that has fields which can correspond
+to single values, collections of values, or entire sub-documents.
 
-We recommend creating the metadata for a battery through the Python interface. 
-Start by creating a ``BatteryMetadata`` object. There are no required fields, but you should always give your data a name.
+Create new metadata through the Python interface by first creating a ``BatteryMetadata`` object.
 
 .. code-block:: python
 
@@ -25,8 +39,8 @@ Start by creating a ``BatteryMetadata`` object. There are no required fields, bu
         name='test-cell',
     )
 
-The metadata is a nested document where different types of information are grouped together into sub objects.
-For example, the details about the battery being tested are in `BatteryDescription`
+Different types of information are grouped together into subdocuments,
+such as details about the battery in :class:`~batdata.schemas.battery.BatteryDescription`
 
 .. code-block:: python
 
@@ -41,20 +55,20 @@ For example, the details about the battery being tested are in `BatteryDescripti
         )
     )
 
-Components
-++++++++++
+:class:`~batdata.schemas.BatteryMetadata` automatically validate inputs,
+and can convert to and JSON formats. (`Pydantic <https://docs.pydantic.dev/latest/>`_!)
 
-We use a component-based approach for the metadata about a dataset.
+See the :mod:`batdata.schemas` for a full accounting of the available fields in our schema.
 
-See the `schemas <https://github.com/ROVI-org/battery-data-toolkit/tree/main/batdata/schemas>`_
-for a full accounting of the available fields in our schema.
+.. note::
 
-.. include:: metadata-schema.rst
+    Validation only checks that already-defined fields are specified properly.
+    Add metadata beyond what is described in battery-data-toolkit as desired.
 
 Source of Terminology
 +++++++++++++++++++++
 
-We use terms from `BattINFO ontology <https://big-map.github.io/BattINFO/index.html>`_ wherever possible.
+The `BattINFO ontology <https://big-map.github.io/BattINFO/index.html>`_ is the core source of terms.
 
 Fields in the schema whose names correspond to a BattINFO term are marked
 with the "IRI" of the field, which points to a website containing the description.
@@ -71,30 +85,37 @@ Look them up using some utilities in ``batdata``.
     print(gather_descendants('MathematicalModel'))
 
 
-Feel free to add fields to any part of the schema.
-The schema is a continual work in progress and the battery-data-toolkit will
-store your new fields.
-Consider adding `an Issue <https://github.com/ROVI-org/battery-data-toolkit/issues>`_ to our GitHub
-if you find you use a term enough that we should add it to the schema.
+.. note::
+    The schema will be a continual work in progress.
+    Consider adding `an Issue <https://github.com/ROVI-org/battery-data-toolkit/issues>`_ to the GitHub
+    if you find you use a term enough it should be part of the schema.
 
-Column Datasets
----------------
+Column Schemas
+--------------
 
-The columns of datasets are described in the `cycling module <https://github.com/ROVI-org/battery-data-toolkit/blob/main/batdata/schemas/cycling.py>`_.
+The contents of each data table available with a dataset are described using a :class:`~batdata.schemas.column.ColumnSchema`.
+The schema is a collection of :class:`~batdata.schemas.column.ColumnInfo` objects detailing each column.
 
-Use the descriptions here when formatting your dataset, playing attention to the sign conventions and units for each column.
+    >>> from batdata.schemas.eis import EISData
+    >>> EISData().test_id.model_dump()
+    {'required': True,
+     'type': <DataType.INTEGER: 'integer'>,
+     'description': 'Integer used to identify rows belonging to the same experiment.',
+     'units': None,
+     'monotonic': False}
 
-Record columns that are not defined in our schema in the ``*_columns`` fields
-of the ``BatteryMetadata``.
+As detailed below, the battery-data-toolkit provides schemas for common types of data (e.g., cycling data for single cells, EIS).
+Document a new type of data by either creating a subclass of :class:`~batdata.schemas.column.ColumnSchema`
+or adding individual columns to an existing schema.
 
 .. code-block:: python
 
-    from batdata.schemas import BatteryMetadata
+    from batdata.schemas.column import RawData, ColumnInfo
 
-    metadata = BatteryMetadata(
-        name='test_cell',
-        raw_data_columns={'new_signal': 'A column not yet defined in our schemas.'}
+    schema = RawData()  # Schema for sensor measurements of cell
+    schema.extra_columns['room_temp'] = ColumnInfo(
+        description='Temperature of the room as measured by the HVAC system',
+        units='C', data_type='float',
     )
 
-
 .. include:: column-schema.rst
diff --git a/docs/user-guide/streaming.rst b/docs/user-guide/streaming.rst
index 967fb2d..5aeb825 100644
--- a/docs/user-guide/streaming.rst
+++ b/docs/user-guide/streaming.rst
@@ -34,4 +34,16 @@ or as part of a for loop.
 Streaming Data to a File
 ------------------------
 
-TBD! Writing incrementally is not yet supported, but a feature on our road map.
+Write large datasets into battery-data-toolkit-compatible formats incrementally using the :class:`~batdata.streaming.hdf5.HDF5Writer`.
+
+Start the writer class by providing the path to the HDF5 file and the metadata to be written
+then opening it via Python's ``with`` syntax.
+
+.. code-block:: python
+
+    metadata = BatteryMetadata(name='example')
+    with HDF5Writer('streamed.h5', metadata=metadata) as writer:
+        for time, current, voltage in data_stream:
+            writer.write_row({'test_time': time, 'current': current, 'voltage': voltage})
+
+The writer only writes to disk after enough rows are collected or the end of a data stream is signaled by exiting the ``with`` block.


Test patch:
diff --git a/tests/files/example-data/resistor-only_complex-cycling.ipynb b/tests/files/example-data/resistor-only_complex-cycling.ipynb
index 029a988..35fe73b 100644
--- a/tests/files/example-data/resistor-only_complex-cycling.ipynb
+++ b/tests/files/example-data/resistor-only_complex-cycling.ipynb
@@ -142,7 +142,7 @@
    "outputs": [
     {
      "data": {
-      "image/png": "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",
+      "image/png": "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",
       "text/plain": [
        "<Figure size 350x200 with 2 Axes>"
       ]
@@ -217,15 +217,27 @@
    "execution_count": 10,
    "id": "9aaa89b5-7ae1-4bfd-9732-7417d42a785b",
    "metadata": {},
-   "outputs": [],
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'raw_data': WindowsPath('single-resistor-complex-charge_from-discharged/raw_data.parquet')}"
+      ]
+     },
+     "execution_count": 10,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
    "source": [
-    "data.to_batdata_hdf('single-resistor-complex-charge_from-discharged.hdf', complevel=9)"
+    "data.to_batdata_hdf('single-resistor-complex-charge_from-discharged.hdf', complevel=9)\n",
+    "data.to_batdata_parquet('single-resistor-complex-charge_from-discharged')"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "e44ad818-a0d7-40fd-ac81-5c936a093bb4",
+   "id": "12052b3e-36df-4696-ac6f-e68977ab5685",
    "metadata": {},
    "outputs": [],
    "source": []
@@ -247,7 +259,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.18"
+   "version": "3.9.19"
   }
  },
  "nbformat": 4,
diff --git a/tests/files/example-data/single-resistor-complex-charge_from-discharged.hdf b/tests/files/example-data/single-resistor-complex-charge_from-discharged.hdf
index c50dd41..5582952 100644
Binary files a/tests/files/example-data/single-resistor-complex-charge_from-discharged.hdf and b/tests/files/example-data/single-resistor-complex-charge_from-discharged.hdf differ
diff --git a/tests/files/example-data/single-resistor-complex-charge_from-discharged/raw_data.parquet b/tests/files/example-data/single-resistor-complex-charge_from-discharged/raw_data.parquet
new file mode 100644
index 0000000..110bc63
Binary files /dev/null and b/tests/files/example-data/single-resistor-complex-charge_from-discharged/raw_data.parquet differ
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 04fa48d..dad0041 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 69d503a..e380b9f 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/postprocess/test_tagging.py b/tests/postprocess/test_tagging.py
index 3fc701a..0a23d86 100644
--- a/tests/postprocess/test_tagging.py
+++ b/tests/postprocess/test_tagging.py
@@ -5,7 +5,7 @@
 
 from batdata.data import BatteryDataset
 from batdata.postprocess.tagging import AddSteps, AddMethod, AddSubSteps
-from batdata.schemas.cycling import ChargingState, ControlMethod
+from batdata.schemas.column import ChargingState, ControlMethod
 
 
 @fixture()
diff --git a/tests/schemas/test_cycling.py b/tests/schemas/test_cycling.py
index 16aab2f..0da927e 100644
--- a/tests/schemas/test_cycling.py
+++ b/tests/schemas/test_cycling.py
@@ -1,4 +1,4 @@
-from batdata.schemas.cycling import RawData
+from batdata.schemas.column import RawData, DataType, ColumnSchema
 
 from pytest import raises, fixture, mark
 import pandas as pd
@@ -15,12 +15,26 @@ def example_df() -> pd.DataFrame:
     })
 
 
+def test_json():
+    """Make sure we can serialize and deserialize classes"""
+
+    as_json = RawData().model_dump_json()
+
+    # Test deserialize using Pydantic, which requires knowing the base class
+    schema = RawData.model_validate_json(as_json)
+    assert schema.state.type == DataType.STATE
+
+    # Test reading using the "unknown base" version
+    schema = ColumnSchema.from_json(as_json)
+    assert schema.state.type == DataType.STATE
+
+
 def test_required():
     """Catch dataframe missing required columns"""
 
     d = pd.DataFrame()
     with raises(ValueError) as exc:
-        RawData.validate_dataframe(d)
+        RawData().validate_dataframe(d)
     assert 'missing a required column' in str(exc)
 
 
@@ -29,11 +43,11 @@ def test_extra_cols(example_df):
     example_df['extra'] = [1, 1]
 
     # Passes with extra columns by default
-    RawData.validate_dataframe(example_df)
+    RawData().validate_dataframe(example_df)
 
     # Fails when desired
     with raises(ValueError) as exc:
-        RawData.validate_dataframe(example_df, allow_extra_columns=False)
+        RawData().validate_dataframe(example_df, allow_extra_columns=False)
     assert 'extra columns' in str(exc)
 
 
@@ -44,17 +58,16 @@ def test_extra_cols(example_df):
 def test_type_failures(example_df, col, values):
     """Columns with the wrong type"""
     example_df[col] = values
-    with raises(ValueError) as exc:
-        RawData.validate_dataframe(example_df)
-    assert 'is a' in str(exc)
+    with raises(ValueError, match=col):
+        RawData().validate_dataframe(example_df)
 
 
 def test_monotonic(example_df):
     """Columns that should be monotonic but are not"""
     example_df['cycle_number'] = [2, 1]
     with raises(ValueError) as exc:
-        RawData.validate_dataframe(example_df)
+        RawData().validate_dataframe(example_df)
     assert 'monotonic' in str(exc)
 
     example_df['cycle_number'] = [1, 1]
-    RawData.validate_dataframe(example_df)
+    RawData().validate_dataframe(example_df)
diff --git a/tests/schemas/test_eis.py b/tests/schemas/test_eis.py
index c372651..faed534 100644
--- a/tests/schemas/test_eis.py
+++ b/tests/schemas/test_eis.py
@@ -19,16 +19,16 @@ def example_df() -> pd.DataFrame:
 
 
 def test_pass(example_df):
-    EISData.validate_dataframe(example_df)
+    EISData().validate_dataframe(example_df)
 
 
 def test_consistency(example_df):
     example_df['z_imag'] *= 2
     with raises(ValueError) as e:
-        EISData.validate_dataframe(example_df)
+        EISData().validate_dataframe(example_df)
     assert 'imag' in str(e.value)
 
     example_df['z_real'] *= 2
     with raises(ValueError) as e:
-        EISData.validate_dataframe(example_df)
+        EISData().validate_dataframe(example_df)
     assert 'real' in str(e.value)
diff --git a/tests/test_data.py b/tests/test_data.py
index d29c5f3..4fae390 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -13,6 +13,7 @@
 
 from batdata.data import BatteryDataset
 from batdata import __version__
+from batdata.schemas.column import ColumnInfo
 
 
 @fixture()
@@ -26,7 +27,11 @@ def test_df():
     cycle_stats = pd.DataFrame({
         'cycle_number': [0],
     })
-    return BatteryDataset(raw_data=raw_data, cycle_stats=cycle_stats, metadata={'name': 'Test data'})
+    dataset = BatteryDataset(raw_data=raw_data, cycle_stats=cycle_stats, metadata={'name': 'Test data'})
+
+    # Add an extra column in the schema
+    dataset.schemas['raw_data'].extra_columns['new'] = ColumnInfo(description='An example column')
+    return dataset
 
 
 def test_write_hdf(tmpdir, test_df):
@@ -42,6 +47,11 @@ def test_write_hdf(tmpdir, test_df):
         assert json.loads(f.attrs['metadata'])['name'] == 'Test data'
         assert 'raw_data' in f
 
+        # Make sure we have a schema
+        g = f['raw_data']
+        assert 'metadata' in g.attrs
+        assert json.loads(g.attrs['metadata'])['test_time']['units'] == 's'
+
     # Test writing to an already-open HDFStore
     with HDFStore(out_path, 'r+') as store:
         test_df.to_batdata_hdf(store)
@@ -61,6 +71,7 @@ def test_read_hdf(tmpdir, test_df):
     assert data.metadata.name == 'Test data'
     assert data.raw_data is not None
     assert data.cycle_stats is not None
+    assert data.schemas['raw_data'].extra_columns['new'].description == 'An example column'
 
     # Test reading from an already-open file
     with HDFStore(out_path, 'r') as store:
@@ -153,7 +164,7 @@ def test_validate(test_df):
     assert 'other' in warnings[0]
 
     # Make sure we can define new columns
-    test_df.metadata.raw_data_columns['other'] = 'A column I added for testing purposes'
+    test_df.schemas['raw_data'].extra_columns['other'] = ColumnInfo(description='Test')
     warnings = test_df.validate()
     assert len(warnings) == 0
 
@@ -163,14 +174,16 @@ def test_parquet(test_df, tmpdir):
     written = test_df.to_batdata_parquet(write_dir)
     assert len(written) == 2
     for file in written.values():
-        metadata = pq.read_schema(file).metadata
+        metadata = pq.read_metadata(file).metadata
         assert b'battery_metadata' in metadata
+        assert b'table_metadata' in metadata
 
     # Read it back in, ensure data are recovered
     read_df = BatteryDataset.from_batdata_parquet(write_dir)
     assert (read_df.cycle_stats['cycle_number'] == test_df.cycle_stats['cycle_number']).all()
     assert (read_df.raw_data['voltage'] == test_df.raw_data['voltage']).all()
     assert read_df.metadata == test_df.metadata
+    assert read_df.schemas['raw_data'].extra_columns['new'].description == 'An example column'
 
     # Test reading subsets
     read_df = BatteryDataset.from_batdata_parquet(write_dir, subsets=('cycle_stats',))
diff --git a/tests/test_stream.py b/tests/test_stream.py
index a9f9773..d838cbb 100644
--- a/tests/test_stream.py
+++ b/tests/test_stream.py
@@ -3,18 +3,26 @@
 from pathlib import Path
 
 import numpy as np
-from pytest import fixture
+import pandas as pd
+from pytest import fixture, mark
 
 from batdata.data import BatteryDataset
 from batdata.extractors.batterydata import BDExtractor
 from batdata.streaming import iterate_records_from_file, iterate_cycles_from_file
+from batdata.streaming.hdf5 import HDF5Writer
 
 
 @fixture()
-def example_h5_path(tmpdir, file_path):
+def example_dataset(file_path):
     data = BDExtractor().parse_to_dataframe([file_path / 'batterydata' / 'p492-13-raw.csv'])
+    data.metadata.name = 'test_name'
+    return data
+
+
+@fixture()
+def example_h5_path(tmpdir, example_dataset):
     h5_path = Path(tmpdir) / 'example_h5'
-    data.to_batdata_hdf(h5_path)
+    example_dataset.to_batdata_hdf(h5_path)
     return h5_path
 
 
@@ -39,3 +47,30 @@ def test_stream_by_cycles(example_h5_path):
     cycle_iter = iterate_cycles_from_file(example_h5_path, make_dataset=True)
     cycle_0 = next(cycle_iter)
     assert cycle_0.metadata == test_data.metadata
+
+
+@mark.parametrize('buffer_size', [128, 400000000])  # Way smaller than data size, way larger
+def test_streaming_write(example_dataset, buffer_size, tmpdir):
+    out_file = Path(tmpdir) / 'streamed.h5'
+    writer = HDF5Writer(out_file, metadata=example_dataset.metadata, buffer_size=buffer_size)
+    assert len(example_dataset.raw_data) > 0
+    with writer:
+        for _, row in example_dataset.raw_data.iterrows():
+            writer.write_row(row.to_dict())
+
+    # Make sure the data are identical
+    copied_data = BatteryDataset.from_batdata_hdf(out_file)
+    assert copied_data.metadata.name == example_dataset.metadata.name
+    cols = ['test_time', 'current']
+    assert np.allclose(copied_data.raw_data[cols], example_dataset.raw_data[cols])
+
+
+def test_streaming_write_existing_store(example_dataset, tmpdir):
+    out_file = Path(tmpdir) / 'streamed.h5'
+    with pd.HDFStore(out_file) as store, HDF5Writer(store, buffer_size=2, complevel=4) as writer:
+        assert writer.write_row({'test_time': 0.}) == 0
+        assert writer.write_row({'test_time': 1.}) == 2
+
+    # Read it in
+    data = BatteryDataset.from_batdata_hdf(out_file)
+    assert np.allclose(data.raw_data['test_time'], [0., 1.])