readcon-core 0.23.2

An oxidized single and multiple CON file reader and writer with FFI bindings for ergonomic C/C++ usage. Implements CON spec v3.
Documentation
# Copyright (c) readcon-core developers
"""The CON writer must not keep a second copy of the trajectory."""

from pathlib import Path

import pytest
import readcon


def _hwm_kib() -> int:
    for line in Path("/proc/self/status").read_text(encoding="utf-8").splitlines():
        if line.startswith("VmHWM:"):
            return int(line.split()[1])
    raise AssertionError("VmHWM")


def _trajectory(path: Path, n_frames: int, n_atoms: int, velocities: bool) -> None:
    lines = []
    for frame in range(n_frames):
        lines.append(f"Frame {frame}")
        lines.append("stream")
        lines.append("8.0 8.0 4.0")
        lines.append("90.0 90.0 90.0")
        lines.append("0 0")
        lines.append("0 0")
        lines.append("1")
        lines.append(str(n_atoms))
        lines.append("63.546")
        lines.append("Cu")
        lines.append("Coordinates of Component 1")
        for atom in range(n_atoms):
            lines.append(f"{atom}.0 {frame}.0 0.0 0 {atom}")
        if velocities:
            lines.append("")
            lines.append("Cu")
            lines.append("Velocities of Component 1")
            for atom in range(n_atoms):
                lines.append(f"0.{atom % 10} 0.2 0.3 0 {atom}")
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def test_write_con_does_not_materialise_a_second_trajectory(tmp_path):
    """Writing a read trajectory streams one frame at a time.

    Collecting every frame into a second Rust trajectory, or building a
    Python atom for each of them, raises the high-water mark by about
    the size of that trajectory on top of the output file.
    """
    n_frames = 2000
    n_atoms = 32
    src = tmp_path / "in.con"
    _trajectory(src, n_frames, n_atoms, velocities=False)
    loaded = readcon.read_con(str(src))
    assert len(loaded) == n_frames
    before = _hwm_kib()
    out = tmp_path / "out.con"
    readcon.write_con(str(out), loaded)
    growth = (_hwm_kib() - before) * 1024
    coords = n_frames * n_atoms * 3 * 8
    file_size = out.stat().st_size
    assert growth < file_size + coords // 2, (growth, file_size, coords)
    again = readcon.read_con(str(out))
    assert len(again) == n_frames
    assert again[3].atoms[4].x == pytest.approx(4.0)
    assert again[3].atoms[4].y == pytest.approx(3.0)

    vel_src = tmp_path / "in.convel"
    _trajectory(vel_src, 4, n_atoms, velocities=True)
    vel_out = tmp_path / "out.convel"
    readcon.write_con(str(vel_out), readcon.read_con(str(vel_src)))
    restored = readcon.read_con(str(vel_out))
    assert restored[0].atoms[1].vx == pytest.approx(0.1)
    assert restored[2].atoms[0].y == pytest.approx(2.0)


def test_multicomponent_read_write_keeps_coordinates(tmp_path):
    """A read that parks coordinates in the shared slab still writes them.

    The atom slots alias the parse buffer. Dropping that buffer before the
    slots point at the slab writes freed coordinates, and a one-rank MPI
    rewrite of the same frames no longer matches the serial file.
    """
    src = Path(__file__).resolve().parents[2] / "resources/test/tiny_multi_cuh2.con"
    many = tmp_path / "many.con"
    many.write_text(src.read_text(encoding="utf-8") * 40, encoding="utf-8")
    loaded = readcon.read_con(str(many))
    out = tmp_path / "out.con"
    readcon.write_con(str(out), loaded)
    first = out.read_text(encoding="utf-8").splitlines()[11]
    assert "0.6394" in first
    assert "0.9045" in first
    again = readcon.read_con(str(out))
    assert again[0].atoms[0].x == pytest.approx(0.6394)
    assert again[0].atoms[0].y == pytest.approx(0.9045)
    assert again[0].atoms[0].z == pytest.approx(6.9753)
    assert again[1].atoms[2].x == pytest.approx(8.8549)
    assert again[1].atoms[2].z == pytest.approx(11.165)