##############################################################################
# MDTraj: A Python Library for Loading, Saving, and Manipulating
#         Molecular Dynamics Trajectories.
# Copyright 2012-2013 Stanford University and the Authors
#
# Authors: Robert McGibbon
# Contributors:
#
# MDTraj is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, either version 2.1
# of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with MDTraj. If not, see <http://www.gnu.org/licenses/>.
##############################################################################

"""
This module implements the MDTraj HDF5 format described at
https://github.com/mdtraj/mdtraj/wiki/HDF5-Trajectory-Format
"""

##############################################################################
# Imports
##############################################################################

import operator
import os
import warnings
from collections import namedtuple

try:
    import simplejson as json
except ImportError:
    import json

# 3rd party
import numpy as np

# ours
import mdtraj
import mdtraj.core.element as elem
from mdtraj.core.topology import Topology
from mdtraj.formats.registry import FormatRegistry
from mdtraj.utils import cast_indices, ensure_type, import_, in_units_of

__all__ = ["HDF5TrajectoryFile", "load_hdf5"]

Frames = namedtuple(
    "Frames",
    [
        "coordinates",
        "time",
        "cell_lengths",
        "cell_angles",
        "velocities",
        "kineticEnergy",
        "potentialEnergy",
        "temperature",
        "alchemicalLambda",
    ],
)

##############################################################################
# Code
##############################################################################


@FormatRegistry.register_loader(".h5")
@FormatRegistry.register_loader(".hdf5")
def load_hdf5(filename, stride=None, atom_indices=None, frame=None):
    """Load an MDTraj hdf5 trajectory file from disk.

    Parameters
    ----------
    filename : path-like
        Path of HDF Trajectory file.
    stride : int, default=None
        Only read every stride-th frame
    atom_indices : array_like, optional
        If not none, then read only a subset of the atoms coordinates from the
        file. This may be slightly slower than the standard read because it
        requires an extra copy, but will save memory.
    frame : int, optional
        Use this option to load only a single frame from a trajectory on disk.
        If frame is None, the default, the entire trajectory will be loaded.
        If supplied, ``stride`` will be ignored.

    Examples
    --------
    >>> import mdtraj as md
    >>> traj = md.load_hdf5('output.h5')
    >>> print traj
    <mdtraj.Trajectory with 500 frames, 423 atoms at 0x110740a90>

    >>> traj2 = md.load_hdf5('output.h5', stride=2, top='topology.pdb')
    >>> print traj2
    <mdtraj.Trajectory with 250 frames, 423 atoms at 0x11136e410>

    Returns
    -------
    trajectory : md.Trajectory
        The resulting trajectory, as an md.Trajectory object.

    See Also
    --------
    mdtraj.HDF5TrajectoryFile :  Low level interface to HDF5 files
    """
    if not isinstance(filename, (str, os.PathLike)):
        raise TypeError(
            "filename must be of type path-like for load_lh5. you supplied %s" % type(filename),
        )

    atom_indices = cast_indices(atom_indices)

    with HDF5TrajectoryFile(filename) as f:
        if frame is not None:
            f.seek(frame)
            n_frames = 1
        else:
            n_frames = None
        return f.read_as_traj(n_frames=n_frames, stride=stride, atom_indices=atom_indices)


@FormatRegistry.register_fileobject(".h5")
@FormatRegistry.register_fileobject(".hdf5")
class HDF5TrajectoryFile:
    """Interface for reading and writing to a MDTraj HDF5 molecular
    dynamics trajectory file, whose format is described
    `here <https://github.com/rmcgibbo/mdtraj/issues/36>`_.

    This is a file-like object, that both reading or writing depending
    on the `mode` flag. It implements the context manager protocol,
    so you can also use it with the python 'with' statement.

    The format is extremely flexible and high performance. It can hold a wide
    variety of information about a trajectory, including fields like the
    temperature and energies. Because it's built on the fantastic HDF5 library,
    it's easily extensible too.

    Parameters
    ----------
    filename : path-like
        Path to the file to open
    mode :  {'r, 'w'}
        Mode in which to open the file. 'r' is for reading and 'w' is for
        writing
    force_overwrite : bool
        In mode='w', how do you want to behave if a file by the name of `filename`
        already exists? if `force_overwrite=True`, it will be overwritten.
    compression : {'zlib', None}
        Apply compression to the file? This will save space, and does not
        cost too many cpu cycles, so it's recommended.

    Attributes
    ----------
    root
    title
    application
    topology
    randomState
    forcefield
    reference
    constraints

    See Also
    --------
    mdtraj.load_hdf5 : High-level wrapper that returns a ``md.Trajectory``
    """

    distance_unit = "nanometers"

    def __init__(self, filename, mode="r", force_overwrite=True, compression="zlib"):
        self._open = False  # is the file handle currently open?
        self.mode = mode  # the mode in which the file was opened?

        if mode not in ["r", "w", "a"]:
            raise ValueError("mode must be one of ['r', 'w', 'a']")

        if mode == "w" and not force_overwrite and os.path.exists(filename):
            raise OSError('"%s" already exists' % filename)

        # import tables
        self.tables = import_("tables")

        if compression == "zlib":
            compression = self.tables.Filters(complib="zlib", shuffle=True, complevel=1)
        elif compression is None:
            compression = None
        else:
            raise ValueError('compression must be either "zlib" or None')

        self._handle = self._open_file(filename, mode=mode, filters=compression)
        self._open = True

        if mode == "w":
            # what frame are we currently reading or writing at?
            self._frame_index = 0
            # do we need to write the header information?
            self._needs_initialization = True
            if not os.fspath(filename).endswith(".h5"):
                warnings.warn("The .h5 extension is recommended.")

        elif mode == "a":
            try:
                self._frame_index = len(self._handle.root.coordinates)
                self._needs_initialization = False
            except self.tables.NoSuchNodeError:
                self._frame_index = 0
                self._needs_initialization = True
        elif mode == "r":
            self._frame_index = 0
            self._needs_initialization = False

    @property
    def root(self):
        """Direct access to the root group of the underlying Tables HDF5 file handle.

        This can be used for random or specific access to the underlying arrays
        on disk
        """
        _check_mode(self.mode, ("r", "a"))
        return self._handle.root

    #####################################################
    # title global attribute (optional)
    #####################################################

    @property
    def title(self):
        """User-defined title for the data represented in the file"""
        if hasattr(self._handle.root._v_attrs, "title"):
            return str(self._handle.root._v_attrs.title)
        return None

    @title.setter
    def title(self, value):
        """Set the user-defined title for the data represented in the file"""
        _check_mode(self.mode, ("w", "a"))
        self._handle.root._v_attrs.title = str(value)

    #####################################################
    # application global attribute (optional)
    #####################################################

    @property
    def application(self):
        "Suite of programs that created the file"
        if hasattr(self._handle.root._v_attrs, "application"):
            return str(self._handle.root._v_attrs.application)
        return None

    @application.setter
    def application(self, value):
        "Set the suite of programs that created the file"
        _check_mode(self.mode, ("w", "a"))
        self._handle.root._v_attrs.application = str(value)

    #####################################################
    # topology global attribute (optional, recommended)
    #####################################################

    @staticmethod
    def convert_topology_to_dict(topology_object):
        """Method for converting mdtraj.topology to a dictionary

        Parameters
        ----------
        topology_object : mdtraj.core.topology.Topology
            MDTraj Topology object

        Returns
        -------
        topology_dict : dict
            A dictionary summarizing the topology object.
        """
        try:
            topology_dict = {
                "chains": [],
                "bonds": [],
            }

            for chain in topology_object.chains:
                chain_dict = {
                    "residues": [],
                    "index": int(chain.index),
                    "chain_id": str(chain.chain_id) if chain.chain_id else None,
                }
                for residue in chain.residues:
                    residue_dict = {
                        "index": int(residue.index),
                        "name": str(residue.name),
                        "atoms": [],
                        "resSeq": int(residue.resSeq),
                        "segmentID": str(residue.segment_id),
                    }

                    for atom in residue.atoms:
                        try:
                            element_symbol_string = str(atom.element.symbol)
                        except AttributeError:
                            element_symbol_string = ""

                        residue_dict["atoms"].append(
                            {
                                "index": int(atom.index),
                                "name": str(atom.name),
                                "element": element_symbol_string,
                            },
                        )
                    chain_dict["residues"].append(residue_dict)
                topology_dict["chains"].append(chain_dict)

            for atom1, atom2 in topology_object.bonds:
                topology_dict["bonds"].append(
                    [
                        int(atom1.index),
                        int(atom2.index),
                    ],
                )

        except AttributeError as e:
            raise AttributeError(
                "topology_object fails to implement the"
                "chains() -> residue() -> atoms() and bond() protocol. "
                "Specifically, we encountered the following %s" % e,
            )

        return topology_dict

    @property
    def topology(self):
        """Get the topology out from the file

        Returns
        -------
        topology : mdtraj.Topology
            A topology object
        """
        try:
            raw = self._get_node("/", name="topology")[0]
            if not isinstance(raw, str):
                raw = raw.decode()
            topology_dict = json.loads(raw)
        except self.tables.NoSuchNodeError:
            return None

        topology = Topology()

        for chain_dict in sorted(topology_dict["chains"], key=operator.itemgetter("index")):
            chain = topology.add_chain()
            chain.chain_id = chain_dict.get("chain_id", None)
            for residue_dict in sorted(chain_dict["residues"], key=operator.itemgetter("index")):
                try:
                    resSeq = residue_dict["resSeq"]
                except KeyError:
                    resSeq = None
                    warnings.warn("No resSeq information found in HDF file, defaulting to zero-based indices")
                try:
                    segment_id = residue_dict["segmentID"]
                except KeyError:
                    segment_id = ""
                residue = topology.add_residue(residue_dict["name"], chain, resSeq=resSeq, segment_id=segment_id)
                for atom_dict in sorted(residue_dict["atoms"], key=operator.itemgetter("index")):
                    try:
                        element = elem.get_by_symbol(atom_dict["element"])
                    except KeyError:
                        element = elem.virtual
                    topology.add_atom(atom_dict["name"], element, residue)

        atoms = list(topology.atoms)
        for index1, index2 in topology_dict["bonds"]:
            topology.add_bond(atoms[index1], atoms[index2])

        return topology

    @topology.setter
    def topology(self, topology_object):
        """Set the topology in the file

        Parameters
        ----------
        topology_object : mdtraj.Topology | openmm.app.Topology | None
            A topology object from mdtraj, openmm, or None
        """
        _check_mode(self.mode, ("w", "a"))

        if topology_object is None:
            # Skip parsing if `topology = None`
            # Will remove topology node from HDF5 File
            topology_dict = None
        elif isinstance(topology_object, Topology):
            # Convert topology to dictionary
            topology_dict = self.convert_topology_to_dict(topology_object)
        else:
            # we want to be able to handle the openmm Topology object
            # here too, so if it's not an mdtraj topology or None
            # we'll just guess that it's probably an openmm topology and convert
            topology_object = Topology.from_openmm(topology_object)
            topology_dict = self.convert_topology_to_dict(topology_object)

        # removing the topology node if it exists
        try:
            self._remove_node(where="/", name="topology")
        except self.tables.NoSuchNodeError:
            pass

        # transforming the topology as json
        data = json.dumps(topology_dict)
        if data == "null":
            return
        elif not isinstance(data, bytes):
            data = data.encode("ascii")

        # actually set the tables
        self._handle.create_array(where="/", name="topology", obj=[data])

    #####################################################
    # randomState global attribute (optional)
    #####################################################

    @property
    def randomState(self):
        "State of the creators internal random number generator at the start of the simulation"
        if hasattr(self._handle.root._v_attrs, "randomState"):
            return str(self._handle.root._v_attrs.randomState)
        return None

    @randomState.setter
    def randomState(self, value):
        "Set the state of the creators internal random number generator at the start of the simulation"
        _check_mode(self.mode, ("w", "a"))
        self._handle.root._v_attrs.randomState = str(value)

    #####################################################
    # forcefield global attribute (optional)
    #####################################################

    @property
    def forcefield(self):
        "Description of the hamiltonian used. A short, human readable string, like AMBER99sbildn."
        if hasattr(self._handle.root._v_attrs, "forcefield"):
            return str(self._handle.root._v_attrs.forcefield)
        return None

    @forcefield.setter
    def forcefield(self, value):
        "Set the description of the hamiltonian used. A short, human readable string, like AMBER99sbildn."
        _check_mode(self.mode, ("w", "a"))
        self._handle.root._v_attrs.forcefield = str(value)

    #####################################################
    # reference global attribute (optional)
    #####################################################

    @property
    def reference(self):
        "A published reference that documents the program or parameters used to generate the data"
        if hasattr(self._handle.root._v_attrs, "reference"):
            return str(self._handle.root._v_attrs.reference)
        return None

    @reference.setter
    def reference(self, value):
        "Set a published reference that documents the program or parameters used to generate the data"
        _check_mode(self.mode, ("w", "a"))
        self._handle.root._v_attrs.reference = str(value)

    #####################################################
    # constraints array (optional)
    #####################################################

    @property
    def constraints(self):
        """Constraints applied to the bond lengths

        Returns
        -------
        constraints : {None, np.array, dtype=[('atom1', '<i4'), ('atom2', '<i4'), ('distance', '<f4')])}
            A one dimensional array with the a int, int, float type giving
            the index of the two atoms involved in the constraints and the
            distance of the constraint. If no constraint information
            is in the file, the return value is None.
        """
        if hasattr(self._handle.root, "constraints"):
            return self._handle.root.constraints[:]
        return None

    @constraints.setter
    def constraints(self, value):
        """Set the constraints applied to bond lengths

        Returns
        -------
        valu : np.array, dtype=[('atom1', '<i4'), ('atom2', '<i4'), ('distance', '<f4')])
            A one dimensional array with the a int, int, float type giving
            the index of the two atoms involved in the constraints and the
            distance of the constraint.
        """
        _check_mode(self.mode, ("w", "a"))

        dtype = np.dtype(
            [
                ("atom1", np.int32),
                ("atom2", np.int32),
                ("distance", np.float32),
            ],
        )
        if not value.dtype == dtype:
            raise ValueError(
                "Constraints must be an array with dtype=%s. currently, I don't do any casting" % dtype,
            )

        if not hasattr(self._handle.root, "constraints"):
            self._create_table(
                where="/",
                name="constraints",
                description=dtype,
            )

        self._handle.root.constraints.truncate(0)
        self._handle.root.constraints.append(value)

    #####################################################
    # read/write methods for file-like behavior
    #####################################################

    def read_as_traj(self, n_frames=None, stride=None, atom_indices=None):
        """Read a trajectory from the HDF5 file

        Parameters
        ----------
        n_frames : {int, None}
            The number of frames to read. If not supplied, all of the
            remaining frames will be read.
        stride : {int, None}
            By default all of the frames will be read, but you can pass this
            flag to read a subset of of the data by grabbing only every
            `stride`-th frame from disk.
        atom_indices : {int, None}
            By default all of the atom  will be read, but you can pass this
            flag to read only a subsets of the atoms for the `coordinates` and
            `velocities` fields. Note that you will have to carefully manage
            the indices and the offsets, since the `i`-th atom in the topology
            will not necessarily correspond to the `i`-th atom in your subset.

        Returns
        -------
        trajectory : Trajectory
            A trajectory object containing the loaded portion of the file.
        """
        _check_mode(self.mode, ("r",))

        from mdtraj.core.trajectory import Trajectory

        topology = self.topology
        if atom_indices is not None:
            topology = topology.subset(atom_indices)

        data = self.read(n_frames=n_frames, stride=stride, atom_indices=atom_indices)
        if len(data) == 0:
            return Trajectory(xyz=np.zeros((0, topology.n_atoms, 3)), topology=topology)

        in_units_of(data.coordinates, self.distance_unit, Trajectory._distance_unit, inplace=True)
        in_units_of(data.cell_lengths, self.distance_unit, Trajectory._distance_unit, inplace=True)

        return Trajectory(
            xyz=data.coordinates,
            topology=topology,
            time=data.time,
            unitcell_lengths=data.cell_lengths,
            unitcell_angles=data.cell_angles,
        )

    def read(self, n_frames=None, stride=None, atom_indices=None):
        """Read one or more frames of data from the file

        Parameters
        ----------
        n_frames : {int, None}
            The number of frames to read. If not supplied, all of the
            remaining frames will be read.
        stride : {int, None}
            By default all of the frames will be read, but you can pass this
            flag to read a subset of of the data by grabbing only every
            `stride`-th frame from disk.
        atom_indices : {int, None}
            By default all of the atom  will be read, but you can pass this
            flag to read only a subsets of the atoms for the `coordinates` and
            `velocities` fields. Note that you will have to carefully manage
            the indices and the offsets, since the `i`-th atom in the topology
            will not necessarily correspond to the `i`-th atom in your subset.

        Notes
        -----
        If you'd like more flexible access to the data, that is available by
        using the pytables group directly, which is accessible via the
        `root` property on this class.

        Returns
        -------
        frames : namedtuple
            The returned namedtuple will have the fields "coordinates", "time", "cell_lengths",
            "cell_angles", "velocities", "kineticEnergy", "potentialEnergy",
            "temperature" and "alchemicalLambda". Each of the fields in the
            returned namedtuple will either be a numpy array or None, dependening
            on if that data was saved in the trajectory. All of the data shall be
            n units of "nanometers", "picoseconds", "kelvin", "degrees" and
            "kilojoules_per_mole".
        """
        _check_mode(self.mode, ("r",))

        if n_frames is None:
            n_frames = np.inf
        if stride is not None:
            stride = int(stride)

        total_n_frames = len(self._handle.root.coordinates)
        frame_slice = slice(self._frame_index, min(self._frame_index + n_frames, total_n_frames), stride)
        if frame_slice.stop - frame_slice.start == 0:
            return []

        if atom_indices is None:
            # get all of the atoms
            atom_slice = slice(None)
        else:
            atom_slice = ensure_type(
                atom_indices,
                dtype=int,
                ndim=1,
                name="atom_indices",
                warn_on_cast=False,
            )
            if not np.all(atom_slice < self._handle.root.coordinates.shape[1]):
                raise ValueError(
                    "As a zero-based index, the entries in "
                    "atom_indices must all be less than the number of atoms "
                    "in the trajectory, %d" % self._handle.root.coordinates.shape[1],
                )
            if not np.all(atom_slice >= 0):
                raise ValueError(
                    "The entries in atom_indices must be greater than or equal to zero",
                )

        def get_field(name, slice, out_units, can_be_none=True):
            try:
                node = self._get_node(where="/", name=name)
                data = node.__getitem__(slice)
                in_units = node.attrs.units
                if not isinstance(in_units, str):
                    in_units = in_units.decode()
                data = in_units_of(data, in_units, out_units)
                return data
            except self.tables.NoSuchNodeError:
                if can_be_none:
                    return None
                raise

        frames = Frames(
            coordinates=get_field(
                "coordinates",
                (frame_slice, atom_slice, slice(None)),
                out_units="nanometers",
                can_be_none=False,
            ),
            time=get_field("time", frame_slice, out_units="picoseconds"),
            cell_lengths=get_field("cell_lengths", (frame_slice, slice(None)), out_units="nanometers"),
            cell_angles=get_field("cell_angles", (frame_slice, slice(None)), out_units="degrees"),
            velocities=get_field(
                "velocities",
                (frame_slice, atom_slice, slice(None)),
                out_units="nanometers/picosecond",
            ),
            kineticEnergy=get_field("kineticEnergy", frame_slice, out_units="kilojoules_per_mole"),
            potentialEnergy=get_field("potentialEnergy", frame_slice, out_units="kilojoules_per_mole"),
            temperature=get_field("temperature", frame_slice, out_units="kelvin"),
            alchemicalLambda=get_field("lambda", frame_slice, out_units="dimensionless"),
        )

        self._frame_index += frame_slice.stop - frame_slice.start
        return frames

    def write(
        self,
        coordinates,
        time=None,
        cell_lengths=None,
        cell_angles=None,
        velocities=None,
        kineticEnergy=None,
        potentialEnergy=None,
        temperature=None,
        alchemicalLambda=None,
    ):
        """Write one or more frames of data to the file

        This method saves data that is associated with one or more simulation
        frames. Note that all of the arguments can either be raw numpy arrays
        or unitted arrays (with openmm.unit.Quantity). If the arrays are
        unittted, a unit conversion will be automatically done from the
        supplied units into the proper units for saving on disk. You won't have
        to worry about it.

        Furthermore, if you wish to save a single frame of simulation data, you
        can do so naturally, for instance by supplying a 2d array for the
        coordinates and a single float for the time. This "shape deficiency"
        will be recognized, and handled appropriately.

        Parameters
        ----------
        coordinates : np.ndarray, shape=(n_frames, n_atoms, 3)
            The cartesian coordinates of the atoms to write. By convention, the
            lengths should be in units of nanometers.
        time : np.ndarray, shape=(n_frames,), optional
            You may optionally specify the simulation time, in picoseconds
            corresponding to each frame.
        cell_lengths : np.ndarray, shape=(n_frames, 3), dtype=float32, optional
            You may optionally specify the unitcell lengths.
            The length of the periodic box in each frame, in each direction,
            `a`, `b`, `c`. By convention the lengths should be in units
            of angstroms.
        cell_angles : np.ndarray, shape=(n_frames, 3), dtype=float32, optional
            You may optionally specify the unitcell angles in each frame.
            Organized analogously to cell_lengths. Gives the alpha, beta and
            gamma angles respectively. By convention, the angles should be
            in units of degrees.
        velocities :  np.ndarray, shape=(n_frames, n_atoms, 3), optional
            You may optionally specify the cartesian components of the velocity
            for each atom in each frame. By convention, the velocities
            should be in units of nanometers / picosecond.
        kineticEnergy : np.ndarray, shape=(n_frames,), optional
            You may optionally specify the kinetic energy in each frame. By
            convention the kinetic energies should b in units of kilojoules per
            mole.
        potentialEnergy : np.ndarray, shape=(n_frames,), optional
            You may optionally specify the potential energy in each frame. By
            convention the kinetic energies should b in units of kilojoules per
            mole.
        temperature : np.ndarray, shape=(n_frames,), optional
            You may optionally specify the temperature in each frame. By
            convention the temperatures should b in units of Kelvin.
        alchemicalLambda : np.ndarray, shape=(n_frames,), optional
            You may optionally specify the alchemical lambda in each frame. These
            have no units, but are generally between zero and one.
        """
        _check_mode(self.mode, ("w", "a"))

        # these must be either both present or both absent. since
        # we're going to throw an error if one is present w/o the other,
        # lets do it now.
        if cell_lengths is None and cell_angles is not None:
            raise ValueError("cell_lengths were given, but no cell_angles")
        if cell_lengths is not None and cell_angles is None:
            raise ValueError("cell_angles were given, but no cell_lengths")

        # if the input arrays are openmm.unit.Quantities, convert them
        # into md units. Note that this acts as a no-op if the user doesn't
        # have openmm.unit installed (e.g. they didn't install OpenMM)
        coordinates = in_units_of(coordinates, None, "nanometers")
        time = in_units_of(time, None, "picoseconds")
        cell_lengths = in_units_of(cell_lengths, None, "nanometers")
        cell_angles = in_units_of(cell_angles, None, "degrees")
        velocities = in_units_of(velocities, None, "nanometers/picosecond")
        kineticEnergy = in_units_of(kineticEnergy, None, "kilojoules_per_mole")
        potentialEnergy = in_units_of(potentialEnergy, None, "kilojoules_per_mole")
        temperature = in_units_of(temperature, None, "kelvin")
        alchemicalLambda = in_units_of(alchemicalLambda, None, "dimensionless")

        # do typechecking and shapechecking on the arrays
        # this ensure_type method has a lot of options, but basically it lets
        # us validate most aspects of the array. Also, we can upconvert
        # on defficent ndim, which means that if the user sends in a single
        # frame of data (i.e. coordinates is shape=(n_atoms, 3)), we can
        # realize that. obviously the default mode is that they want to
        # write multiple frames at a time, so the coordinate shape is
        # (n_frames, n_atoms, 3)
        coordinates = ensure_type(
            coordinates,
            dtype=np.float32,
            ndim=3,
            name="coordinates",
            shape=(None, None, 3),
            can_be_none=False,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        (
            n_frames,
            n_atoms,
        ) = coordinates.shape[0:2]
        time = ensure_type(
            time,
            dtype=np.float32,
            ndim=1,
            name="time",
            shape=(n_frames,),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        cell_lengths = ensure_type(
            cell_lengths,
            dtype=np.float32,
            ndim=2,
            name="cell_lengths",
            shape=(n_frames, 3),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        cell_angles = ensure_type(
            cell_angles,
            dtype=np.float32,
            ndim=2,
            name="cell_angles",
            shape=(n_frames, 3),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        velocities = ensure_type(
            velocities,
            dtype=np.float32,
            ndim=3,
            name="velocities",
            shape=(n_frames, n_atoms, 3),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        kineticEnergy = ensure_type(
            kineticEnergy,
            dtype=np.float32,
            ndim=1,
            name="kineticEnergy",
            shape=(n_frames,),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        potentialEnergy = ensure_type(
            potentialEnergy,
            dtype=np.float32,
            ndim=1,
            name="potentialEnergy",
            shape=(n_frames,),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        temperature = ensure_type(
            temperature,
            dtype=np.float32,
            ndim=1,
            name="temperature",
            shape=(n_frames,),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )
        alchemicalLambda = ensure_type(
            alchemicalLambda,
            dtype=np.float32,
            ndim=1,
            name="alchemicalLambda",
            shape=(n_frames,),
            can_be_none=True,
            warn_on_cast=False,
            add_newaxis_on_deficient_ndim=True,
        )

        # if this is our first call to write(), we need to create the headers
        # and the arrays in the underlying HDF5 file
        if self._needs_initialization:
            self._initialize_headers(
                n_atoms=n_atoms,
                set_coordinates=True,
                set_time=(time is not None),
                set_cell=(cell_lengths is not None or cell_angles is not None),
                set_velocities=(velocities is not None),
                set_kineticEnergy=(kineticEnergy is not None),
                set_potentialEnergy=(potentialEnergy is not None),
                set_temperature=(temperature is not None),
                set_alchemicalLambda=(alchemicalLambda is not None),
            )
            self._needs_initialization = False

            # we need to check that that the entries that the user is trying
            # to save are actually fields in OUR file

        try:
            # try to get the nodes for all of the fields that we have
            # which are not None
            for name in [
                "coordinates",
                "time",
                "cell_angles",
                "cell_lengths",
                "velocities",
                "kineticEnergy",
                "potentialEnergy",
                "temperature",
            ]:
                contents = locals()[name]
                if contents is not None:
                    self._get_node(where="/", name=name).append(contents)
                if contents is None:
                    # for each attribute that they're not saving, we want
                    # to make sure the file doesn't explect it
                    try:
                        self._get_node(where="/", name=name)
                        raise AssertionError()
                    except self.tables.NoSuchNodeError:
                        pass

            # lambda is different, since the name in the file is lambda
            # but the name in this python function is alchemicalLambda
            name = "lambda"
            if alchemicalLambda is not None:
                self._get_node(where="/", name=name).append(alchemicalLambda)
            else:
                try:
                    self._get_node(where="/", name=name)
                    raise AssertionError()
                except self.tables.NoSuchNodeError:
                    pass

        except self.tables.NoSuchNodeError:
            raise ValueError(
                "The file that you're trying to save to doesn't "
                f"contain the field {name}. You can always save a new trajectory "
                "and have it contain this information, but I don't allow 'ragged' "
                f"arrays. If one frame is going to have {name} information, then I expect "
                "all of them to. So I can't save it for just these frames. Sorry "
                "about that :)",
            )
        except AssertionError:
            raise ValueError(
                "The file that you're saving to expects each frame "
                f"to contain {name} information, but you did not supply it."
                "I don't allow 'ragged' arrays. If one frame is going "
                f"to have {name} information, then I expect all of them to. ",
            )

        self._frame_index += n_frames
        self.flush()

    def _initialize_headers(
        self,
        n_atoms,
        set_coordinates,
        set_time,
        set_cell,
        set_velocities,
        set_kineticEnergy,
        set_potentialEnergy,
        set_temperature,
        set_alchemicalLambda,
    ):
        self._n_atoms = n_atoms

        self._handle.root._v_attrs.conventions = "Pande"
        self._handle.root._v_attrs.conventionVersion = "1.1"
        self._handle.root._v_attrs.program = "MDTraj"
        self._handle.root._v_attrs.programVersion = mdtraj.__version__
        self._handle.root._v_attrs.title = "title"

        # if the client has not the title attribute themselves, we'll
        # set it to MDTraj as a default option.
        if not hasattr(self._handle.root._v_attrs, "application"):
            self._handle.root._v_attrs.application = "MDTraj"

        # create arrays that store frame level informat
        if set_coordinates:
            self._create_earray(
                where="/",
                name="coordinates",
                atom=self.tables.Float32Atom(),
                shape=(0, self._n_atoms, 3),
            )
            self._handle.root.coordinates.attrs["units"] = "nanometers"

        if set_time:
            self._create_earray(
                where="/",
                name="time",
                atom=self.tables.Float32Atom(),
                shape=(0,),
            )
            self._handle.root.time.attrs["units"] = "picoseconds"

        if set_cell:
            self._create_earray(
                where="/",
                name="cell_lengths",
                atom=self.tables.Float32Atom(),
                shape=(0, 3),
            )
            self._create_earray(
                where="/",
                name="cell_angles",
                atom=self.tables.Float32Atom(),
                shape=(0, 3),
            )
            self._handle.root.cell_lengths.attrs["units"] = "nanometers"
            self._handle.root.cell_angles.attrs["units"] = "degrees"

        if set_velocities:
            self._create_earray(
                where="/",
                name="velocities",
                atom=self.tables.Float32Atom(),
                shape=(0, self._n_atoms, 3),
            )
            self._handle.root.velocities.attrs["units"] = "nanometers/picosecond"

        if set_kineticEnergy:
            self._create_earray(
                where="/",
                name="kineticEnergy",
                atom=self.tables.Float32Atom(),
                shape=(0,),
            )
            self._handle.root.kineticEnergy.attrs["units"] = "kilojoules_per_mole"

        if set_potentialEnergy:
            self._create_earray(
                where="/",
                name="potentialEnergy",
                atom=self.tables.Float32Atom(),
                shape=(0,),
            )
            self._handle.root.potentialEnergy.attrs["units"] = "kilojoules_per_mole"

        if set_temperature:
            self._create_earray(
                where="/",
                name="temperature",
                atom=self.tables.Float32Atom(),
                shape=(0,),
            )
            self._handle.root.temperature.attrs["units"] = "kelvin"

        if set_alchemicalLambda:
            self._create_earray(
                where="/",
                name="lambda",
                atom=self.tables.Float32Atom(),
                shape=(0,),
            )
            self._get_node("/", name="lambda").attrs["units"] = "dimensionless"

    def seek(self, offset, whence=0):
        """Move to a new file position

        Parameters
        ----------
        offset : int
            A number of frames.
        whence : {0, 1, 2}
            0: offset from start of file, offset should be >=0.
            1: move relative to the current position, positive or negative
            2: move relative to the end of file, offset should be <= 0.
            Seeking beyond the end of a file is not supported
        """
        _check_mode(self.mode, ("r",))

        if whence == 0 and offset >= 0:
            self._frame_index = offset
        elif whence == 1:
            self._frame_index = self._frame_index + offset
        elif whence == 2 and offset <= 0:
            self._frame_index = len(self._handle.root.coordinates) + offset
        else:
            raise OSError("Invalid argument")

    def tell(self):
        """Current file position

        Returns
        -------
        offset : int
            The current frame in the file.
        """
        return int(self._frame_index)

    def _validate(self):
        raise NotImplementedError()

        # check that all of the shapes are consistent
        # check that everything has units

    @property
    def _get_node(self):
        return self._handle.get_node

    @property
    def _create_earray(self):
        return self._handle.create_earray

    @property
    def _create_table(self):
        return self._handle.create_table

    @property
    def _remove_node(self):
        return self._handle.remove_node

    @property
    def _open_file(self):
        return self.tables.open_file

    def close(self):
        "Close the HDF5 file handle"
        if self._open:
            self._handle.close()
            self._open = False

    def flush(self):
        "Write all buffered data in the to the disk file."
        if self._open:
            self._handle.flush()

    def __del__(self):
        self.close()

    def __enter__(self):
        "Support the context manager protocol"
        return self

    def __exit__(self, *exc_info):
        "Support the context manager protocol"
        self.close()

    def __len__(self):
        "Number of frames in the file"
        if not self._open:
            raise ValueError("I/O operation on closed file")
        return len(self._handle.root.coordinates)


def _check_mode(m, modes):
    if m not in modes:
        raise ValueError(
            'This operation is only available when a file is open in mode="%s".' % m,
        )
