# -*- coding: utf-8 -*-

"""A node or step for LAMMPS in a flowchart"""

import copy
import glob
import logging
from math import sqrt, exp, degrees, radians, cos, acos
from pathlib import Path
import os
import os.path
import pkg_resources
import pprint
import re
import shutil
import string
import sys
import traceback

import bibtexparser
import numpy
import pandas
import psutil
from scipy.stats import t as t_student
import statsmodels.tsa.stattools as stattools

import lammps_step
import molsystem
import seamm
import seamm_util
import seamm_util.printing as printing
from seamm_util.printing import FormattedText as __

from pymbar import timeseries

logger = logging.getLogger("lammps")
job = printing.getPrinter()
printer = printing.getPrinter("lammps")

# Add LAMMPS's properties to the standard properties
path = Path(pkg_resources.resource_filename(__name__, "data/"))
csv_file = path / "properties.csv"
molsystem.add_properties_from_file(csv_file)

bond_style = {
    "quadratic_bond": "harmonic",
    "quartic_bond": "class2",
    "fene": "fene",
    "morse": "morse",
}

angle_style = {
    "quadratic_angle": "harmonic",
    "quartic_angle": "class2",
}

dihedral_style = {
    "torsion_1": "harmonic",
    "torsion_3": "class2",
}

improper_style = {
    "wilson_out_of_plane": "class2",
}


class LAMMPS(seamm.Node):
    display_units = {
        "T": "K",
        "P": "atm",
        "t": "fs",
        "density": "g/mL",
        "a": "Å",
        "b": "Å",
        "c": "Å",
        "Etot": "kcal/mol",
        "Eke": "kcal/mol",
        "Epe": "kcal/mol",
        "Emol": "kcal/mol",
        "Epair": "kcal/mol",
    }
    display_title = {
        "T": "Temperature",
        "P": "Pressure",
        "t": "Time",
        "density": "Density",
        "a": "a lattice parameter",
        "b": "b lattice parameter",
        "c": "c lattice parameter",
        "Etot": "Total Energy",
        "Eke": "Kinetic Energy",
        "Epe": "Potential Energy",
        "Emol": "Molecular Energy, Valence Terms",
        "Epair": "Pair (Nonbond) Energy",
    }

    def __init__(
        self, flowchart=None, namespace="org.molssi.seamm.lammps", extension=None
    ):
        """Setup the main LAMMPS step

        Keyword arguments:
        """
        logger.debug("Creating LAMMPS {}".format(self))

        # The subflowchart
        self.subflowchart = seamm.Flowchart(
            parent=self, name="LAMMPS", namespace=namespace
        )
        self._initialization_node = None
        self._trajectory = []
        self._data = {}

        super().__init__(
            flowchart=flowchart, title="LAMMPS", extension=extension, logger=logger
        )

    @property
    def version(self):
        """The semantic version of this module."""
        return lammps_step.__version__

    @property
    def git_revision(self):
        """The git version of this module."""
        return lammps_step.__git_revision__

    @staticmethod
    def box_to_cell(lx, ly, lz, xy, xz, yz):
        """Convert the LAMMPS box definition to cell parameters."""
        if xy == 0 and xz == 0 and yz == 0:
            a = lx
            b = ly
            c = lz
            alpha = 0.0
            beta = 0.0
            gamma = 0.0
        else:
            a = lx
            b = sqrt(ly**2 + xy**2)
            c = sqrt(lz**2 + xz**2 + yz**2)
            alpha = degrees(acos((xy * xz + lx * yz) / (b * c)))
            beta = degrees(acos(xz / c))
            gamma = degrees(acos(xy / b))

        return (a, b, c, alpha, beta, gamma)

    @staticmethod
    def cell_to_box(a, b, c, alpha, beta, gamma):
        """Convert cell parameters to the LAMMPS box."""
        if alpha == 90 and beta == 90 and gamma == 90:
            lx = a
            ly = b
            lz = c
            xy = xz = yz = 0.0
        else:
            lx = 0
            xy = b * cos(radians(gamma))
            xz = c * cos(radians(beta))
            ly = sqrt(b**2 - xy**2)
            yz = (b * c * cos(radians(alpha)) - xy * xz) / ly
            lz = sqrt(c**2 - xz**2 - yz**2)

        return (lx, ly, lz, xy, xz, yz)

    def create_parser(self):
        """Setup the command-line / config file parser"""
        parser_name = self.step_type
        parser = seamm_util.getParser()

        # Remember if the parser exists ... this type of step may have been
        # found before
        parser_exists = parser.exists(parser_name)

        # Create the standard options, e.g. log-level
        result = super().create_parser(name=parser_name)

        if parser_exists:
            return result

        # LAMMPS specific options
        parser.add_argument(
            parser_name,
            "--ncores",
            default="available",
            help=(
                "The maximum number of cores to use for LAMMPS. "
                "Default: all available cores."
            ),
        )
        parser.add_argument(
            parser_name,
            "--atoms-per-core",
            type=int,
            default="1000",
            help="the optimal number of atoms per core for LAMMPS",
        )
        parser.add_argument(
            parser_name,
            "--html",
            action="store_true",
            help="whether to write out html files for graphs, etc.",
        )
        parser.add_argument(
            parser_name,
            "--modules",
            nargs="*",
            default=None,
            help="the environment modules to load for LAMMPS",
        )
        parser.add_argument(
            parser_name,
            "--lammps-path",
            default=None,
            help="the path to the LAMMPS executables",
        )
        parser.add_argument(
            parser_name,
            "--lammps-serial",
            default="lmp_serial",
            help="the serial version of LAMMPS",
        )
        parser.add_argument(
            parser_name,
            "--lammps-mpi",
            default="lmp_mpi",
            help="the mpi version of LAMMPS",
        )
        parser.add_argument(
            parser_name, "--mpiexec", default="mpiexec", help="the mpi executable"
        )

        return result

    def set_id(self, node_id):
        """Set the id for node to a given tuple"""
        self._id = node_id

        # and set our subnodes
        self.subflowchart.set_ids(self._id)

        return self.next()

    def description_text(self, P=None):
        """Return a short description of this step.

        Return a nicely formatted string describing what this step will
        do.

        Keyword arguments:
            P: a dictionary of parameter values, which may be variables
                or final values. If None, then the parameters values will
                be used as is.
        """

        self.subflowchart.root_directory = self.flowchart.root_directory

        # Get the first real node
        node = self.subflowchart.get_node("1").next()

        text = self.header + "\n\n"

        while node is not None:
            try:
                text += __(node.description_text(), indent=3 * " ").__str__()
            except Exception as e:
                print(
                    "Error describing LAMMPS flowchart: {} in {}".format(
                        str(e), str(node)
                    )
                )
                self.logger.critical(
                    "Error describing LAMMPS flowchart: {} in {}".format(
                        str(e), str(node)
                    )
                )
                raise
            except:  # noqa: E722
                print(
                    "Unexpected error describing LAMMPS flowchart: {} in {}".format(
                        sys.exc_info()[0], str(node)
                    )
                )
                self.logger.critical(
                    "Unexpected error describing LAMMPS flowchart: {} in {}".format(
                        sys.exc_info()[0], str(node)
                    )
                )
                raise
            text += "\n"
            node = node.next()

        return text

    def run(self):
        """Run a LAMMPS simulation"""

        system_db = self.get_variable("_system_db")
        configuration = system_db.system.configuration

        n_atoms = configuration.n_atoms
        if n_atoms == 0:
            self.logger.error("LAMMPS run(): there is no structure!")
            raise RuntimeError("LAMMPS run(): there is no structure!")

        next_node = super().run(printer)

        # Get the options
        o = self.options
        global_options = self.global_options

        # Whether to run parallel and if so, how many mpi processes
        if global_options["parallelism"] in ("any", "mpi"):
            np = o["ncores"]
            if np == "available":
                np = global_options["ncores"]

            if np == "available":
                np = int(round(n_atoms / o["atoms_per_core"]))
                if np < 1:
                    np = 1

                # How many processors does this node have?
                n_cores = psutil.cpu_count(logical=False)
                self.logger.info("The number of cores is {}".format(n_cores))

                if np > n_cores:
                    self.logger.info(
                        f"LAMMPS could use {np} cores, but only {n_cores} are "
                        "available"
                    )
                    np = n_cores
            else:
                np = int(np)
        else:
            np = 1

        # Print headers and get to work
        printer.important(self.header)
        if np > 1:
            printer.important("    LAMMPS using MPI with {} processes.\n".format(np))
        else:
            printer.important("   LAMMPS using the serial version.\n")

        self.subflowchart.root_directory = self.flowchart.root_directory

        files = {}

        # Get the first real node
        node = self.subflowchart.get_node("1").next()

        extras = {}

        history_nodes = []

        # Create overall directory for the lammps step

        os.makedirs(self.directory, exist_ok=True)

        files = {}

        while node is not None:

            if isinstance(node, lammps_step.Initialization):
                initialization_header, eex = self._get_node_input(
                    node=node, extras={"read_data": True}
                )
                files["structure"] = {}
                files["structure"]["filename"] = "structure.dat"
                files["structure"]["data"] = "\n".join(self.structure_data(eex))

                f = os.path.join(self.directory, files["structure"]["filename"])
                with open(f, mode="w") as fd:
                    fd.write(files["structure"]["data"])

                self.logger.debug(
                    files["structure"]["filename"] + ": " + files["structure"]["data"]
                )

                self._initialization_node = node

                files["input"] = {}
                files["input"]["filename"] = None
                files["input"]["data"] = copy.deepcopy(initialization_header)

                # Find the bond & angle types as needed for shake/rattle
                P = node.parameters.current_values_to_dict(
                    context=seamm.flowchart_variables._data
                )

                shake = self.shake_fix(P, eex)
                if shake != "":
                    extras["shake"] = shake

            else:

                P = node.parameters.current_values_to_dict(
                    context=seamm.flowchart_variables._data
                )

                if "run_control" not in P or "Until" not in P["run_control"]:

                    history_nodes.append(node)

                else:

                    if len(history_nodes) > 0:  # if imcccc

                        files = self._prepare_input(
                            files,
                            nodes=history_nodes,
                            read_restart=False,
                            write_restart=True,
                            extras=extras,
                        )

                        files = self._execute_single_sim(files, np=np)

                        self.analyze(nodes=history_nodes)

                        self._trajectory = []

                    iteration = 0

                    extras["nsteps"] = 666

                    while True:

                        extras["nsteps"] = round(1.5 * extras["nsteps"])

                        control_properties = {}

                        for prp in P["control_properties"]:
                            k = prp[0]
                            control_properties[k] = {
                                "accuracy": float(prp[1][0].strip("%")),
                                "units": prp[1][1],
                                "enough_accuracy": False,
                            }

                        P = node.parameters.current_values_to_dict(
                            context=seamm.flowchart_variables._data
                        )

                        files = self._prepare_input(
                            files,
                            nodes=node,
                            iteration=iteration,
                            read_restart=True,
                            write_restart=True,
                            extras=extras,
                        )

                        files = self._execute_single_sim(files, np=np)

                        # Analyze the results
                        analysis = self.analyze(nodes=node)
                        node_id = node._id[1]
                        for prp, v in analysis[node_id].items():
                            if v["short_production"] is False:
                                if v["few_neff"] is False:

                                    accuracy = control_properties[prp]["accuracy"] / 100
                                    dof = v["n_sample"]
                                    mean = v["mean"]
                                    ci = t_student.interval(
                                        0.95, dof - 1, loc=0, scale=1
                                    )
                                    interval = (ci[1] - ci[0]) * v["sem"]
                                    print(abs(interval / mean))
                                    if abs(interval / mean) < accuracy:
                                        control_properties[prp][
                                            "enough_accuracy"
                                        ] = True

                        enough_acc = [
                            v["enough_accuracy"]
                            for prp, v in control_properties.items()
                        ]
                        if all(enough_acc) is True:
                            history_nodes = []

                            initialization_header, eex = self._get_node_input(
                                node=self._initialization_node,
                                extras={"read_data": False},
                            )
                            files["input"]["data"] = copy.deepcopy(
                                initialization_header
                            )
                            files["input"]["data"].append(
                                "read_restart       %s"
                                % (os.path.join(files["restart"]["filename"]))
                            )
                            self._trajectory = []
                            break

                        iteration = iteration + 1

            node = node.next()

        if len(history_nodes) == 0:

            node_initialization = self.subflowchart.get_node("1").next()

            if node_initialization is None:
                raise TypeError(
                    "The initial node in a LAMMPS workflow should be an ",
                    "initialization node",
                )

            if isinstance(node_initialization, lammps_step.Initialization) is False:
                raise TypeError(
                    "The initial node in a LAMMPS workflow should be an ",
                    "initialization node",
                )

            # base = "lammps_substep_%s_iter_%d" % (node_initialization._id[1], 0)
            base = "lammps"

            restart = base + ".restart.*"
            dump = base + ".dump.*"
            input_file = base + ".dat"
            new_input_data = []
            new_input_data.append("run          0")
            new_input_data.append(f"write_restart          {restart}")

            new_input_data.append(
                f"write_dump all custom  {dump} id " "xu yu zu modify flush yes sort id"
            )

            files["input"]["filename"] = input_file
            files["input"]["data"] += new_input_data
            files["input"]["data"] = "\n".join(files["input"]["data"])

            self.logger.debug(
                files["input"]["filename"] + ":\n" + files["input"]["data"]
            )

            files = self._execute_single_sim(files, np=np)

        if len(history_nodes) > 0:

            files = self._prepare_input(
                files,
                nodes=history_nodes,
                read_restart=False,
                write_restart=True,
                extras=extras,
            )

            files = self._execute_single_sim(files, np=np)

            self.analyze(nodes=history_nodes)

            self._trajectory = []

        self.read_dump(os.path.join(self.directory, files["dump"]["filename"]))

        printer.normal("")

        return next_node

    def _execute_single_sim(self, files, np=1, return_files=None):
        """
        Step #1: Dump input file
        Step #2: Execute input file
        Step #3: Dump stderr
        Step #4: Dump output files
        """
        tmpdict = {}
        for k, v in files.items():

            if v["filename"] is None or v["data"] is None:
                continue

            filename = os.path.join(self.directory, v["filename"])
            mode = "w" if type(v["data"]) is str else "wb"
            with open(filename, mode=mode) as fd:
                fd.write(v["data"])

            tmpdict[v["filename"]] = v["data"]

        return_files = [
            "summary_*.txt",
            "trajectory_*.seamm_trj",
            "*.restart.*",
            "*.dump.*",
            "*.log",
            "log.cite",
        ]  # yapf: disable

        local = seamm.ExecLocal()

        # Find the executables and if they exist.
        lammps_path = Path(self.options["lammps_path"]).expanduser().resolve()
        lmp_serial = lammps_path / self.options["lammps_serial"]
        lmp_serial = lmp_serial.expanduser().resolve()
        if not lmp_serial.exists():
            lmp_serial = None

        lmp_mpi = lammps_path / self.options["lammps_mpi"]
        lmp_mpi = lmp_mpi.expanduser().resolve()
        if lmp_mpi.exists():
            mpiexec = lammps_path / self.options["mpiexec"]
            mpiexec = mpiexec.expanduser().resolve()
            if not mpiexec.exists():
                # See if it is in the path
                mpiexec = shutil.which(self.options["mpiexec"])
                if mpiexec is None:
                    lmp_mpi = None
                else:
                    mpiexec = Path(mpiexec).expanduser().resolve()
        else:
            lmp_mpi = None

        # Use the parallel executable if the serial does not exist, and vice
        # versa
        if lmp_mpi is not None and (np > 1 or lmp_serial is None):
            cmd = [str(mpiexec), "-np", str(np), str(lmp_mpi)]
        else:
            cmd = [str(lmp_serial)]
        cmd.extend(["-in", files["input"]["filename"]])

        result = local.run(cmd=cmd, files=tmpdict, return_files=return_files)

        if result is None:
            self.logger.error("There was an error running LAMMPS")
            return None

        self.logger.debug("\n" + pprint.pformat(result))

        self.logger.debug("stdout:\n" + result["stdout"])

        f = os.path.join(self.directory, "stdout.txt")
        with open(f, mode="w") as fd:
            fd.write(result["stdout"])

        # Add the citations, getting the version from stdout and any citations
        if "log.cite" in result:
            self._add_lammps_citations(
                result["stdout"], cite=result["log.cite"]["data"]
            )
        else:
            self._add_lammps_citations(result["stdout"])

        if result["stderr"] != "":
            self.logger.warning("stderr:\n" + result["stderr"])
            f = os.path.join(self.directory, "sstderr.txt")
            with open(f, mode="w") as fd:
                fd.write(result["stderr"])

        for filename in result["files"]:
            f = os.path.join(self.directory, filename)
            mode = "wb" if type(result[filename]["data"]) is bytes else "w"
            with open(f, mode=mode) as fd:
                if result[filename]["data"] is not None:
                    fd.write(result[filename]["data"])
                else:
                    fd.write(result[filename]["exception"])

        base = os.path.basename(files["input"]["filename"][0:-4])
        restart_file = base + ".restart.*"
        dump_file = base + ".dump.*"
        filename = os.path.join(self.directory, restart_file)
        restart_filenames = glob.glob(filename)

        # Probably the step didn't run
        if len(restart_filenames) == 0:
            raise FileNotFoundError(
                "Lammps_step: could not find any file with the pattern %s" % (filename)
            )

        run_lengths = []

        for f in restart_filenames:
            try:
                pre, ext = os.path.splitext(f)
                ext = int(ext.strip("."))
            except ValueError:
                raise Exception("Lammps_step: could not extract run length from %s" % f)
            run_lengths.append(ext)

            last_snapshot = str(max(run_lengths))

        restart_file = restart_file.replace("*", last_snapshot)
        dump_file = dump_file.replace("*", last_snapshot)
        files["restart"] = {}
        files["restart"]["filename"] = restart_file
        files["dump"] = {}
        files["dump"]["filename"] = dump_file
        files["dump"]["data"] = None

        filename = os.path.join(self.directory, restart_file)
        with open(filename, mode="rb") as fd:
            files["restart"]["data"] = fd.read()

        files["input"] = {}
        files["input"]["filename"] = None
        initialization_header, eex = self._get_node_input(
            node=self._initialization_node, extras={"read_data": False}
        )
        files["input"]["data"] = copy.deepcopy(initialization_header)

        return files

    def _prepare_input(
        self,
        files,
        nodes=None,
        iteration=0,
        read_restart=False,
        write_restart=False,
        extras=None,
    ):

        if isinstance(nodes, list) is False:
            node_ids = [nodes._id[1]]
            new_input_data = self._get_node_input(node=nodes, extras=extras)

        else:
            node_ids = []
            new_input_data = []
            for n in nodes:
                node_ids.append(n._id[1])
                new_input_data += self._get_node_input(node=n, extras=extras)

        # base = "lammps_substep_%s_iter_%d" % ("_".join(node_ids), iteration)
        base = "lammps"

        input_file = base + ".dat"
        restart = base + ".restart.*"
        dump = base + ".dump.*"

        if read_restart:
            new_input_data.insert(
                0, "read_restart          %s" % (files["restart"]["filename"])
            )

        if write_restart:
            new_input_data.append(f"write_restart          {restart}")

        new_input_data.append(
            f"write_dump all custom  {dump} id " "xu yu zu modify flush yes sort id"
        )

        files["input"]["data"] += new_input_data

        files["input"]["filename"] = input_file
        files["input"]["data"] = "\n".join(files["input"]["data"])
        self.logger.debug(files["input"]["filename"] + ":\n" + files["input"]["data"])

        return files

    def _get_node_input(self, node=None, extras=None):

        try:
            ret = node.get_input(extras=extras)
        except Exception as e:
            print("Error running LAMMPS flowchart: {} in {}".format(str(e), str(node)))
            self.logger.critical(
                "Error running LAMMPS flowchart: {} in {}".format(str(e), str(node))
            )
            raise
        except:  # noqa: E722
            print(
                "Unexpected error running LAMMPS flowchart: {} in {}".format(
                    sys.exc_info()[0], str(node)
                )
            )
            self.logger.critical(
                "Unexpected error running LAMMPS flowchart: {} in {}".format(
                    sys.exc_info()[0], str(node)
                )
            )
            raise
        return ret

    def structure_data(self, eex, triclinic=False):
        """Create the LAMMPS structure file from the energy expression"""
        lines = []

        # The terms in the forcefield, and for each a list of forms
        terms = eex["terms"]

        lines.append("Structure file for LAMMPS generated by a MolSSI flowchart")
        lines.append("{:10d} atoms".format(eex["n_atoms"]))
        lines.append("{:10d} atom types".format(eex["n_atom_types"]))
        if "n_bonds" in eex and eex["n_bonds"] > 0:
            lines.append("{:10d} bonds".format(eex["n_bonds"]))
            lines.append("{:10d} bond types".format(eex["n_bond_types"]))
        if "n_angles" in eex and eex["n_angles"] > 0:
            lines.append("{:10d} angles".format(eex["n_angles"]))
            lines.append("{:10d} angle types".format(eex["n_angle_types"]))
        if "n_torsions" in eex and eex["n_torsions"] > 0:
            lines.append("{:10d} dihedrals".format(eex["n_torsions"]))
            lines.append("{:10d} dihedral types".format(eex["n_torsion_types"]))
        if "n_oops" in eex and eex["n_oops"] > 0:
            lines.append("{:10d} impropers".format(eex["n_oops"]))
            lines.append("{:10d} improper types".format(eex["n_oop_types"]))

        # Find the box limits
        periodicity = eex["periodicity"]
        if periodicity == 3:
            a, b, c, alpha, beta, gamma = eex["cell"]
            lx, ly, lz, xy, xz, yz = LAMMPS.cell_to_box(a, b, c, alpha, beta, gamma)

            lines.append("{} {} xlo xhi".format(0.0, lx))
            lines.append("{} {} ylo yhi".format(0.0, ly))
            lines.append("{} {} zlo zhi".format(0.0, lz))

            xy = xy if abs(xy) > 1.0e-06 else 0.0
            xz = xz if abs(xy) > 1.0e-06 else 0.0
            yz = yz if abs(xy) > 1.0e-06 else 0.0

            if triclinic or xy > 0.0 or xz > 0.0 or yz > 0.0:
                lines.append("{} {} {} xy xz yz".format(xy, xz, yz))
        else:
            x, y, z, index = eex["atoms"][0]
            xlo = xhi = x
            ylo = yhi = y
            zlo = zhi = z
            for x, y, z, index in eex["atoms"]:
                xlo = x if x < xlo else xlo
                xhi = x if x > xhi else xhi
                ylo = y if y < ylo else ylo
                yhi = y if y > yhi else yhi
                zlo = z if z < zlo else zlo
                zhi = z if z > zhi else zhi

            # Some extra space....
            xlo -= 10.0
            xhi += 10.0
            ylo -= 10.0
            yhi += 10.0
            zlo -= 10.0
            zhi += 10.0

            lines.append("{} {} xlo xhi".format(xlo, xhi))
            lines.append("{} {} ylo yhi".format(ylo, yhi))
            lines.append("{} {} zlo zhi".format(zlo, zhi))

        # the atoms and their masses, etc.
        lines.append("")
        lines.append("Atoms")
        lines.append("")

        if "charges" in eex:
            for i, xyz_index, q in zip(
                range(1, eex["n_atoms"] + 1), eex["atoms"], eex["charges"]
            ):
                x, y, z, index = xyz_index
                # The '1' is molecule ID ... should correct at some point!
                lines.append(
                    f"{i:6d}      1 {index:6d} {q:6.3f} {x:12.7f} {y:12.7f} "
                    f"{z:12.7f}"
                )
        else:
            for i, xyz_index in enumerate(eex["atoms"]):
                x, y, z, index = xyz_index
                lines.append(f"{i+1:6d} {index:6d} {x:12.7f} {y:12.7f} {z:12.7f}")
        lines.append("")

        lines.append("Masses")
        lines.append("")
        self._data["masses"] = []
        for i, parameters in zip(range(1, eex["n_atom_types"] + 1), eex["masses"]):
            mass, itype = parameters
            lines.append("{:6d} {} # {}".format(i, mass, itype))
            self._data["masses"].append(float(mass))

        # nonbonds
        if "nonbond parameters" in eex:
            lines.append("")
            lines.append("Pair Coeffs")
            lines.append("")
            for i, parameters in zip(
                range(1, eex["n_atom_types"] + 1), eex["nonbond parameters"]
            ):
                form, values, types, parameters_type, real_types = parameters
                if form == "nonbond(9-6)":
                    lines.append(
                        "{:6d} {} {} # {} --> {}".format(
                            i, values["eps"], values["rmin"], types[0], real_types[0]
                        )
                    )
                else:
                    lines.append(
                        "{:6d} {} {} # {} --> {}".format(
                            i, values["eps"], values["sigma"], types[0], real_types[0]
                        )
                    )

        # bonds
        if "n_bonds" in eex and eex["n_bonds"] > 0:
            lines.append("")
            lines.append("Bonds")
            lines.append("")
            for counter, tmp in zip(range(1, eex["n_bonds"] + 1), eex["bonds"]):
                i, j, index = tmp
                lines.append("{:6d} {:6d} {:6d} {:6d}".format(counter, index, i, j))

            lines.append("")
            lines.append("Bond Coeffs")
            lines.append("")

            use_hybrid = len(terms["bond"]) > 1

            for counter, parameters in zip(
                range(1, eex["n_bond_types"] + 1), eex["bond parameters"]
            ):
                form, values, types, parameters_type, real_types = parameters
                if form == "quadratic_bond":
                    function = "harmonic" if use_hybrid else ""
                    line = f"{counter:6d} {function} " f"{values['K2']} {values['R0']}"
                elif form == "quartic_bond":
                    function = "class2" if use_hybrid else ""
                    line = (
                        f"{counter:6d} {function} {values['R0']} "
                        f"{values['K2']} {values['K3']} {values['K4']}"
                    )
                line += (
                    f" # {types[0]}-{types[1]}" f" --> {real_types[0]}_{real_types[1]}"
                )
                lines.append(line)

        # angles
        if "n_angles" in eex and eex["n_angles"] > 0:
            lines.append("")
            lines.append("Angles")
            lines.append("")
            for counter, tmp in zip(range(1, eex["n_angles"] + 1), eex["angles"]):
                i, j, k, index = tmp
                lines.append(
                    "{:6d} {:6d} {:6d} {:6d} {:6d}".format(counter, index, i, j, k)
                )

            lines.append("")
            lines.append("Angle Coeffs")
            lines.append("")

            use_hybrid = len(terms["angle"]) > 1
            quartic_function = "class2" if "n_bond-bond_types" in eex else "quartic"

            for counter, parameters in zip(
                range(1, eex["n_angle_types"] + 1), eex["angle parameters"]
            ):
                form, values, types, parameters_type, real_types = parameters
                if form == "quadratic_angle":
                    function = "harmonic" if use_hybrid else ""
                    line = (
                        f"{counter:6d} {function} " f"{values['K2']} {values['Theta0']}"
                    )
                elif form == "quartic_angle":
                    function = quartic_function if use_hybrid else ""
                    line = (
                        f"{counter:6d} {function} {values['Theta0']} "
                        f"{values['K2']} {values['K3']} {values['K4']}"
                    )
                line += (
                    f" # {types[0]} {types[1]} {types[2]}"
                    f" --> {real_types[0]} {real_types[1]} {real_types[2]}"
                )
                lines.append(line)

            # bond-bond coefficients, which must match angles in order & number
            if "n_bond-bond_types" in eex:
                lines.append("")
                lines.append("BondBond Coeffs")
                lines.append("")
                for counter, parameters, angles in zip(
                    range(1, eex["n_bond-bond_types"] + 1),
                    eex["bond-bond parameters"],
                    eex["angle parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    angle_form = angles[0]
                    if angle_form == "quartic_angle":
                        lines.append(
                            "{:6d} class2 {} {} {}".format(
                                counter, values["K"], values["R10"], values["R20"]
                            )
                            + " # {}-{}-{} --> {}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                            )
                        )
                    else:
                        lines.append(
                            "{:6d} skip".format(counter)
                            + " # {}-{}-{} --> {}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                            )
                        )

                # bond-angles coefficients, which must match angles in order &
                # number
                lines.append("")
                lines.append("BondAngle Coeffs")
                lines.append("")
                for counter, parameters, angles in zip(
                    range(1, eex["n_bond-angle_types"] + 1),
                    eex["bond-angle parameters"],
                    eex["angle parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    angle_form = angles[0]
                    if angle_form == "quartic_angle":
                        lines.append(
                            "{:6d} class2 {} {} {} {}".format(
                                counter,
                                values["K12"],
                                values["K23"],
                                values["R10"],
                                values["R20"],
                            )
                            + " # {}-{}-{} --> {}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                            )
                        )
                    else:
                        lines.append(
                            "{:6d} skip".format(counter)
                            + " # {}-{}-{} --> {}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                            )
                        )

        # torsions
        if "n_torsions" in eex and eex["n_torsions"] > 0:
            lines.append("")
            lines.append("Dihedrals")
            lines.append("")
            for counter, tmp in zip(range(1, eex["n_torsions"] + 1), eex["torsions"]):
                i, j, k, l, index = tmp
                lines.append(
                    "{:6d} {:6d} {:6d} {:6d} {:6d} {:6d}".format(
                        counter, index, i, j, k, l
                    )
                )

            lines.append("")
            lines.append("Dihedral Coeffs")
            lines.append("")

            use_hybrid = len(terms["torsion"]) > 1

            for counter, parameters in zip(
                range(1, eex["n_torsion_types"] + 1), eex["torsion parameters"]
            ):
                form, values, types, parameters_type, real_types = parameters
                if form == "torsion_1":
                    KPhi = values["KPhi"]
                    n = values["n"]
                    Phi0 = values["Phi0"]

                    # Discover form is
                    #  KPhi * [1 + cos(n*Phi - Phi0)]
                    #  with trans = 180
                    #
                    #  For ethane, Phi0 = 0 so at Phi=180 E is min. Correct

                    # Lammps for is
                    #  KPhi * [1 + d*cos(n*Phi)]
                    #  with trans = 180
                    #
                    # Again for ethane, d=+1 and at Phi=180, E is min.
                    #
                    # Phi0 = 0   ==> d=+1
                    # Phi0 = 180 ==> d=-1

                    if float(Phi0) == 0.0:
                        d = "-1"
                    elif float(Phi0) == 180.0:
                        d = "+1"
                    else:
                        raise RuntimeError(
                            "LAMMPS cannot handle Phi0 = {}".format(Phi0)
                        )
                    function = "harmonic" if use_hybrid else ""
                    line = f"{counter:6d} {function} {KPhi} {d} {n}"
                elif form == "torsion_3":
                    function = "class2" if use_hybrid else ""
                    line = (
                        f"{counter:6d} {function} "
                        f"{values['V1']} {values['Phi0_1']} "
                        f"{values['V2']} {values['Phi0_2']} "
                        f"{values['V3']} {values['Phi0_3']} "
                    )
                line += (
                    f" # {types[0]}-{types[1]}-{types[2]}-{types[3]} "
                    f"--> {real_types[0]}-{real_types[1]}-"
                    f"{real_types[2]}-{real_types[3]}"
                )
                lines.append(line)

            # middle bond-torsion_3 coefficients, which must match torsions
            # in order & number
            if "n_middle_bond-torsion_3_types" in eex:
                lines.append("")
                lines.append("MiddleBondTorsion Coeffs")
                lines.append("")
                for counter, parameters, torsions in zip(
                    range(1, eex["n_middle_bond-torsion_3_types"] + 1),
                    eex["middle_bond-torsion_3 parameters"],
                    eex["torsion parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    torsion_form = torsions[0]
                    if torsion_form == "torsion_3":
                        lines.append(
                            "{:6d} class2 {} {} {} {}".format(
                                counter,
                                values["V1"],
                                values["V2"],
                                values["V3"],
                                values["R0"],
                            )
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )
                    else:
                        lines.append(
                            "{:6d} skip".format(counter)
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )

                # end bond-torsion_3 coefficients, which must match torsions
                # in order & number
                lines.append("")
                lines.append("EndBondTorsion Coeffs")
                lines.append("")
                for counter, parameters, torsions in zip(
                    range(1, eex["n_end_bond-torsion_3_types"] + 1),
                    eex["end_bond-torsion_3 parameters"],
                    eex["torsion parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    torsion_form = torsions[0]
                    if torsion_form == "torsion_3":
                        lines.append(
                            "{:6d} class2 {} {} {} {} {} {} {} {}".format(
                                counter,
                                values["V1_L"],
                                values["V2_L"],
                                values["V3_L"],
                                values["V1_R"],
                                values["V2_R"],
                                values["V3_R"],
                                values["R0_L"],
                                values["R0_R"],
                            )
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )
                    else:
                        lines.append(
                            "{:6d} skip".format(counter)
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )

                # angle-torsion_3 coefficients, which must match torsions
                # in order & number
                lines.append("")
                lines.append("AngleTorsion Coeffs")
                lines.append("")
                for counter, parameters, torsions in zip(
                    range(1, eex["n_angle-torsion_3_types"] + 1),
                    eex["angle-torsion_3 parameters"],
                    eex["torsion parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    torsion_form = torsions[0]
                    if torsion_form == "torsion_3":
                        lines.append(
                            "{:6d} class2 {} {} {} {} {} {} {} {}".format(
                                counter,
                                values["V1_L"],
                                values["V2_L"],
                                values["V3_L"],
                                values["V1_R"],
                                values["V2_R"],
                                values["V3_R"],
                                values["Theta0_L"],
                                values["Theta0_R"],
                            )
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )
                    else:
                        lines.append(
                            "{:6d} skip".format(counter)
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )

                # angle-angle-torsion_1 coefficients, which must match torsions
                # in order & number
                lines.append("")
                lines.append("AngleAngleTorsion Coeffs")
                lines.append("")
                for counter, parameters, torsions in zip(
                    range(1, eex["n_angle-angle-torsion_1_types"] + 1),
                    eex["angle-angle-torsion_1 parameters"],
                    eex["torsion parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    torsion_form = torsions[0]
                    if torsion_form == "torsion_3":
                        lines.append(
                            "{:6d} class2 {} {} {}".format(
                                counter,
                                values["K"],
                                values["Theta0_L"],
                                values["Theta0_R"],
                            )
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )
                    else:
                        lines.append(
                            "{:6d} skip".format(counter)
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )

                # bond-bond_1_3 coefficients, which must match torsions
                # in order & number
                lines.append("")
                lines.append("BondBond13 Coeffs")
                lines.append("")
                for counter, parameters, torsions in zip(
                    range(1, eex["n_bond-bond_1_3_types"] + 1),
                    eex["bond-bond_1_3 parameters"],
                    eex["torsion parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    torsion_form = torsions[0]
                    if torsion_form == "torsion_3":
                        lines.append(
                            "{:6d} class2 {} {} {}".format(
                                counter, values["K"], values["R10"], values["R30"]
                            )
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )
                    else:
                        lines.append(
                            "{:6d} skip".format(counter)
                            + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                                types[0],
                                types[1],
                                types[2],
                                types[3],
                                real_types[0],
                                real_types[1],
                                real_types[2],
                                real_types[3],
                            )
                        )

        # out-of-planes
        if "n_oops" in eex and eex["n_oops"] > 0:
            lines.append("")
            lines.append("Impropers")
            lines.append("")
            for counter, tmp in zip(range(1, eex["n_oops"] + 1), eex["oops"]):
                i, j, k, l, index = tmp
                lines.append(
                    "{:6d} {:6d} {:6d} {:6d} {:6d} {:6d}".format(
                        counter, index, i, j, k, l
                    )
                )

            lines.append("")
            lines.append("Improper Coeffs")
            lines.append("")
            for counter, parameters in zip(
                range(1, eex["n_oop_types"] + 1), eex["oop parameters"]
            ):
                form, values, types, parameters_type, real_types = parameters
                lines.append(
                    "{:6d} {} {}".format(counter, values["K"], values["Chi0"])
                    + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                        types[0],
                        types[1],
                        types[2],
                        types[3],
                        real_types[0],
                        real_types[1],
                        real_types[2],
                        real_types[3],
                    )
                )

            # angle-angle
            if "n_angle-angle_types" in eex:
                lines.append("")
                lines.append("AngleAngle Coeffs")
                lines.append("")
                for counter, parameters in zip(
                    range(1, eex["n_angle-angle_types"] + 1),
                    eex["angle-angle parameters"],
                ):
                    form, values, types, parameters_type, real_types = parameters
                    lines.append(
                        "{:6d} {} {} {} {} {} {}".format(
                            counter,
                            values["K1"],
                            values["K2"],
                            values["K3"],
                            values["Theta10"],
                            values["Theta20"],
                            values["Theta30"],
                        )
                        + " # {}-{}-{}-{} --> {}-{}-{}-{}".format(
                            types[0],
                            types[1],
                            types[2],
                            types[3],
                            real_types[0],
                            real_types[1],
                            real_types[2],
                            real_types[3],
                        )
                    )

        lines.append("")
        return lines

    def analyze(self, indent="", nodes=None, **kwargs):
        """Analyze the output of the calculation"""
        if isinstance(nodes, list) is False:
            nodes = [nodes]

        ret = {node._id[1]: None for node in nodes}

        for node in nodes:

            for value in node.description:
                printer.important(value)
                printer.important(" ")

            id_str = "_".join(str(e) for e in node._id)

            filenames = glob.glob(
                os.path.join(self.directory, "*trajectory*" + id_str + "*.seamm_trj")
            )

            if len(filenames) > 1:
                filenames.sort(
                    key=lambda x: int(re.search(r"iter_\d+", x).group().split("_")[1])
                )

            if len(filenames) > 0:
                filename = filenames[-1]

            P = node.parameters.current_values_to_dict(
                context=seamm.flowchart_variables._data
            )

            node_data = None

            if "run_control" in P:

                if P["run_control"] == "For a fixed length of simulated time.":
                    control_properties = lambda x: x not in ["tstep"]  # noqa: E731
                    # Reset the trajectory data so doesn't carry over
                    self._trajectory = []
                else:

                    if len(P["control_properties"]) == 0:
                        raise KeyError(
                            "No physical property selected for automatic",
                            "equilibration detection",
                        )

                    control_properties = [prp[0] for prp in P["control_properties"]]

                node_data = self.analyze_trajectory(
                    filename, control_properties=control_properties
                )
                # Get just the values from the node data
                values = {k: v["mean"] for k, v in node_data.items()}
                node.analyze(data=values)

            ret[node._id[1]] = node_data

        return ret

    def analyze_trajectory(self, filename, sampling_rate=20, control_properties=None):
        """Read a trajectory file and do the statistical analysis"""

        write_html = "html" in self.options and self.options["html"]

        rootname = os.path.splitext(filename)[0]

        results = {}

        if isinstance(control_properties, list) and "t" not in control_properties:
            control_properties.append("t")

        # Process the trajectory data

        with open(filename, "r") as fd:
            file_data = pandas.read_csv(
                fd,
                sep=" ",
                header=0,
                comment="!",
                usecols=control_properties,
                index_col="t",
            )
            self._trajectory.append(file_data.iloc[:-1])

        dt_fs = file_data.index[1] - file_data.index[0]
        dt = dt_fs
        data = pandas.concat(self._trajectory)
        data = data.reset_index(drop=True)
        data.index *= dt

        self.logger.debug("Columns: {}".format(data.columns))
        self.logger.debug("  Types:\n{}".format(data.dtypes))

        printer.normal("       Analysis of " + os.path.basename(filename) + "\n")

        printer.normal(
            "                                             Std Error  "
            "Time to\n"
            "               Property           Value       of mean   "
            "convergence     tau    inefficiency\n"
            "          --------------------   ---------  ---------   "
            "-----------  --------  ------------"
        )

        # Work out the time step, rather than give the whole vector
        t = data.index
        t_units = "fs"
        len_trj = (len(t) - 1) * dt_fs
        divisor = 1
        if len_trj >= 4000000000:
            t_units = "ms"
            divisor = 1000000000
        elif len_trj >= 4000000:
            t_units = "ns"
            divisor = 1000000
        elif len_trj >= 4000:
            t_units = "ps"
            divisor = 1000
        dt /= divisor
        t_max = float((len(t) - 1) * dt)

        for column in data.columns:
            have_warning = False
            have_acf_warning = False
            y = data[column]

            self.logger.info("Analyzing {}, nsamples = {}".format(column, len(y)))

            # compute indices of uncorrelated timeseries using pymbar
            yy = y.to_numpy()
            conv, inefficiency, Neff_max = timeseries.detect_equilibration(yy)

            self.logger.info(
                "  converged in {} steps, inefficiency = {}, Neff_max = {}".format(
                    conv, inefficiency, Neff_max
                )
            )

            if numpy.isnan(inefficiency) or numpy.isnan(Neff_max):
                # Apparently didn't converge!
                printer.normal(
                    __(
                        "{column:>23s} did not converge to a stationary state",
                        column=column,
                        indent=7 * " ",
                        wrap=False,
                        dedent=False,
                    )
                )

                have_acf = False
                is_converged = False
            else:
                is_converged = True
                tau = dt_fs * (inefficiency - 1) / 2
                if tau < dt_fs / 2:
                    tau = dt_fs / 2
                t0 = conv * dt_fs
                y_t_equil = yy[conv:]
                indices = timeseries.subsample_correlated_data(
                    y_t_equil, g=inefficiency
                )

                if len(indices) == 0:
                    print("Problem with column " + column)
                    print("yy")
                    print(yy)
                    print("y_t_equil")
                    print(y_t_equil)
                    print("indices")
                    print(indices)
                    continue

                y_n = y_t_equil[indices]
                n_samples = len(y_n)
                mean = y_n.mean()
                std = y_n.std()
                sem = std / sqrt(n_samples)

                # Get the autocorrelation function
                if len(y_t_equil) < 8:
                    have_acf = False
                    have_acf_warning = True
                    acf_warning = "^"
                else:
                    have_acf = True
                    acf_warning = " "
                    nlags = 4 * int(round(inefficiency + 0.5))
                    if nlags > int(len(y_t_equil) / 2):
                        nlags = int(len(y_t_equil) / 2)
                    acf, confidence = stattools.acf(
                        y_t_equil,
                        nlags=nlags,
                        alpha=0.05,
                        fft=nlags > 16,
                        adjusted=False,
                    )

                results[column] = {}
                results[column]["mean"] = mean
                results[column]["sem"] = sem
                results[column]["n_sample"] = n_samples
                results[column]["short_production"] = have_acf_warning

                # Work out units on convergence time
                conv_units = "fs"
                t_conv = t0
                if t0 >= 1000000000:
                    conv_units = "ms"
                    t_conv = t0 / 1000000000
                elif t0 >= 1000000:
                    conv_units = "ns"
                    t_conv = t0 / 1000000
                elif t0 >= 1000:
                    conv_units = "ps"
                    t_conv = t0 / 1000

                # Work out units on autocorrelation time
                tau_units = "fs"
                t_tau = tau
                if tau >= 1000000000:
                    tau_units = "ms"
                    t_tau = tau / 1000000000
                elif tau >= 1000000:
                    tau_units = "ns"
                    t_tau = tau / 1000000
                elif tau >= 1000:
                    tau_units = "ps"
                    t_tau = tau / 1000

                if n_samples < 100:
                    have_warning = True
                    warn = "*"
                else:
                    warn = " "

                results[column]["few_neff"] = have_warning

                printer.normal(
                    __(
                        "{column:>23s} = {value:9.3f} ± {stderr:7.3f}{warn}"
                        " {t0:8.2f} {conv_units} {tau:8.1f} {tau_units}{acf} "
                        "{inefficiency:9.1f}",
                        column=column,
                        value=mean,
                        stderr=sem,
                        warn=warn,
                        t0=t_conv,
                        conv_units=conv_units,
                        tau=t_tau,
                        tau_units=tau_units,
                        acf=acf_warning,
                        inefficiency=inefficiency,
                        indent=7 * " ",
                        wrap=False,
                        dedent=False,
                    )
                )

            # Create graphs of the property
            figure = self.create_figure(
                module_path=(self.__module__.split(".")[0], "seamm"),
                template="line.graph_template",
                title=LAMMPS.display_title[column],
            )

            # The autocorrelation function
            if have_acf:
                plot_acf = figure.add_plot("acf")

                dt_acf = float(dt_fs)
                t_acf_units = "fs"
                len_acf = (len(acf) - 1) * dt_fs
                if len_acf >= 2000000000:
                    t_acf_units = "ms"
                    dt_acf /= 1000000000
                elif len_acf >= 2000000:
                    t_acf_units = "ns"
                    dt_acf /= 1000000
                elif len_acf >= 2000:
                    t_acf_units = "ps"
                    dt_acf /= 1000

                x_acf_axis = plot_acf.add_axis(
                    "x", label="Time ({})".format(t_acf_units)
                )
                y_acf_axis = plot_acf.add_axis("y", label="acf", anchor=x_acf_axis)
                x_acf_axis.anchor = y_acf_axis

                # Put the fit to the autocorrelation time in first so the
                # subsequent trajectory trace sits in top
                ts = 0.0
                fit = [1.0]
                for step in range(len(acf) - 1):
                    ts += dt_fs
                    fit.append(exp(-ts / tau))

                plot_acf.add_trace(
                    x_axis=x_acf_axis,
                    y_axis=y_acf_axis,
                    name="fit",
                    x0=0,
                    dx=dt_acf,
                    xlabel="t",
                    xunits=t_acf_units,
                    y=fit,
                    ylabel="fit",
                    yunits="",
                    color="gray",
                )

                # the partly transparent error band
                yplus = []
                yminus = []
                t_acf = []
                tmp = 0.0
                for lower, upper in confidence:
                    t_acf.append(tmp)
                    yplus.append(upper)
                    yminus.append(lower)
                    tmp += dt_acf

                plot_acf.add_trace(
                    x_axis=x_acf_axis,
                    y_axis=y_acf_axis,
                    name="stderr",
                    x=t_acf + t_acf[::-1],
                    xlabel="t",
                    xunits=t_acf_units,
                    y=yplus + yminus[::-1],
                    ylabel="stderr",
                    yunits=LAMMPS.display_units[column],
                    showlegend="false",
                    color="rgba(211,211,211,0.5)",
                    fill="toself",
                )

                # And the acf plot last
                plot_acf.add_trace(
                    x_axis=x_acf_axis,
                    y_axis=y_acf_axis,
                    name="acf",
                    x0=0,
                    dx=dt_acf,
                    xlabel="t",
                    xunits=t_acf_units,
                    y=list(acf),
                    ylabel="acf",
                    yunits="",
                    color="red",
                )

            # The property data over the trajectory
            y = list(data[column])

            plot = figure.add_plot("trj")

            ylabel = LAMMPS.display_title[column]
            if LAMMPS.display_units[column] != "":
                ylabel += " ({})".format(LAMMPS.display_units[column])

            x_axis = plot.add_axis("x", label="Time ({})".format(t_units))
            y_axis = plot.add_axis("y", label=ylabel, anchor=x_axis)
            x_axis.anchor = y_axis

            # Add the trajectory, error band and median value in that order so
            # stack in a nice order.

            # Add the trajectory
            plot.add_trace(
                x_axis=x_axis,
                y_axis=y_axis,
                name=column,
                x0=0,
                dx=dt,
                xlabel="t",
                xunits=t_units,
                y=list(y),
                ylabel=column,
                yunits=LAMMPS.display_units[column],
                color="#4dbd74",
            )

            if is_converged:
                # the partly transparent error band
                t_min = t0 / divisor
                plot.add_trace(
                    x_axis=x_axis,
                    y_axis=y_axis,
                    name="sem",
                    x=[t_min, t_max, t_max, t_min],
                    xlabel="t",
                    xunits=t_units,
                    y=[mean + sem, mean + sem, mean - sem, mean - sem],
                    ylabel="sem",
                    yunits=LAMMPS.display_units[column],
                    showlegend="false",
                    color="rgba(211,211,211,0.5)",
                    fill="toself",
                )

                # and finally the median value so it is on top
                plot.add_trace(
                    x_axis=x_axis,
                    y_axis=y_axis,
                    name="average",
                    x=[t_min, t_max],
                    xlabel="t",
                    xunits=t_units,
                    y=[mean, mean],
                    ylabel="average",
                    yunits=LAMMPS.display_units[column],
                    color="black",
                )

            if have_acf:
                figure.grid_plots("trj - acf")
            else:
                figure.grid_plots("trj")
            figure.dump("{}_{}.graph".format(rootname, column))

            if write_html:
                figure.template = "line.html_template"
                figure.dump("{}_{}.html".format(rootname, column))

        if have_warning or have_acf_warning:
            printer.normal("\n")
        if have_warning:
            printer.normal(
                __(
                    "          * this property has less than 100 independent "
                    "samples, so may not be accurate.",
                    wrap=False,
                    dedent=False,
                )
            )

        if have_acf_warning:
            printer.normal(
                __(
                    "          ^ there are not enough samples after "
                    "equilibration to plot the ACF.",
                    wrap=False,
                    dedent=False,
                )
            )

        # Add citations for pymbar
        self.references.cite(
            raw=self._bibliography["mbar"],
            alias="pymbar-1",
            module="lammps_step",
            level=1,
            note="The main reference for pymbar.",
        )
        self.references.cite(
            raw=self._bibliography["histogram"],
            alias="pymbar-2",
            module="lammps_step",
            level=2,
            note="The second citation for pymbar",
        )
        self.references.cite(
            raw=self._bibliography["convergence"],
            alias="pymbar-3",
            module="lammps_step",
            level=2,
            note="The third citation for pymbar",
        )

        return results

    def shake_fix(self, P, eex):
        """Create the 'fix shake' line needed for handling waters and X-H.

        Parameters
        ----------
        P : dict
            The parameters for the initialization step as a dict.
        eex : dict
            The energy expression for this calculation

        Returns
        -------
        line : str
            The correct fix line for LAMMPS
        """

        bond_types = {}
        angle_types = {}

        # Water models
        if P["rigid_waters"]:
            # waters = seamm_util.water_models.Water.find_waters(data.structure)  # noqa: E501

            waters = []

            if len(waters) > 0:
                atoms = []
                for i, j, k in waters:
                    atoms.append(i)
                    atoms.append(j)
                    atoms.append(k)
                if "n_bonds" in eex and eex["n_bonds"] > 0:
                    for i, j, index in eex["bonds"]:
                        if i in atoms and j in atoms:
                            bond_types[index] = 1
                if "n_angles" in eex and eex["n_angles"] > 0:
                    for i, j, k, index in eex["angles"]:
                        if i in atoms and j in atoms and k in atoms:
                            angle_types[index] = 1

        # Fixing bond lengths of X-H bonds...
        if "n_bonds" in eex and eex["n_bonds"] > 0:
            fix_bonds = P["fix_XH_bond_lengths"]
            elements = eex["elements"]
            if fix_bonds == "CH":
                for i, j, index in eex["bonds"]:
                    if (elements[i] == "C" and elements[j] == "H") or (
                        elements[i] == "H" and elements[j] == "C"
                    ):
                        bond_types[index] = 1
            elif fix_bonds == "all":
                for i, j, index in eex["bonds"]:
                    if elements[i] == "H" or elements[j] == "H":
                        bond_types[index] = 1

        # And the result is ....
        if len(bond_types) > 0:
            result = "fix                 {} all rattle 0.001 20 1000 b "
            for bond_type in bond_types.keys():
                result += " " + str(bond_type)
            if len(angle_types) > 0:
                result += " a "
                for angle_type in angle_types.keys():
                    result += " " + str(angle_type)
        else:
            result = ""

        return result

    def read_dump(self, dumpfile):
        """Read the LAMMPS dumpfile and update the system.

        Parameters
        ----------
        dumpfile : str
            The filename (or path) to the dumpfile.
        """
        self.logger.info("Reading dump file '{}'".format(dumpfile))

        system_db = self.get_variable("_system_db")
        configuration = system_db.system.configuration
        periodicity = configuration.periodicity
        n_atoms = configuration.n_atoms

        section = ""
        section_lines = []
        xs = []
        ys = []
        zs = []
        with open(dumpfile, "r") as fd:
            lineno = 0
            for line in fd:
                line = line.strip()
                lineno += 1
                if lineno == 1:
                    if line[0:5] != "ITEM:":
                        raise RuntimeError(
                            "Error reading dump file '" + dumpfile + "': The "
                            "first line is incorrect! (" + line + ")"
                        )
                    section = line[6:].strip()
                    section_lines = []
                    self.logger.debug("   section = " + section)
                    continue

                if line[0:5] == "ITEM:":
                    # end a section
                    self.logger.debug("  processing section '{}'".format(section))
                    if "BOX BOUNDS" in section:
                        if len(section.split()) == 8:
                            xlo_bound, xhi_bound, xy = section_lines[0].split()
                            ylo_bound, yhi_bound, xz = section_lines[1].split()
                            zlo, zhi, yz = section_lines[2].split()

                            xlo_bound = float(xlo_bound)
                            xhi_bound = float(xhi_bound)
                            ylo_bound = float(ylo_bound)
                            yhi_bound = float(yhi_bound)
                            zlo = float(zlo)
                            zhi = float(zhi)
                            xy = float(xy)
                            xz = float(xz)
                            yz = float(yz)

                            xlo = xlo_bound - min(0.0, xy, xz, xy + xz)
                            xhi = xhi_bound - max(0.0, xy, xz, xy + xz)
                            ylo = ylo_bound - min(0.0, yz)
                            yhi = yhi_bound - max(0.0, yz)
                            cell = LAMMPS.box_to_cell(
                                xhi - xlo, yhi - ylo, zhi - zlo, xy, xz, yz
                            )
                        else:
                            xlo, xhi = section_lines[0].split()
                            ylo, yhi = section_lines[1].split()
                            zlo, zhi = section_lines[2].split()

                            xlo = float(xlo)
                            xhi = float(xhi)
                            ylo = float(ylo)
                            yhi = float(yhi)
                            zlo = float(zlo)
                            zhi = float(zhi)

                            cell = (xhi - xlo, yhi - ylo, zhi - zlo, 90, 90, 90)
                    elif section == "NUMBER OF ATOMS":
                        if int(section_lines[0]) != n_atoms:
                            raise RuntimeError(
                                "Number of atoms has changed! {} to {}".format(
                                    n_atoms, section_lines[0]
                                )
                            )
                    elif "ATOMS" in section:
                        for tmp in section_lines:
                            id, x, y, z = tmp.split()
                            xs.append(float(x))
                            ys.append(float(y))
                            zs.append(float(z))
                    section = line[6:].strip()
                    section_lines = []
                else:
                    section_lines.append(line)

        # Clean up the last section
        xyz = []
        if "ATOMS" in section:
            self.logger.debug("  processing section '{}'".format(section))
            self.logger.debug("  handling the atoms")
            for tmp in section_lines:
                id, x, y, z = tmp.split()
                xyz.append([float(x), float(y), float(z)])

        if periodicity == 3:
            configuration.cell.parameters = cell
        configuration.atoms.set_coordinates(xyz, fractionals=False)

    def _add_lammps_citations(self, text, cite=None):
        """Add the two main citations for LAMMPS, getting the version from stdout
        text.

        Parameters
        ----------
        text : str
            The standard output from LAMMPS

        Returns
        -------
        None
        """
        # Add the JCP paper
        self.references.cite(
            raw=self._bibliography["PLIMPTON19951"],
            alias="lammps-jcp",
            module="lammps_step",
            level=1,
            note="The principle LAMMPS citation.",
        )

        # And the citation to the LAMMPS code itself
        lines = text.splitlines()

        if len(lines) == 0:
            return

        line = lines[0]
        tmp = line.split(" ")
        if len(tmp) != 4:
            self.logger.info(f"Cannot get LAMMPS version: '{line}'")
            return

        month = tmp[2]
        year = tmp[3].rstrip(")")
        version = " ".join(tmp[1:])

        try:
            template = string.Template(self._bibliography["lammps"])

            citation = template.substitute(month=month, version=version, year=year)

            self.references.cite(
                raw=citation,
                alias="lammps-exe",
                module="lammps_step",
                level=1,
                note="The principle citation for the LAMMPS executable.",
            )

        except Exception as e:
            printer.important(f"Exception in citation {type(e)}: {e}")
            printer.important(traceback.format_exc())

        # If there is a log.cite file, process it
        if cite is not None:
            self.logger.debug("log.cite\n" + cite + "\n")
            bibliography = {}
            tmp = bibtexparser.loads(cite).entries_dict
            writer = bibtexparser.bwriter.BibTexWriter()
            for key, data in tmp.items():
                self.logger.info(f"      {key}")
                bibliography[key] = writer._entry_to_bibtex(data)
            self.logger.debug("Bibliography\n" + pprint.pformat(bibliography))

            for entry in bibliography:
                if entry.lower() in ("commment",):
                    continue
                self.references.cite(
                    raw=bibliography[entry],
                    alias=entry,
                    module="lammps_step",
                    level=1,
                    note="LAMMPS citations from log.cite.",
                )
