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 covers three kinds of file: data (single-frame structures), trajectory (multi-frame sequences), and forcefield (parameter files). All three are mp.io, which mirrors molrs.io by identity (mp.io.read_pdb is molrs.io.read_pdb): every reader and writer is a function at its top named after its format — read_<fmt>[_<what>] / write_<fmt>[_<what>] for a path, _str for text in memory, _trajectory for every frame of a multi-frame file — and a class that belongs to one format lives in that format's submodule (mp.io.smiles, mp.io.cgsmiles, mp.io.lammps, mp.io.mrec, mp.io.pdb, mp.io.xyz, …). No door picks the format from a file name: you call the format's own.

Data files

Every data reader returns a Frame: blocks, dict-like meta, and an optional box. What a format holds beyond the structure is frame meta — a LAMMPS data file's * Coeffs text, header counts and Type Labels included.

Reading

Every format has its own reader function on mp.io:

The examples below share this setup:

import molpy as mp

frame = mp.Frame(
    blocks={
        "atoms": {
            # A writer asks for the columns its format has fields for: LAMMPS
            # wants `type` labels and `mol_id`, XSF wants `atomic_number`.
            "id": [1, 2, 3, 4, 5, 6],
            "mol_id": [1, 1, 1, 2, 2, 2],
            "type": ["OW", "HW", "HW", "OW", "HW", "HW"],
            "element": ["O", "H", "H", "O", "H", "H"],
            "atomic_number": [8, 1, 1, 8, 1, 1],
            "charge": [-0.834, 0.417, 0.417, -0.834, 0.417, 0.417],
            "x": [1.0, 1.96, 0.76, 6.0, 6.96, 5.76],
            "y": [1.0, 1.0, 1.93, 6.0, 6.0, 6.93],
            "z": [1.0, 1.0, 1.0, 6.0, 6.0, 6.0],
        }
    }
)
frame.box = mp.Box(mp.core.Cell.lengths([20.0] * 3))
# 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")
frame = mp.io.read_lammps_data("system.data", atom_style="full")
frame = mp.io.read_xyz("coords.xyz")
frame = mp.io.read_cif("crystal.cif")

A CIF's symmetry stays with the frame: read_cif keeps the operator loop verbatim in frame.meta["symops"] and the Hermann–Mauguin symbol in frame.meta["space_group"], each only when the file states it. The atoms block is the asymmetric unit as written; mp.builder.Lattice.from_cif(frame) expands it into the full cell (see Nanostructures).

SMILES text in is an io door; a local SMARTS out is a perceive pattern, whose str is the SMARTS text:

mol = mp.io.read_smiles_str("CCO")
pattern = mp.perceive.SmartsPattern.from_environment(mol, next(iter(mol.atoms)).handle)
print(str(pattern))  # the SMARTS text
print(mp.io.write_smiles_str(mol))  # and SMILES back out

Readers take a path and return a Frame. A LAMMPS data file's force field is its * Coeffs sections, which the structure reader keeps as text; read_lammps_data_coeffs reads them from the same file into a ForceField when you want them. Each row is named by the file's Type Labels, and the units are the file's own (its write_data title line), else "real" — pass units= for a file that does not state them:

# docs: skip — reads an offline data file; I/O unit-tested with fixtures
frame = mp.io.read_lammps_data("system.data")
ff = mp.io.read_lammps_data_coeffs("system.data")
print(frame.meta["lammps_counts"])  # atoms=…,bonds=…,atom_types=…

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"].n_rows) # number of atoms
print(list(frame["atoms"].keys())) # ['id', 'element', 'x', 'y', 'z',...]

Writing

Writers follow the same one-door-per-format pattern:

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

For LAMMPS, the most common pattern writes the data file and the force-field coefficients side by side: write_lammps_data for topology + coordinates, and write_lammps_forcefield(path, forcefield, frame) for the coefficients. The coefficient writer takes the frame too: every type label of the frame is looked up in the force field by its exact name, and only the coefficients the frame uses are written. The pair cutoff is a run setting you declare on the pair style; molpy never invents one.

ff = mp.ff.forcefield.ForceField(name="demo", units="real")
atom_style = ff.def_style("atom", "full")
ow = atom_style.def_type("OW", mass=15.999, charge=-0.834)
hw = atom_style.def_type("HW", mass=1.008, charge=0.417)
pair_style = ff.def_style("pair", "lj/cut")
pair_style.def_type("OW", ow, epsilon=0.1521, sigma=3.1507)
pair_style.def_type("HW", hw, epsilon=0.0, sigma=0.0)
pair_style["cutoff"] = 10.0
ff.def_style("pair", "coul/cut")["cutoff"] = 10.0  # the charges' pair term

mp.io.write_lammps_data("system.data", frame)  # topology + coordinates
mp.io.write_lammps_forcefield("system.ff", ff, frame)  # force-field coefficients

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 write_mol2 Tripos MOL2
LAMMPS data read_lammps_data write_lammps_data atom_style fixes the Atoms layout (detected when None); * Coeffs kept as text in meta["lammps_coeffs_text"]; type_labels={"atoms": [...]} declares unused labels; a Drude system gets its fix drude flags
LAMMPS molecule read_lammps_molecule write_lammps_molecule Template files
XYZ read_xyz write_xyz Extended XYZ; an n-wide property is one (N, n) column
XSF read_xsf write_xsf XCrySDen format
AMBER AC read_ac — Antechamber format; emits charge
AMBER inpcrd read_inpcrd — AMBER coordinates; frame= reads them into an existing frame
AMBER prmtop read_prmtop — Structure; mp.io.read_prmtop_system returns (ForceField, Frame)
CIF read_cif write_cif Crystallographic Information File
VASP POSCAR read_poscar write_poscar VASP structure

LAMMPS fix bond/react systems are written by mp.io.write_lammps_bond_react_system(workdir, frame, ff, templates) (data, .ff, _pre.mol / _post.mol and .map files with one type numbering), from mp.io.lammps.BondReactTemplate(pre, post, initiator_atoms, ...).

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

mp.io.write_lammps_dump_trajectory("dump.lammpstrj", [frame, frame, frame])
reader = mp.io.read_lammps_dump_trajectory("dump.lammpstrj")

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

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

for snapshot in reader:
 atoms = snapshot["atoms"]
 # process one frame at a time

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

reader.close()

Writing

Trajectory writers accept a list of frames:

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

mrec: scientific records

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

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. The schema comes from prototype frames:

frames = []
for step in range(5):
    snapshot = frame.copy()
    snapshot["atoms"]["x"] = snapshot["atoms"]["x"] + 0.1 * step  # a toy drift
    frames.append(snapshot)

schema = mp.io.mrec.SequenceSchema.from_frames(frames[:1])
with mp.io.mrec.MrecWriter("run.mrec", schema) as writer:
    for step, snapshot in enumerate(frames):
        writer.append(snapshot, step=100 * step)  # time=... is optional
# frames land on close; call writer.flush() to land them sooner

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

reader = mp.io.mrec.MrecReader("run.mrec")
print(len(reader))             # 5 — committed frame count, index only, no decode
print(reader.step)             # [0, 100, 200, 300, 400] — per-frame step labels
last = reader[-1]              # random access; negative indexing
if reader.has_block("bonds"):  # decide once, not per frame
    ...
for snapshot in reader:        # lazy iteration, one decode at a time
    ...

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 mp.io.read_mrec_trajectory / mp.io.write_mrec_trajectory doors serve small records that fit in memory. A record holding one snapshot is read and written with mp.io.read_mrec_frame / mp.io.write_mrec_frame, a topology-only record with mp.io.read_mrec_system / mp.io.write_mrec_system, and mp.io.mrec.section_names(path) says which of these a record holds.

A force field travels in the same record as its own forcefield section, with its units declared and never converted:

mp.io.write_mrec_frame("water.mrec", frame, forcefield=ff)  # or mp.io.write_mrec_forcefield
section = mp.io.read_mrec_forcefield("water.mrec")  # an mp.io.mrec.ForceFieldSection, None when absent
ff_back = section.to_forcefield()

frame.meta is stored typed (an i32 stays i32, NaN stays NaN).

A float column can declare a precision — an absolute tolerance in the column's units. The writer rounds the values to that grid and compresses the column, which cuts coordinates to a few bytes per atom per frame; the reader hands back the rounded values and the declaration. Without a declaration the floats are stored exactly.

snapshot = frame.copy()
snapshot["atoms"].set_precision("x", 1e-3)  # keep x to a thousandth of an Å
mp.io.write_mrec_frame("snapshot.mrec", snapshot)
back = mp.io.read_mrec_frame("snapshot.mrec")
print(back["atoms"].precision("x"))  # 0.001
print(sorted(mp.io.mrec.section_names("snapshot.mrec")))  # ['frame', 'meta']

A trajectory takes each column's precision from its prototype frame (SequenceSchema.from_frames), or from schema.declare_precision(block, column, precision).

Supported trajectory formats

Format Read Write Notes
LAMMPS dump read_lammps_dump_trajectory write_lammps_dump_trajectory Custom columns supported
XYZ read_xyz_trajectory write_xyz_trajectory Multi-frame XYZ
PDB read_pdb_trajectory write_pdb_trajectory One MODEL per frame
GRO read_gro_trajectory write_gro_trajectory Multi-frame GRO
DCD read_dcd_trajectory write_dcd_trajectory NAMD/CHARMM binary
TRR read_trr_trajectory write_trr_trajectory GROMACS full precision
XTC read_xtc_trajectory write_xtc_trajectory GROMACS compressed
mrec record mrec.MrecReader / read_mrec_trajectory mrec.MrecWriter / write_mrec_trajectory Zarr-v3 directory; ragged; step labels; per-frame blocks

Names come in pairs: read_X / write_X for one frame, read_X_trajectory / write_X_trajectory for a sequence of frames. A read_X_trajectory returns the format's own lazy reader (mp.io.lammps.LammpsDumpReader, mp.io.xyz.XyzReader, mp.io.pdb.PdbReader, mp.io.gro.GroReader, mp.io.dcd.DcdReader, mp.io.trr.TrrReader, mp.io.xtc.XtcReader); .read_all() gives a list of frames.

Every file door is mp.io.read_* / mp.io.write_*; an mrec record is read and written through mp.io.read_mrec* / mp.io.write_mrec*.

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)

read_lammps_log_str(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 (read_lammps_log_str for in-memory text) — mp.io.lammps.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 their *_str variants are the public API. The force-field IR adopts the LAMMPS standard (every style's expression, factors and parameter units are its LAMMPS style's; angle values in degrees), so a coefficient is read and written as it stands (K = k). The reader declares the file's units on the force field and converts nothing; the writer converts energies and lengths only when the target units differs from the force field's.

Reading

# docs: skip — reads offline force-field files; I/O unit-tested with fixtures
ff = mp.io.read_openmm_forcefield(mp.resources.get_path("forcefield/tip3p.xml"))
ff = mp.io.read_lammps_forcefield("system.ff")
ff = mp.io.read_top_forcefield("forcefield.itp")

AMBER prmtop files contain both structure and parameters. read_prmtop_system returns both — the force field, and the structure frame with a pairs block for the 1-4 pairs whose SCEE / SCNB differ from the field's special_bonds (GLYCAM beside ff14SB) — as read_top_system does for a GROMACS topology; the inpcrd adds the coordinates:

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

Writing

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

# LAMMPS coefficients (selected by the frame's type labels)
mp.io.write_lammps_forcefield("system.ff", ff, frame, precision=4)

# OpenMM XML
mp.io.write_openmm_forcefield("system.xml", ff, precision=6)

GROMACS keeps cutoffs in the run parameters (.mdp), not in the topology, so the .itp writer does not write the pair style's cutoff — the same ff feeds both writers:

mp.io.write_top_forcefield("system.itp", ff, precision=4)

Type filtering

LAMMPS force field files should include only the types actually used in the system. write_lammps_forcefield does that by construction: the frame's type labels select the coefficients, so a large library force field writes only what the system uses. A frame label the force field has no type for raises ValueError naming the block and the label.

Supported force field formats

Format Read Write Notes
Force-field XML read_xml_forcefield write_xml_forcefield Every style of a force field, in its declared units; a write followed by a read gives the same force field
OpenMM XML read_openmm_forcefield write_openmm_forcefield OpenMM's own schema (nm, kJ/mol); the writer refuses what OpenMM cannot express
LAMMPS coefficients read_lammps_forcefield write_lammps_forcefield Coefficients selected by the frame's type labels
LAMMPS data * Coeffs read_lammps_data_coeffs / read_lammps_data_coeffs_str write_lammps_data_coeffs_str The reader takes the data file (or its text); the writer returns the sections' text for a force field and its typed frame
GROMACS .top / .itp read_top_forcefield write_top_forcefield Force-field directives
GROMACS system read_top_system write_top_system (ForceField, typed Frame), 0-based
AMBER prmtop read_prmtop_forcefield write_frcmod Force-field tables
AMBER system read_prmtop_system — (ForceField, typed Frame), per-pair 1-4 weights in pairs

All of these are on mp.io; the ForceField they read and write is mp.ff.forcefield's.

Extending with new formats

A new format is a native reader and writer in molrs, which mp.io mirrors by identity. The workflow is documented 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_data(path, frame) + mp.io.write_lammps_forcefield(path, ff, frame)
Read a trajectory mp.io.read_lammps_dump_trajectory(path)
Load OPLS-AA mp.ff.typifier.OplsAaTypifier().source_forcefield()
Read AMBER topology mp.io.read_prmtop_system(prmtop) + mp.io.read_inpcrd(inpcrd, frame)
Write only the used coefficients mp.io.write_lammps_forcefield(path, ff, frame)
Add a new format Developer Guide: I/O Formats

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