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:
Always close the reader when done (or use a context manager) to release memory-mapped file descriptors:
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:
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.