Skip to content

I/O: Reading and Writing File Formats

Readers turn files into explicit format products; writers consume Frames plus any format-owned side inputs. To add support for a new format, see Developer Guide: I/O Formats.

MolPy's I/O system has three layers: data (single-frame structures), trajectory (multi-frame sequences), and forcefield (parameter files). Each layer has its own reader/writer base class.

Data files

Most data readers return a Frame. LAMMPS data parsing returns a LammpsDataResult because its ForceField, header counts, and type-label inventory are format products, not frame fields. A Frame carries blocks, dict-like meta, and an optional box.

Reading

Every format has a factory function at mp.io.read_*:

The examples below share this setup:

import numpy as np
import molpy as mp

rng = np.random.default_rng(0)
xyz = rng.uniform(0.0, 20.0, size=(200, 3))
frame = mp.Frame()
frame["atoms"] = {
 # A writer asks for the columns its format has fields for: LAMMPS wants a
 # numeric `type_id` and `mol_id`, XSF wants `atomic_number`.
 "id": np.arange(1, 201, dtype=np.uint32),
 "mol_id": np.repeat(np.arange(1, 101, dtype=np.uint32), 2),
 "type_id": np.tile(np.array([1, 2], dtype=np.uint32), 100),
 "type": np.tile(np.array(["OW", "HW"]), 100),
 "element": np.tile(np.array(["O", "H"]), 100),
 "atomic_number": np.tile(np.array([8, 1], dtype=np.uint32), 100),
 "charge": np.tile([0.4, -0.4], 100),
 "x": xyz[:, 0],
 "y": xyz[:, 1],
 "z": xyz[:, 2],
}
frame.box = mp.Box.cubic(20.0)
# docs: skip — reads offline structure files; I/O unit-tested with fixtures
import molpy as mp

frame = mp.io.read_pdb("molecule.pdb")
frame = mp.io.read_gro("system.gro")
frame = mp.io.read_mol2("ligand.mol2")
result = mp.io.read_lammps_data("system.data", atom_style="full")
frame = result.frame
forcefield = result.forcefield
frame = mp.io.read_xyz("coords.xyz")

SMILES in, local SMARTS out — both on the io surface, not methods on the graph:

mol = mp.io.read_smiles("CCO")
pattern = mp.io.write_smarts(mol, next(iter(mol.atoms)))

Readers take a path; readers whose format has only frame state return a Frame. LAMMPS data callers explicitly choose result.frame, result.forcefield, result.counts, or result.type_labels.

# docs: skip — reads offline structure files; I/O unit-tested with fixtures
# Populate an existing frame while preserving typed metadata
existing = mp.Frame(meta={"timestep": 42})
frame = mp.io.read_pdb("molecule.pdb", frame=existing)
print(frame.meta["timestep"]) # 42

The Frame returned by a data reader typically contains an "atoms" block with coordinates, and may contain "bonds", "angles", and "dihedrals" blocks depending on the format.

# docs: skip — reads offline structure files; I/O unit-tested with fixtures
frame = mp.io.read_pdb("water.pdb")
print(frame["atoms"].nrows) # number of atoms
print(list(frame["atoms"].keys())) # ['id', 'element', 'x', 'y', 'z',...]

Writing

Writers follow the same factory pattern:

mp.io.write_pdb("output.pdb", frame)
mp.io.write_gro("output.gro", frame)
mp.io.write_lammps_data("output.data", frame, atom_style="full")
mp.io.write_xsf("output.xsf", frame)

For LAMMPS, writing both the data file and force-field coefficients at once is the most common pattern. write_lammps_system is a convenience wrapper — it does exactly what calling write_lammps_data and write_lammps_forcefield yourself would. Its first argument is a directory: it is created if needed, and the two files (always named system.data and system.ff) are written inside it:

ff = mp.ForceField(name="demo", units="real")
atomstyle = ff.def_atomstyle("full")
atomstyle.def_type("OW", mass=15.999)
atomstyle.def_type("HW", mass=1.008)

paths = mp.io.write_lammps_system("system", frame, ff)
# Creates the directory system/ containing:
# system/system.data (topology + coordinates)
# system/system.ff (force-field coefficients)
# and returns {"data": Path("system/system.data"), "ff": Path("system/system.ff")}

Supported data formats

Format Read Write Notes
PDB read_pdb write_pdb ATOM/HETATM + CRYST1 + CONECT
GRO read_gro write_gro GROMACS structure
MOL2 read_mol2 — Tripos MOL2
LAMMPS data read_lammps_data write_lammps_data atom_style defaults to "full"; result is Frame-like (result.box, result["atoms"]) plus .forcefield
LAMMPS molecule read_lammps_molecule write_lammps_molecule Template files
XYZ read_xyz — Simple coordinate format
XSF read_xsf write_xsf XCrySDen format
AMBER AC read_amber_ac — Antechamber format
AMBER inpcrd read_amber_inpcrd — AMBER coordinates

Trajectory files: lazy Frame sequences

Trajectory readers return objects that behave like indexed, iterable sequences of frames. They use memory-mapped files and persistent indexing to handle large trajectories efficiently.

Reading

# docs: skip — trajectory demo needs a well-typed frame from offline data
mp.io.write_lammps_trajectory("dump.lammpstrj", [frame, frame], atom_style="full")
reader = mp.io.read_lammps_trajectory("dump.lammpstrj")

print(reader.n_frames) # total frame count
frame_0 = reader[0] # random access by index
frame_last = reader[-1] # negative indexing
subset = reader[10:20] # slicing returns list[Frame]

Iteration is lazy — frames are parsed on demand with background prefetching:

# docs: skip — depends on trajectory reader from offline dump
for frame in reader:
 atoms = frame["atoms"]
 # process one frame at a time

Always close the reader when done (or use a context manager) to release memory-mapped file descriptors:

# docs: skip — depends on offline trajectory reader above
reader.close()

Writing

Trajectory writers accept a list of frames:

frames = [frame, frame]
mp.io.write_lammps_trajectory("output.lammpstrj", frames, atom_style="full")
mp.io.write_xyz_trajectory("output.xyz", frames)

mrec stores: scientific records

An *.mrec store is a Zarr-v3 directory (not a single file) that holds a trajectory as a self-describing record. Frames may be ragged — a growth or packing run whose atom count changes per frame stays one trajectory — and the store carries step labels and, optionally, per-frame blocks such as bonds.

Read it lazily with the one-frame cursor, under mp.io.mrec:

# docs: skip — needs an on-disk .mrec store
from molpy.io import mrec

reader = mrec.TrajectoryReader("growth.mrec")
len(reader)                    # committed frame count, index only — no decode
frame = reader[-1]             # random access; negative indexing
reader.step                    # per-frame step numbers (labels)
if reader.has_block("bonds"):  # decide once, not per frame
    ...
for frame in reader:           # lazy iteration, one decode at a time
    ...

For a run too large to hold in memory — or one that grows a frame at a time — write it append-first instead of building the whole Trajectory:

# docs: skip — needs on-disk frames
from molpy.io import mrec

schema = mrec.SequenceSchema.from_frames([first_frame, prototype])
with mrec.TrajectoryWriter("growth.mrec", schema) as writer:
    for frame in frames:
        writer.append(frame)            # or append(frame, step=..., time=...)
# frames land on close; call writer.flush() to land them sooner

The schema is pinned at create: every appended frame is checked against it. To record a section that only appears partway through a run — per-frame bonds in a growth run — declare it up front by including a frame that carries an even-empty bonds block in the schema prototype. The one-shot mrec.read_trajectory / mrec.write_trajectory doors remain for small stores that fit in memory.

Supported trajectory formats

Format Read Write Notes
LAMMPS dump read_lammps_trajectory write_lammps_trajectory Custom columns supported
XYZ read_xyz_trajectory write_xyz_trajectory Multi-frame XYZ
mrec store mrec.TrajectoryReader / mrec.read_trajectory mrec.TrajectoryWriter / mrec.write_trajectory Zarr-v3 directory; ragged; step labels; per-frame blocks

All I/O is mp.io.read_* / mp.io.write_* only — there is no package-root mp.read_* and no MolStore / Zarr store layer.

Log files: simulation run metadata

LAMMPS log files are different from data and trajectory files: they describe what LAMMPS did during each run, not where atoms were. The parser keeps that structure: a LammpsLog holds the header, one LammpsRun per run, and the raw text of anything not represented by a dedicated field.

The key idea is: use read_lammps_log when you need run-level diagnostics such as thermo output, loop timing, load balance, neighbor statistics, or warnings. A run's thermo is a table you index by the LAMMPS column name, so downstream analysis can use dynamic column names directly.

In practice, each run is accessed in the same order it appears in the log:

# docs: skip — reads offline log.lammps; I/O unit-tested with fixtures
log = mp.io.read_lammps_log("log.lammps")
run = log.runs[0]

print(run.thermo.columns)             # ["Step", "Temp", ...]
print(run.thermo["Temp"].mean())      # one float64 column
print(run.thermo.rows.shape)          # (n_rows, n_columns)
print(run.loop_time.seconds)
print(run.neighbor_statistics.dangerous_builds)

parse_lammps_log_text(text) parses an in-memory string the same way, and log.to_dict() gives the nested JSON-friendly form.

Supported log formats

Format Read Write Notes
LAMMPS log read_lammps_log — LammpsLog → LammpsRun → LammpsThermo, parsed natively

Force field files: ForceField in, ForceField out

Force field readers parse parameter files into ForceField objects. Force field writers serialize ForceField objects into engine-specific formats.

LAMMPS *.ff includes are read and written (the LAMMPS force-field reader/writer). Molpy's mp.io.read_lammps_forcefield, write_lammps_forcefield, and LAMMPSForceFieldWriter are the public API. Params live in engine units (Å, kcal/mol, radians, harmonic ½k); the writer converts to LAMMPS real (K = k/2, degrees) on output.

Reading

# docs: skip — reads offline force-field files; I/O unit-tested with fixtures
ff = mp.io.read_xml_forcefield(mp.data.get_forcefield_path("oplsaa.xml"))
ff = mp.io.read_lammps_forcefield(paths["ff"])
ff = mp.io.read_top("forcefield.itp")

AMBER prmtop files contain both structure and parameters. read_amber returns both:

# docs: skip — reads Amber prmtop/inpcrd artifacts from an offline run
frame, ff = mp.io.read_amber("system.prmtop", "system.inpcrd")

Writing

Each writer produces output in a specific engine format from the same ForceField object:

from molpy.io.forcefield import LAMMPSForceFieldWriter, XMLForceFieldWriter
from molpy.io.forcefield.top import GromacsForceFieldWriter

# LAMMPS coefficients
LAMMPSForceFieldWriter("system.ff", precision=4).write(ff)

# GROMACS.itp
GromacsForceFieldWriter("system.itp", precision=4).write(ff)

# OpenMM XML
XMLForceFieldWriter("system.xml", precision=6).write(ff)

Type filtering

LAMMPS force field files often need to include only the types actually used in the system. Pass type sets to filter the output:

LAMMPSForceFieldWriter("system.ff").write(
 ff,
 atom_types={"CT", "HC", "OH"},
 bond_types={"CT-HC", "CT-OH"},
)

Supported force field formats

Format Read Write Notes
OpenMM/OPLS XML read_xml_forcefield XMLForceFieldWriter Primary force field format
LAMMPS coefficients read_lammps_forcefield LAMMPSForceFieldWriter Supports hybrid styles
GROMACS.itp read_top GromacsForceFieldWriter Topology format
AMBER prmtop read_amber — Returns (Frame, ForceField)

Extending with new formats

Adding a new data, trajectory, or force-field format is done by subclassing the reader/writer base classes and registering a factory function. That extension workflow — DataReader/DataWriter, BaseTrajectoryReader/TrajectoryWriter, and ForceFieldWriter + formatter registration — is documented in full in the Developer Guide: I/O Formats.

Quick reference

I want to... Function
Read a PDB mp.io.read_pdb(path)
Write LAMMPS data + ff mp.io.write_lammps_system(dir, frame, ff)
Read a trajectory mp.io.read_lammps_trajectory(path)
Load OPLS-AA mp.io.read_xml_forcefield(mp.data.get_forcefield_path("oplsaa.xml"))
Read AMBER topology mp.io.read_amber(prmtop, inpcrd)
Write filtered ff LAMMPSForceFieldWriter(path).write(ff, atom_types={...})
Add a new format Developer Guide: I/O Formats

See also: API Reference: I/O, Concepts: Force Field.