mdinterface 2.0.0__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {mdinterface-2.0.0 → mdinterface-2.0.1}/CHANGELOG.md +12 -0
  2. {mdinterface-2.0.0 → mdinterface-2.0.1}/CONTRIBUTING.md +4 -0
  3. {mdinterface-2.0.0 → mdinterface-2.0.1}/MANIFEST.in +1 -0
  4. {mdinterface-2.0.0 → mdinterface-2.0.1}/PKG-INFO +1 -1
  5. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/guide/polymer.md +1 -1
  6. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/guide/simcell.md +24 -9
  7. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/guide/specie.md +1 -1
  8. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/__init__.py +1 -1
  9. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/builder.py +22 -22
  10. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/core/polymer.py +8 -6
  11. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/core/specie.py +6 -2
  12. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/externals/ligpargen.py +7 -5
  13. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/io/gromacswriter.py +132 -43
  14. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/auxiliary.py +6 -0
  15. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface.egg-info/PKG-INFO +1 -1
  16. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface.egg-info/SOURCES.txt +6 -1
  17. mdinterface-2.0.1/tests/data/gromacs/README.md +5 -0
  18. mdinterface-2.0.1/tests/data/gromacs/benzene.json +729 -0
  19. mdinterface-2.0.1/tests/data/gromacs/ethanol.json +446 -0
  20. mdinterface-2.0.1/tests/data/gromacs/methane.json +172 -0
  21. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_chemistry.py +14 -1
  22. mdinterface-2.0.1/tests/test_gromacswriter.py +351 -0
  23. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_parameterization.py +29 -0
  24. {mdinterface-2.0.0 → mdinterface-2.0.1}/LICENSE +0 -0
  25. {mdinterface-2.0.0 → mdinterface-2.0.1}/README.md +0 -0
  26. {mdinterface-2.0.0 → mdinterface-2.0.1}/assets/mdinterface.png +0 -0
  27. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/api/database.md +0 -0
  28. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/api/externals.md +0 -0
  29. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/api/io.md +0 -0
  30. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/api/polymer.md +0 -0
  31. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/api/simcell.md +0 -0
  32. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/api/specie.md +0 -0
  33. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/assets/mdinterface.png +0 -0
  34. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/development/releasing.md +0 -0
  35. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/guide/database.md +0 -0
  36. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/guide/logging.md +0 -0
  37. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/guide/migration-2.md +0 -0
  38. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/index.md +0 -0
  39. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/installation.md +0 -0
  40. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/quickstart.md +0 -0
  41. {mdinterface-2.0.0 → mdinterface-2.0.1}/docs/requirements.txt +0 -0
  42. {mdinterface-2.0.0 → mdinterface-2.0.1}/environment-full.yml +0 -0
  43. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/data/membrane.data.lammps +0 -0
  44. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/data/membrane.production.dump +0 -0
  45. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/data/water_frame.dump +0 -0
  46. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/electrode_interface.py +0 -0
  47. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/multilayer.py +0 -0
  48. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/multisolvent_box.py +0 -0
  49. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/polymer/data/mon1/monomer_1.lammps.lmp +0 -0
  50. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/polymer/data/mon2/monomer_2.lammps.lmp +0 -0
  51. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/polymer/polymer_from_smiles.py +0 -0
  52. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/polymer/polymer_piperion.py +0 -0
  53. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/sandwich_from_traj.py +0 -0
  54. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/smiles_box.py +0 -0
  55. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/solvent_box.py +0 -0
  56. {mdinterface-2.0.0 → mdinterface-2.0.1}/examples/solvent_pocket.py +0 -0
  57. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/__init__.py +0 -0
  58. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/box.py +0 -0
  59. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/compartment.py +0 -0
  60. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/continuum2sim.py +0 -0
  61. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/polymerize.py +0 -0
  62. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/regions.py +0 -0
  63. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/snippets.py +0 -0
  64. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/build/solvent.py +0 -0
  65. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/config.ini +0 -0
  66. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/config.py +0 -0
  67. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/core/__init__.py +0 -0
  68. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/core/chemistry.py +0 -0
  69. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/core/topology.py +0 -0
  70. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/database/__init__.py +0 -0
  71. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/database/graphene.py +0 -0
  72. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/database/ions.py +0 -0
  73. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/database/metals.py +0 -0
  74. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/database/molecules.py +0 -0
  75. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/database/nobles.py +0 -0
  76. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/externals/__init__.py +0 -0
  77. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/externals/aimd.py +0 -0
  78. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/externals/ase.py +0 -0
  79. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/externals/obabel.py +0 -0
  80. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/externals/optimization.py +0 -0
  81. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/externals/pyscf.py +0 -0
  82. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/io/__init__.py +0 -0
  83. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/io/lammpswriter.py +0 -0
  84. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/io/packmol.py +0 -0
  85. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/io/read.py +0 -0
  86. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/io/structure_metadata.py +0 -0
  87. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/read/cp2ktraj.py +0 -0
  88. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/read/lammpstraj.py +0 -0
  89. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/read/read.py +0 -0
  90. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/read/trajectory.py +0 -0
  91. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/read/xyztraj.py +0 -0
  92. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/__init__.py +0 -0
  93. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/draw.py +0 -0
  94. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/graphs.py +0 -0
  95. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/logger.py +0 -0
  96. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/map.py +0 -0
  97. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/poisson.py +0 -0
  98. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/rings.py +0 -0
  99. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface/utils/units.py +0 -0
  100. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface.egg-info/dependency_links.txt +0 -0
  101. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface.egg-info/requires.txt +0 -0
  102. {mdinterface-2.0.0 → mdinterface-2.0.1}/mdinterface.egg-info/top_level.txt +0 -0
  103. {mdinterface-2.0.0 → mdinterface-2.0.1}/mkdocs.yml +0 -0
  104. {mdinterface-2.0.0 → mdinterface-2.0.1}/pyproject.toml +0 -0
  105. {mdinterface-2.0.0 → mdinterface-2.0.1}/requirements.txt +0 -0
  106. {mdinterface-2.0.0 → mdinterface-2.0.1}/scripts/extract_changelog_summary.py +0 -0
  107. {mdinterface-2.0.0 → mdinterface-2.0.1}/scripts/verify_distribution_contents.py +0 -0
  108. {mdinterface-2.0.0 → mdinterface-2.0.1}/setup.cfg +0 -0
  109. {mdinterface-2.0.0 → mdinterface-2.0.1}/setup.py +0 -0
  110. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/__init__.py +0 -0
  111. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_auxiliary.py +0 -0
  112. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_box.py +0 -0
  113. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_builder.py +0 -0
  114. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_changelog_summary.py +0 -0
  115. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_compartment.py +0 -0
  116. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_database.py +0 -0
  117. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_distribution_contents.py +0 -0
  118. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_imports.py +0 -0
  119. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_ligpargen.py +0 -0
  120. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_regions.py +0 -0
  121. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_solvent.py +0 -0
  122. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_specie.py +0 -0
  123. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_structure_metadata.py +0 -0
  124. {mdinterface-2.0.0 → mdinterface-2.0.1}/tests/test_topology.py +0 -0
@@ -4,6 +4,18 @@ All notable changes to mdinterface are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.1] - 2026-09-28
8
+
9
+ Fixes GROMACS topology generation and validation for single- and multi-species systems, with LAMMPS/GROMACS energy and force regression coverage. Charge audits now report numerical noise near zero as `0.0` without changing atomic partial charges.
10
+
11
+ ### Fixed
12
+ - Parameterization and polymer charge audits normalize numerical noise within `1e-12 e` of zero without altering atomic partial charges.
13
+ - GROMACS exports restore scaled 1-4 interactions using explicit pairs derived from bond connectivity, including zero-valued torsions.
14
+ - `SimCell.write_gromacs()` writes shared atom types before molecule definitions so mixed-species topologies can be preprocessed.
15
+ - GROMACS system topologies preserve consecutive molecule order to match the coordinate file.
16
+ - GROMACS exports reject incomplete or unsupported parameters and conflicting species definitions before writing files.
17
+ - `SimCell.write_gromacs()` rejects assembled residues whose atom types, charges, masses, or atom counts differ from their species definitions.
18
+
7
19
  ## [2.0.0] - 2026-09-28
8
20
 
9
21
  Makes `SimCell` the sole system builder, removing the deprecated `SimulationBox` and `BoxBuilder` APIs and adding spatially constrained solvent regions and optional LAMMPS structural metadata. This major release also changes explicit centering behavior, requires Python 3.10 or newer, and adds RDKit molecular preparation, atomic force-field parameterization, export validation, and improved external-tool diagnostics.
@@ -65,3 +65,7 @@ Update the relevant guide when changing a documented public API or workflow. API
65
65
  Add every user-visible change to `CHANGELOG.md` under `Unreleased`, grouped under `Added`, `Changed`, `Fixed`, or `Deprecated`. Write one concise, unwrapped sentence per bullet and describe one observable change. Omit internal refactoring, agent instructions, contributor-only documentation, CI maintenance, and release-process changes unless they affect what package users install or receive.
66
66
 
67
67
  See `docs/development/releasing.md` for the release process.
68
+
69
+ ### GROMACS conversion checks
70
+
71
+ `pytest -q tests/test_gromacswriter.py` checks topology structure, parameter validation, molecule ordering, and unit conversions. With `gmx_d` (preferred), or `gmx`, and `lmp` on `PATH`, it also runs GROMACS preprocessing and compares single-point energies and forces against LAMMPS. Engine-dependent tests use the `integration` marker and skip when the executables are unavailable. Hosted integration CI installs both engines. `GMX_BINARY` can select a specific GROMACS executable; the packed electrolyte force comparison requires double precision. Frozen common-molecule fixtures avoid a BOSS dependency in CI. These tests use matched coordinates and explicit OPLS settings; they do not select production simulation protocols.
@@ -17,6 +17,7 @@ recursive-include docs *
17
17
  recursive-include examples *
18
18
  recursive-include scripts *.py
19
19
  recursive-include tests *.py
20
+ recursive-include tests/data *.json *.md
20
21
  include assets/*
21
22
 
22
23
  # Exclude unwanted files
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdinterface
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Build Interface Systems for Molecular Dynamics Simulations
5
5
  Author-email: Fabrice Roncoroni <fabrice.roncoroni@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -123,7 +123,7 @@ Refinement stages topology and charge changes and applies them only after every
123
123
 
124
124
  Refinement requires a connected graph and `snippet_radius >= 4`. Snippets preserve touched rings, formal-charge sites and their immediate neighbors, and multiple bonds; artificial caps are neutral. Charges and topology are recalculated for every junction without the former element-string cache. RDKit-backed snippets use the MOL handoff described in the [species guide](specie.md).
125
125
 
126
- Assembly removes leaving-atom partial charges, so an unrefined chain need not have its intended total charge. `round_charges()` only rounds the existing total. Refinement returns a charge audit containing the initial total, formal target, refined total, residual (refined minus formal), per-atom correction, final total, and junction count. It also logs the residual before applying the optional uniform `charge_correction="uniform"` correction; this correction enforces the chain total, not an integer charge on each repeat unit. Check local charge changes as well as the total. The piperion example runs refinement and checks both the chain charge and final membrane neutrality before export. Its whole-chain RDKit geometry step replaces the former optional ML-relaxation recipe.
126
+ Assembly removes leaving-atom partial charges, so an unrefined chain need not have its intended total charge. `round_charges()` only rounds the existing total. Refinement returns a charge audit containing the initial total, formal target, refined total, residual (refined minus formal), per-atom correction, final total, and junction count. It also logs the residual before applying the optional uniform `charge_correction="uniform"` correction; this correction enforces the chain total, not an integer charge on each repeat unit. Check local charge changes as well as the total. The piperion example runs refinement and checks both the chain charge and final membrane neutrality before export. Its whole-chain RDKit geometry step replaces the former optional ML-relaxation recipe. Charge-audit totals and residuals within `1e-12 e` of zero are reported as `0.0`, without applying a numerical-noise correction or changing individual atomic partial charges.
127
127
 
128
128
  ### Serialization (optional)
129
129
 
@@ -246,27 +246,42 @@ universe = simbox.universe
246
246
  ### GROMACS output *(experimental)*
247
247
 
248
248
  !!! warning
249
- GROMACS output is experimental. Unit conversions and dihedral mappings
250
- have been validated for OPLS-AA molecules generated by LigParGen, but
251
- other force fields or atom styles may need adjustments. Verify the output
252
- against a reference before production use.
249
+ GROMACS output assumes harmonic bonds and angles, four-term OPLS proper torsions, CVFF impropers, geometric Lennard-Jones mixing, and 0.5 scaling of both LJ and Coulomb 1-4 interactions. It is not a general force-field converter. Constraints, rigid-water settings, electrostatics methods, cutoffs, and simulation protocols must be chosen separately for your model.
253
250
 
254
- `write_gromacs()` produces three files in the directory of your choice:
251
+ `write_gromacs()` produces these files in the directory of your choice:
255
252
 
256
- - `{prefix}.gro` — atomic coordinates and box vectors
257
- - `{resname}.itp` — per-species include topology (one per unique species)
258
- - `{prefix}.top` — system topology with molecule counts matching the GRO file
253
+ - `{prefix}.gro` - atomic coordinates and box vectors, with standard GRO precision (0.001 nm).
254
+ - `{resname}.itp` - per-species molecule definitions, bonded terms, and explicit 1-4 pairs derived from shortest bond-graph distances.
255
+ - `{prefix}.top` - shared atom types before all molecule includes, OPLS defaults, and consecutive molecule counts matching the GRO coordinate order.
259
256
 
260
257
  ```python
261
258
  simbox.write_gromacs(prefix="system", outdir="gromacs_input/")
262
259
  ```
263
260
 
264
- Individual ITP files can also be written directly from a `Specie`:
261
+ The exporter validates force-field completeness before creating files. Missing or nonfinite parameters, unsupported fifth torsion coefficients, invalid CVFF parameters, and conflicting atom types raise `ValueError`. Species sharing a residue name must have identical atom order, charges, masses, and topology parameters; use distinct `name=` values for different models. Assembled residues must match their species definitions, including charges and atom types.
262
+
263
+ Individual ITP files include atom types by default:
265
264
 
266
265
  ```python
267
266
  water.write_gromacs_itp("water.itp")
268
267
  ```
269
268
 
269
+ For manual multi-species assembly, keep molecule files separate from shared atom-type definitions:
270
+
271
+ ```python
272
+ from mdinterface.io import write_gromacs_top
273
+
274
+ species = [water, sodium, chloride]
275
+ itp_files = []
276
+ for specie in species:
277
+ filename = f"{specie.resname}.itp"
278
+ specie.write_gromacs_itp(filename, include_atomtypes=False)
279
+ itp_files.append(filename)
280
+ write_gromacs_top(universe, itp_files, species=species)
281
+ ```
282
+
283
+ GROMACS preprocessing tests cover mixtures, chains, and rings. Single-point energy and force comparisons against LAMMPS cover harmonic terms, OPLS torsions, scaled 1-4 interactions, and CVFF impropers, plus water, benzene, ethanol, methane, dimers, mixed molecular systems, and a packed water/NaCl box. Strict packed-system comparisons use double-precision GROMACS to limit rounding of small force components. These checks do not validate arbitrary force fields or production dynamics. Compare engines at identical coordinates because standard GRO rounding can change bonded energies.
284
+
270
285
  ## Verbosity
271
286
 
272
287
  ```python
@@ -96,7 +96,7 @@ report = cation.parameterize(charge_correction="none")
96
96
  cation.validate_force_field()
97
97
  ```
98
98
 
99
- Coordinates stay unchanged. The charge audit reports the initial, raw refined and final totals, formal target, residual, correction per atom and number of junctions. Charge correction is always explicit: `"none"` is the default, and `"uniform"` distributes the residual over all atoms. `ligpargen=True` uses this same parameterization path.
99
+ Coordinates stay unchanged. The charge audit reports the initial, raw refined and final totals, formal target, residual, correction per atom and number of junctions. Charge correction is always explicit: `"none"` is the default, and `"uniform"` distributes the residual over all atoms. `ligpargen=True` uses this same parameterization path. Charge-audit totals and residuals within `1e-12 e` of zero are reported as `0.0`, without applying a numerical-noise correction or changing individual atomic partial charges.
100
100
 
101
101
  **Large molecules (>200 atoms):** `parameterize()` splits the molecule into chemically valid capped segments and refines the junctions. `segment_size` counts caps and cannot exceed 200. All segments and expanded junction snippets are checked before any LigParGen calculation. If the molecule cannot be split safely or a junction remains too large, parameterization fails with guidance rather than sending an oversized structure to BOSS. `snippet_radius` controls junction extent, and `cap_element` selects a neutral monovalent cap. All calculations and validation must succeed before parameters are applied. `refine_large_topology()` delegates to the same workflow with the same options and report.
102
102
 
@@ -6,7 +6,7 @@ Initially developed to construct electrolyte/electrode interfaces, it is also we
6
6
 
7
7
  """
8
8
 
9
- __version__ = '2.0.0'
9
+ __version__ = '2.0.1'
10
10
  __date__ = '28 Sep. 2026'
11
11
  __author__ = 'Fabrice Roncoroni'
12
12
  __all__ = ['SimCell', "Specie", "Polymer", "PackmolError", "LigParGenError"]
@@ -16,7 +16,7 @@ from typing import List, Optional, Union, Tuple, Any
16
16
 
17
17
  from mdinterface.utils.auxiliary import find_smallest_missing, label_to_element
18
18
  from mdinterface.io.lammpswriter import DATAWriter, write_lammps_coefficients
19
- from mdinterface.io.gromacswriter import write_gromacs_itp, write_gromacs_top
19
+ from mdinterface.io.gromacswriter import write_gromacs_itp, write_gromacs_top, _prepare_species
20
20
  from mdinterface.build.box import make_interface_slab, add_component
21
21
  from mdinterface.build.regions import FilledRegion
22
22
  from mdinterface.build.compartment import (
@@ -557,11 +557,11 @@ class SimCell:
557
557
 
558
558
  Produces (all written to *outdir*):
559
559
 
560
- - ``{prefix}.gro`` — atomic coordinates and box vectors.
561
- - ``{resname}.itp`` — per-species include topology (one per unique
562
- species), with ``[ atomtypes ]``, ``[ moleculetype ]``, ``[ atoms ]``,
563
- ``[ bonds ]``, ``[ angles ]``, and ``[ dihedrals ]`` sections.
564
- - ``{prefix}.top`` — system topology that includes the ITP files and
560
+ - ``{prefix}.gro`` - atomic coordinates and box vectors.
561
+ - ``{resname}.itp`` - per-species include topology (one per unique
562
+ species), with ``[ moleculetype ]``, ``[ atoms ]``,
563
+ ``[ bonds ]``, ``[ pairs ]``, ``[ angles ]``, and ``[ dihedrals ]`` sections.
564
+ - ``{prefix}.top`` - system topology that defines shared atom types, includes the ITP files, and
565
565
  lists molecule counts matching the GRO file.
566
566
 
567
567
  Parameters
@@ -579,16 +579,20 @@ class SimCell:
579
579
  RuntimeError
580
580
  If called before :meth:`build`.
581
581
 
582
+ ValueError
583
+ If parameters are incomplete or unsupported, species names collide
584
+ with different definitions, or assembled residues do not match.
585
+
582
586
  .. warning::
583
- GROMACS output is **experimental**. Unit conversions and dihedral
584
- mappings have been validated for OPLS-AA molecules generated by
585
- LigParGen, but other force fields or atom styles may need
586
- adjustments. Use with care and verify the output against a
587
- reference.
587
+ Experimental export for harmonic bonds/angles, four-term OPLS
588
+ torsions, CVFF impropers, geometric LJ mixing and 0.5 LJ/Coulomb
589
+ 1-4 scaling. Constraints and simulation settings are not generated.
588
590
  """
589
591
  if self._universe is None:
590
592
  raise RuntimeError("Call build() before write_gromacs().")
591
593
 
594
+ species = _prepare_species(self._all_species, self._universe)
595
+
592
596
  import os
593
597
  os.makedirs(outdir, exist_ok=True)
594
598
 
@@ -597,17 +601,13 @@ class SimCell:
597
601
  "write_gromacs is experimental -- verify output before production use."
598
602
  )
599
603
 
600
- # -- one ITP per unique species (keyed by resname) -----------------
601
604
  itp_basenames = []
602
- written = set()
603
- for sp in self._all_species:
604
- if sp.resname not in written:
605
- itp_name = f"{sp.resname}.itp"
606
- itp_path = os.path.join(outdir, itp_name)
607
- write_gromacs_itp(sp, filename=itp_path)
608
- itp_basenames.append(itp_name) # basename only for #include
609
- written.add(sp.resname)
610
- logger.info(" >> Species topology: %s", itp_path)
605
+ for sp in species:
606
+ itp_name = f"{sp.resname}.itp"
607
+ itp_path = os.path.join(outdir, itp_name)
608
+ write_gromacs_itp(sp, filename=itp_path, include_atomtypes=False)
609
+ itp_basenames.append(itp_name)
610
+ logger.info(" >> Species topology: %s", itp_path)
611
611
 
612
612
  # -- coordinates (.gro) --------------------------------------------
613
613
  gro_file = os.path.join(outdir, f"{prefix}.gro")
@@ -617,7 +617,7 @@ class SimCell:
617
617
  # -- system topology (.top) ----------------------------------------
618
618
  top_file = os.path.join(outdir, f"{prefix}.top")
619
619
  write_gromacs_top(self._universe, itp_basenames,
620
- filename=top_file, system_name=system_name)
620
+ filename=top_file, system_name=system_name, species=species)
621
621
  logger.info(" >> System topology: %s", top_file)
622
622
 
623
623
  def to_ase(self) -> ase.Atoms:
@@ -14,6 +14,8 @@ from mdinterface.build.polymerize import build_polymer
14
14
  from mdinterface.build.snippets import make_snippet, remap_snippet_topology
15
15
 
16
16
  # import random
17
+ from mdinterface.utils.auxiliary import _clean_charge
18
+
17
19
  import numpy as np
18
20
  import logging
19
21
 
@@ -238,7 +240,7 @@ class Polymer(Specie):
238
240
 
239
241
  # get charges and connection elements
240
242
  charges = self.charges
241
- initial_charge = float(charges.sum())
243
+ initial_charge = _clean_charge(charges.sum())
242
244
  pairs = self.junction_bonds
243
245
 
244
246
 
@@ -253,12 +255,12 @@ class Polymer(Specie):
253
255
  if not np.isfinite(charges).all():
254
256
  raise ValueError("Junction refinement returned nonfinite partial charges.")
255
257
  target = int(self.atoms.arrays["nominal_charge"].sum())
256
- refined_charge = float(charges.sum())
257
- residual = refined_charge - target
258
+ refined_charge = _clean_charge(charges.sum())
259
+ residual = _clean_charge(refined_charge - target)
258
260
  correction = 0.0
259
261
  logger.info("Polymer charge after junction refinement: %.8f e; formal: %d e; residual: %+.8f e",
260
- charges.sum(), target, residual)
261
- if charge_correction == "uniform":
262
+ refined_charge, target, residual)
263
+ if charge_correction == "uniform" and residual != 0:
262
264
  correction = -residual / len(charges)
263
265
  logger.info("Applying uniform partial-charge correction: %+.8f e per atom", correction)
264
266
  charges += correction
@@ -270,6 +272,6 @@ class Polymer(Specie):
270
272
  "refined_charge": refined_charge,
271
273
  "residual": residual,
272
274
  "correction_per_atom": correction,
273
- "final_charge": float(charges.sum()),
275
+ "final_charge": _clean_charge(charges.sum()),
274
276
  "junctions": len(pairs),
275
277
  }
@@ -955,7 +955,7 @@ class Specie(object):
955
955
 
956
956
  return
957
957
 
958
- def write_gromacs_itp(self, filename=None):
958
+ def write_gromacs_itp(self, filename=None, *, include_atomtypes=True):
959
959
  """
960
960
  Write a GROMACS include topology (.itp) file for this species.
961
961
 
@@ -966,12 +966,16 @@ class Specie(object):
966
966
  ----------
967
967
  filename : str, optional
968
968
  Output filename. Defaults to ``{resname}.itp``.
969
+ include_atomtypes : bool, default True
970
+ Include atom types in this file. For multi-species exports, use
971
+ False and supply ``species`` to ``write_gromacs_top`` to define
972
+ all atom types before all molecule definitions.
969
973
  """
970
974
  logger.warning(
971
975
  "write_gromacs_itp is experimental -- verify output before production use."
972
976
  )
973
977
  from mdinterface.io.gromacswriter import write_gromacs_itp
974
- write_gromacs_itp(self, filename=filename)
978
+ write_gromacs_itp(self, filename=filename, include_atomtypes=include_atomtypes)
975
979
 
976
980
  def validate_force_field(self):
977
981
  """Raise if molecular parameters required for classical export are missing.
@@ -13,6 +13,8 @@ from mdinterface.io.read import read_lammps_data_file
13
13
  import logging
14
14
  import math
15
15
  import copy
16
+ from mdinterface.utils.auxiliary import _clean_charge
17
+
16
18
  import numpy as np
17
19
  import os
18
20
  import ase
@@ -398,7 +400,7 @@ def refine_large_specie_topology(specie, snippet_radius=12, cap_element="H",
398
400
  if cap_element not in {"H", "F", "Cl", "Br", "I"}:
399
401
  raise ValueError("cap_element must be a neutral monovalent element.")
400
402
  staged, attributes = specie._parameterization_copy()
401
- initial = float(staged.charges.sum())
403
+ initial = _clean_charge(staged.charges.sum())
402
404
  target = staged._resolve_charge(None)
403
405
  if len(staged.atoms) <= segment_size:
404
406
  result, atom_types, bonds, angles, dihedrals, impropers = run_ligpargen(staged.atoms, charge=target)
@@ -412,13 +414,13 @@ def refine_large_specie_topology(specie, snippet_radius=12, cap_element="H",
412
414
  charges = staged.charges
413
415
  if not np.isfinite(charges).all():
414
416
  raise ValueError("Parameterization returned nonfinite partial charges.")
415
- refined = float(charges.sum())
416
- residual = refined - target
417
- correction = -residual / len(charges) if charge_correction == "uniform" else 0.0
417
+ refined = _clean_charge(charges.sum())
418
+ residual = _clean_charge(refined - target)
419
+ correction = -residual / len(charges) if charge_correction == "uniform" and residual != 0 else 0.0
418
420
  staged.atoms.set_initial_charges(charges + correction)
419
421
  report = dict(formal_charge=target, initial_charge=initial, refined_charge=refined,
420
422
  residual=residual, correction_per_atom=correction,
421
- final_charge=float(staged.charges.sum()), junctions=junctions)
423
+ final_charge=_clean_charge(staged.charges.sum()), junctions=junctions)
422
424
  staged.validate_force_field()
423
425
  specie._apply_parameterization(staged, attributes)
424
426
  logger.info("Parameterization charge audit: %s", report)
@@ -19,7 +19,10 @@ LAMMPS cvff improper dihedrals are mapped to ``funct=4``.
19
19
  """
20
20
 
21
21
  import logging
22
- import os
22
+ from itertools import groupby
23
+
24
+ import networkx as nx
25
+ import numpy as np
23
26
 
24
27
  logger = logging.getLogger(__name__)
25
28
 
@@ -65,15 +68,92 @@ _DIHS_HDR = "; {:>5} {:>5} {:>5} {:>5} {:>5} {:>10} {:>14} {:>3}\n"
65
68
  _DIHS_FMT = " {:>5d} {:>5d} {:>5d} {:>5d} {:>5d} {:>10.3f} {:>14.6f} {:>3d}\n"
66
69
 
67
70
 
68
- def write_gromacs_itp(specie, filename=None):
71
+ def _validate_specie(specie):
72
+ specie.validate_force_field()
73
+ if not len(specie.atoms):
74
+ raise ValueError("Cannot export an empty GROMACS molecule.")
75
+ masses = specie.atoms.get_masses()
76
+ if not np.isfinite(masses).all() or (masses <= 0).any():
77
+ raise ValueError("GROMACS atom masses must be finite and positive.")
78
+ for tid in set(specie.dihedrals[1]):
79
+ if specie._dtype[tid].values[4] is not None:
80
+ raise ValueError("GROMACS export requires four-term OPLS dihedrals; A5 is unsupported.")
81
+ for tid in set(specie.impropers[1]):
82
+ _, sign, multiplicity = specie._itype[tid].values
83
+ if sign not in (-1, 1) or multiplicity not in range(7):
84
+ raise ValueError("GROMACS export requires CVFF impropers with d = +/-1 and integer n = 0..6.")
85
+
86
+
87
+ def _atomtype_records(species):
88
+ records = {}
89
+ for specie in species:
90
+ labels, indices = specie.get_atom_types(return_index=True)
91
+ for label, index, atom in zip(labels, indices, specie.atoms):
92
+ parameter = specie._stype[index]
93
+ record = (atom.number, atom.mass, parameter.sig, parameter.eps)
94
+ if label in records and records[label] != record:
95
+ raise ValueError(f"Conflicting GROMACS atom type {label!r}.")
96
+ records[label] = record
97
+ return records
98
+
99
+
100
+ def _write_atomtypes(stream, records):
101
+ nw = max(4, max(map(len, records)))
102
+ stream.write("[ atomtypes ]\n")
103
+ stream.write(_at_hdr(nw).format(
104
+ "name", "atnum", "mass", "charge", "ptype", "sigma(nm)", "eps(kJ/mol)"))
105
+ for label, (number, mass, sigma, epsilon) in records.items():
106
+ stream.write(_at_fmt(nw).format(
107
+ label, number, mass, 0.0, "A", sigma * _ANG_TO_NM, epsilon * _KCAL_TO_KJ))
108
+ stream.write("\n")
109
+
110
+
111
+ def _species_signature(specie):
112
+ interactions = []
113
+ for name, key in (("bonds", "_btype"), ("angles", "_atype"),
114
+ ("dihedrals", "_dtype"), ("impropers", "_itype")):
115
+ indices, types = getattr(specie, name)
116
+ parameters = getattr(specie, key)
117
+ interactions.append(tuple((tuple(ids), tuple(parameters[tid].values))
118
+ for ids, tid in zip(indices, types)))
119
+ return (tuple(specie.get_atom_types()), tuple(specie._sids),
120
+ tuple(specie.atoms.numbers), tuple(specie.atoms.get_masses()),
121
+ tuple(specie.charges), tuple(_atomtype_records([specie]).items()),
122
+ tuple(interactions))
123
+
124
+
125
+ def _prepare_species(species, universe):
126
+ unique = {}
127
+ signatures = {}
128
+ for specie in species:
129
+ _validate_specie(specie)
130
+ signature = _species_signature(specie)
131
+ if specie.resname in signatures and signatures[specie.resname] != signature:
132
+ raise ValueError(f"Conflicting GROMACS molecule definitions for {specie.resname!r}; use distinct species names.")
133
+ unique[specie.resname] = specie
134
+ signatures[specie.resname] = signature
135
+ _atomtype_records(unique.values())
136
+ for residue in universe.residues:
137
+ if residue.resname not in unique:
138
+ raise ValueError(f"No GROMACS molecule definition for {residue.resname!r}.")
139
+ specie = unique[residue.resname]
140
+ atoms = residue.atoms
141
+ if (len(atoms) != len(specie.atoms)
142
+ or not np.array_equal(atoms.types, specie.get_atom_types())
143
+ or not np.allclose(atoms.charges, specie.charges, rtol=0, atol=1e-8)
144
+ or not np.allclose(atoms.masses, specie.atoms.get_masses(), rtol=0, atol=1e-8)):
145
+ raise ValueError(f"Assembled residue {residue.resname!r} does not match its GROMACS molecule definition.")
146
+ return list(unique.values())
147
+
148
+
149
+ def write_gromacs_itp(specie, filename=None, *, include_atomtypes=True):
69
150
  """
70
151
  Write a GROMACS include topology (.itp) file for a Specie.
71
152
 
72
153
  .. warning::
73
- GROMACS output is **experimental**. Unit conversions and dihedral
74
- mappings have been validated for OPLS-AA molecules generated by
75
- LigParGen, but other force fields or atom styles may need adjustments.
76
- Use with care and verify the output against a reference.
154
+ GROMACS output is experimental and assumes harmonic bonds and angles,
155
+ four-term OPLS proper torsions, CVFF impropers, geometric LJ mixing,
156
+ and 0.5 LJ/Coulomb 1-4 scaling. Constraints are not generated.
77
157
 
78
158
  Parameters
79
159
  ----------
@@ -81,14 +161,24 @@ def write_gromacs_itp(specie, filename=None):
81
161
  The molecular species to write.
82
162
  filename : str, optional
83
163
  Output filename. Defaults to ``{resname}.itp``.
164
+ include_atomtypes : bool, default True
165
+ Include atom-type definitions. For multi-species systems, set False
166
+ and pass the species to :func:`write_gromacs_top` so all atom types
167
+ precede all molecule definitions.
168
+
169
+ Raises
170
+ ------
171
+ ValueError
172
+ If parameters are incomplete, conflicting, or unsupported.
84
173
  """
174
+ _validate_specie(specie)
175
+ records = _atomtype_records([specie])
85
176
  if filename is None:
86
177
  filename = f"{specie.resname}.itp"
87
178
 
88
179
  masses = specie.atoms.get_masses()
89
180
  charges = specie.atoms.get_initial_charges()
90
- atnums = specie.atoms.get_atomic_numbers()
91
- atom_types, type_indexes = specie.get_atom_types(return_index=True)
181
+ atom_types = specie.get_atom_types()
92
182
 
93
183
  bond_idxs, bond_tids = specie.bonds
94
184
  angle_idxs, angle_tids = specie.angles
@@ -98,8 +188,6 @@ def write_gromacs_itp(specie, filename=None):
98
188
  # column width for name/type: fit the longest label, at least "name" (4)
99
189
  nw = max(max(len(t) for t in atom_types), 4)
100
190
 
101
- AT_HDR = _at_hdr(nw)
102
- AT_FMT = _at_fmt(nw)
103
191
  ATOMS_HDR = _atoms_hdr(nw)
104
192
  ATOMS_FMT = _atoms_fmt(nw)
105
193
 
@@ -107,23 +195,8 @@ def write_gromacs_itp(specie, filename=None):
107
195
  f.write("; GROMACS ITP file generated by mdinterface\n")
108
196
  f.write(f"; Molecule: {specie.resname}\n\n")
109
197
 
110
- # ------------------------------------------------------------------
111
- # [ atomtypes ] -- unique FF types with LJ parameters
112
- # ------------------------------------------------------------------
113
- f.write("[ atomtypes ]\n")
114
- f.write(AT_HDR.format(
115
- "name", "atnum", "mass", "charge", "ptype", "sigma(nm)", "eps(kJ/mol)"))
116
- seen = set()
117
- for atype, atnum, mass, tidx in zip(
118
- atom_types, atnums, masses, type_indexes):
119
- if atype in seen:
120
- continue
121
- seen.add(atype)
122
- stype = specie._stype[tidx]
123
- sig = (stype.sig * _ANG_TO_NM) if stype.sig is not None else 0.0
124
- eps = (stype.eps * _KCAL_TO_KJ) if stype.eps is not None else 0.0
125
- f.write(AT_FMT.format(atype, atnum, mass, 0.0, "A", sig, eps))
126
- f.write("\n")
198
+ if include_atomtypes:
199
+ _write_atomtypes(f, records)
127
200
 
128
201
  # ------------------------------------------------------------------
129
202
  # [ moleculetype ]
@@ -153,12 +226,22 @@ def write_gromacs_itp(specie, filename=None):
153
226
  f.write(_BONDS_HDR.format("ai", "aj", "funct", "b0(nm)", "kb(kJ/mol/nm2)"))
154
227
  for (ai, aj), tid in zip(bond_idxs, bond_tids):
155
228
  bond = specie._btype[tid]
156
- b0 = (bond.r0 * _ANG_TO_NM) if bond.r0 is not None else 0.0
229
+ b0 = bond.r0 * _ANG_TO_NM
157
230
  # LAMMPS: E = K*(r-r0)^2 | GROMACS: V = (kb/2)*(r-r0)^2 => kb = 2*K
158
- kb = (2 * bond.kr * _KCAL_TO_KJ / _ANG_TO_NM**2) if bond.kr is not None else 0.0
231
+ kb = 2 * bond.kr * _KCAL_TO_KJ / _ANG_TO_NM**2
159
232
  f.write(_BONDS_FMT.format(ai+1, aj+1, 1, b0, kb))
160
233
  f.write("\n")
161
234
 
235
+ graph = nx.Graph()
236
+ graph.add_edges_from(bond_idxs)
237
+ pairs = sorted((a, b) for a, distances in nx.all_pairs_shortest_path_length(graph, cutoff=3)
238
+ for b, distance in distances.items() if a < b and distance == 3)
239
+ if pairs:
240
+ f.write("[ pairs ]\n; ai aj funct\n")
241
+ for ai, aj in pairs:
242
+ f.write(f" {ai + 1} {aj + 1} 1\n")
243
+ f.write("\n")
244
+
162
245
  # ------------------------------------------------------------------
163
246
  # [ angles ]
164
247
  # ------------------------------------------------------------------
@@ -167,9 +250,9 @@ def write_gromacs_itp(specie, filename=None):
167
250
  f.write(_ANGS_HDR.format("ai", "aj", "ak", "funct", "th0(deg)", "cth(kJ/mol/rad2)"))
168
251
  for (ai, aj, ak), tid in zip(angle_idxs, angle_tids):
169
252
  angle = specie._atype[tid]
170
- th0 = angle.theta0 if angle.theta0 is not None else 0.0
253
+ th0 = angle.theta0
171
254
  # LAMMPS: E = K*(θ-θ0)^2 | GROMACS: V = (kt/2)*(θ-θ0)^2 => kt = 2*K
172
- cth = (2 * angle.kr * _KCAL_TO_KJ) if angle.kr is not None else 0.0
255
+ cth = 2 * angle.kr * _KCAL_TO_KJ
173
256
  f.write(_ANGS_FMT.format(ai+1, aj+1, ak+1, 1, th0, cth))
174
257
  f.write("\n")
175
258
 
@@ -195,10 +278,10 @@ def write_gromacs_itp(specie, filename=None):
195
278
  vals = dih.values # [K1, K2, K3, K4, K5?]
196
279
  for vi, n, phi0 in opls_map:
197
280
  K = vals[vi]
198
- if K is None or K == 0.0:
281
+ if K == 0.0:
199
282
  continue
200
283
  kphi = K / 2.0 * _KCAL_TO_KJ
201
- f.write(_DIHS_FMT.format(ai+1, aj+1, ak+1, al+1, 9, phi0, kphi, n))
284
+ f.write(_DIHS_FMT.format(ai+1, aj+1, ak+1, al+1, 9, phi0, kphi, int(n)))
202
285
  f.write("\n")
203
286
 
204
287
  # ------------------------------------------------------------------
@@ -217,14 +300,14 @@ def write_gromacs_itp(specie, filename=None):
217
300
  imp = specie._itype[tid]
218
301
  K, d, n = imp.values
219
302
  phi0 = 0.0 if d == 1 else 180.0
220
- kphi = (K * _KCAL_TO_KJ) if K is not None else 0.0
221
- f.write(_DIHS_FMT.format(ai+1, aj+1, ak+1, al+1, 4, phi0, kphi, n))
303
+ kphi = K * _KCAL_TO_KJ
304
+ f.write(_DIHS_FMT.format(ai+1, aj+1, ak+1, al+1, 4, phi0, kphi, int(n)))
222
305
  f.write("\n")
223
306
 
224
307
 
225
308
 
226
309
  def write_gromacs_top(universe, itp_files, filename="system.top",
227
- system_name="MD System"):
310
+ system_name="MD System", *, species=None):
228
311
  """
229
312
  Write a GROMACS system topology (.top) file.
230
313
 
@@ -239,13 +322,16 @@ def write_gromacs_top(universe, itp_files, filename="system.top",
239
322
  Output filename. Default ``"system.top"``.
240
323
  system_name : str, optional
241
324
  Title written in the ``[ system ]`` section.
325
+ species : sequence of Specie, optional
326
+ Species whose atom types are written before the molecule includes.
327
+ These ITP files must be written with ``include_atomtypes=False``.
328
+ Also validates residue identity against the assembled universe.
242
329
  """
243
- # Preserve the order molecules appear in the GRO/universe so that
244
- # [ molecules ] matches the coordinate file exactly.
245
- seen = {}
246
- for res in universe.residues:
247
- name = res.resname
248
- seen[name] = seen.get(name, 0) + 1
330
+ records = None
331
+ if species is not None:
332
+ records = _atomtype_records(_prepare_species(species, universe))
333
+ runs = [(name, sum(1 for _ in group))
334
+ for name, group in groupby(universe.residues.resnames)]
249
335
 
250
336
  with open(filename, "w") as f:
251
337
  f.write("; GROMACS topology file generated by mdinterface\n\n")
@@ -255,6 +341,9 @@ def write_gromacs_top(universe, itp_files, filename="system.top",
255
341
  f.write("; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ\n")
256
342
  f.write(" 1 3 yes 0.5 0.5\n\n")
257
343
 
344
+ if records:
345
+ _write_atomtypes(f, records)
346
+
258
347
  for itp in itp_files:
259
348
  f.write(f'#include "{itp}"\n')
260
349
  f.write("\n")
@@ -264,6 +353,6 @@ def write_gromacs_top(universe, itp_files, filename="system.top",
264
353
 
265
354
  f.write("[ molecules ]\n")
266
355
  f.write("; {:<14} {:>6}\n".format("molecule", "nmols"))
267
- for resname, count in seen.items():
356
+ for resname, count in runs:
268
357
  f.write(f" {resname:<14s} {count:>6d}\n")
269
358
 
@@ -192,3 +192,9 @@ def round_list_to_sum(lst, target_sum, decimals=3):
192
192
  difference -= adjustment
193
193
 
194
194
  return rounded_list
195
+
196
+
197
+ def _clean_charge(value):
198
+ """Normalize charge-report roundoff within 1e-12 e without rounding charges."""
199
+ value = float(value)
200
+ return 0.0 if abs(value) <= 1e-12 else value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdinterface
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Build Interface Systems for Molecular Dynamics Simulations
5
5
  Author-email: Fabrice Roncoroni <fabrice.roncoroni@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -107,6 +107,7 @@ tests/test_chemistry.py
107
107
  tests/test_compartment.py
108
108
  tests/test_database.py
109
109
  tests/test_distribution_contents.py
110
+ tests/test_gromacswriter.py
110
111
  tests/test_imports.py
111
112
  tests/test_ligpargen.py
112
113
  tests/test_parameterization.py
@@ -114,4 +115,8 @@ tests/test_regions.py
114
115
  tests/test_solvent.py
115
116
  tests/test_specie.py
116
117
  tests/test_structure_metadata.py
117
- tests/test_topology.py
118
+ tests/test_topology.py
119
+ tests/data/gromacs/README.md
120
+ tests/data/gromacs/benzene.json
121
+ tests/data/gromacs/ethanol.json
122
+ tests/data/gromacs/methane.json