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:
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:
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.