reaxkit 1.0.0__py3-none-any.whl
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.
- reaxkit/__init__.py +0 -0
- reaxkit/analysis/__init__.py +0 -0
- reaxkit/analysis/composed/RDF_analyzer.py +560 -0
- reaxkit/analysis/composed/__init__.py +0 -0
- reaxkit/analysis/composed/connectivity_analyzer.py +706 -0
- reaxkit/analysis/composed/coordination_analyzer.py +144 -0
- reaxkit/analysis/composed/electrostatics_analyzer.py +687 -0
- reaxkit/analysis/per_file/__init__.py +0 -0
- reaxkit/analysis/per_file/control_analyzer.py +165 -0
- reaxkit/analysis/per_file/eregime_analyzer.py +108 -0
- reaxkit/analysis/per_file/ffield_analyzer.py +305 -0
- reaxkit/analysis/per_file/fort13_analyzer.py +79 -0
- reaxkit/analysis/per_file/fort57_analyzer.py +106 -0
- reaxkit/analysis/per_file/fort73_analyzer.py +61 -0
- reaxkit/analysis/per_file/fort74_analyzer.py +65 -0
- reaxkit/analysis/per_file/fort76_analyzer.py +191 -0
- reaxkit/analysis/per_file/fort78_analyzer.py +154 -0
- reaxkit/analysis/per_file/fort79_analyzer.py +83 -0
- reaxkit/analysis/per_file/fort7_analyzer.py +393 -0
- reaxkit/analysis/per_file/fort99_analyzer.py +411 -0
- reaxkit/analysis/per_file/molfra_analyzer.py +359 -0
- reaxkit/analysis/per_file/params_analyzer.py +258 -0
- reaxkit/analysis/per_file/summary_analyzer.py +84 -0
- reaxkit/analysis/per_file/trainset_analyzer.py +84 -0
- reaxkit/analysis/per_file/vels_analyzer.py +95 -0
- reaxkit/analysis/per_file/xmolout_analyzer.py +528 -0
- reaxkit/cli.py +181 -0
- reaxkit/count_loc.py +276 -0
- reaxkit/data/alias.yaml +89 -0
- reaxkit/data/constants.yaml +27 -0
- reaxkit/data/reaxff_input_files_contents.yaml +186 -0
- reaxkit/data/reaxff_output_files_contents.yaml +301 -0
- reaxkit/data/units.yaml +38 -0
- reaxkit/help/__init__.py +0 -0
- reaxkit/help/help_index_loader.py +531 -0
- reaxkit/help/introspection_utils.py +131 -0
- reaxkit/io/__init__.py +0 -0
- reaxkit/io/base_handler.py +165 -0
- reaxkit/io/generators/__init__.py +0 -0
- reaxkit/io/generators/control_generator.py +123 -0
- reaxkit/io/generators/eregime_generator.py +341 -0
- reaxkit/io/generators/geo_generator.py +967 -0
- reaxkit/io/generators/trainset_generator.py +1758 -0
- reaxkit/io/generators/tregime_generator.py +113 -0
- reaxkit/io/generators/vregime_generator.py +164 -0
- reaxkit/io/generators/xmolout_generator.py +304 -0
- reaxkit/io/handlers/__init__.py +0 -0
- reaxkit/io/handlers/control_handler.py +209 -0
- reaxkit/io/handlers/eregime_handler.py +122 -0
- reaxkit/io/handlers/ffield_handler.py +812 -0
- reaxkit/io/handlers/fort13_handler.py +123 -0
- reaxkit/io/handlers/fort57_handler.py +143 -0
- reaxkit/io/handlers/fort73_handler.py +145 -0
- reaxkit/io/handlers/fort74_handler.py +155 -0
- reaxkit/io/handlers/fort76_handler.py +195 -0
- reaxkit/io/handlers/fort78_handler.py +142 -0
- reaxkit/io/handlers/fort79_handler.py +227 -0
- reaxkit/io/handlers/fort7_handler.py +264 -0
- reaxkit/io/handlers/fort99_handler.py +128 -0
- reaxkit/io/handlers/geo_handler.py +224 -0
- reaxkit/io/handlers/molfra_handler.py +184 -0
- reaxkit/io/handlers/params_handler.py +137 -0
- reaxkit/io/handlers/summary_handler.py +135 -0
- reaxkit/io/handlers/trainset_handler.py +658 -0
- reaxkit/io/handlers/vels_handler.py +293 -0
- reaxkit/io/handlers/xmolout_handler.py +174 -0
- reaxkit/utils/__init__.py +0 -0
- reaxkit/utils/alias.py +219 -0
- reaxkit/utils/cache.py +77 -0
- reaxkit/utils/constants.py +75 -0
- reaxkit/utils/equation_of_states.py +96 -0
- reaxkit/utils/exceptions.py +27 -0
- reaxkit/utils/frame_utils.py +175 -0
- reaxkit/utils/log.py +43 -0
- reaxkit/utils/media/__init__.py +0 -0
- reaxkit/utils/media/convert.py +90 -0
- reaxkit/utils/media/make_video.py +91 -0
- reaxkit/utils/media/plotter.py +812 -0
- reaxkit/utils/numerical/__init__.py +0 -0
- reaxkit/utils/numerical/extrema_finder.py +96 -0
- reaxkit/utils/numerical/moving_average.py +103 -0
- reaxkit/utils/numerical/numerical_calcs.py +75 -0
- reaxkit/utils/numerical/signal_ops.py +135 -0
- reaxkit/utils/path.py +55 -0
- reaxkit/utils/units.py +104 -0
- reaxkit/webui/__init__.py +0 -0
- reaxkit/webui/app.py +0 -0
- reaxkit/webui/components.py +0 -0
- reaxkit/webui/layouts.py +0 -0
- reaxkit/webui/utils.py +0 -0
- reaxkit/workflows/__init__.py +0 -0
- reaxkit/workflows/composed/__init__.py +0 -0
- reaxkit/workflows/composed/coordination_workflow.py +393 -0
- reaxkit/workflows/composed/electrostatics_workflow.py +587 -0
- reaxkit/workflows/composed/xmolout_fort7_workflow.py +343 -0
- reaxkit/workflows/meta/__init__.py +0 -0
- reaxkit/workflows/meta/help_workflow.py +136 -0
- reaxkit/workflows/meta/introspection_workflow.py +235 -0
- reaxkit/workflows/meta/make_video_workflow.py +61 -0
- reaxkit/workflows/meta/plotter_workflow.py +601 -0
- reaxkit/workflows/per_file/__init__.py +0 -0
- reaxkit/workflows/per_file/control_workflow.py +110 -0
- reaxkit/workflows/per_file/eregime_workflow.py +267 -0
- reaxkit/workflows/per_file/ffield_workflow.py +390 -0
- reaxkit/workflows/per_file/fort13_workflow.py +86 -0
- reaxkit/workflows/per_file/fort57_workflow.py +137 -0
- reaxkit/workflows/per_file/fort73_workflow.py +151 -0
- reaxkit/workflows/per_file/fort74_workflow.py +88 -0
- reaxkit/workflows/per_file/fort76_workflow.py +188 -0
- reaxkit/workflows/per_file/fort78_workflow.py +135 -0
- reaxkit/workflows/per_file/fort79_workflow.py +314 -0
- reaxkit/workflows/per_file/fort7_workflow.py +592 -0
- reaxkit/workflows/per_file/fort83_workflow.py +60 -0
- reaxkit/workflows/per_file/fort99_workflow.py +223 -0
- reaxkit/workflows/per_file/geo_workflow.py +554 -0
- reaxkit/workflows/per_file/molfra_workflow.py +577 -0
- reaxkit/workflows/per_file/params_workflow.py +135 -0
- reaxkit/workflows/per_file/summary_workflow.py +161 -0
- reaxkit/workflows/per_file/trainset_workflow.py +356 -0
- reaxkit/workflows/per_file/tregime_workflow.py +79 -0
- reaxkit/workflows/per_file/vels_workflow.py +309 -0
- reaxkit/workflows/per_file/vregime_workflow.py +75 -0
- reaxkit/workflows/per_file/xmolout_workflow.py +678 -0
- reaxkit-1.0.0.dist-info/METADATA +128 -0
- reaxkit-1.0.0.dist-info/RECORD +130 -0
- reaxkit-1.0.0.dist-info/WHEEL +5 -0
- reaxkit-1.0.0.dist-info/entry_points.txt +2 -0
- reaxkit-1.0.0.dist-info/licenses/AUTHORS.md +20 -0
- reaxkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- reaxkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geometry and structure generators for ReaxFF simulations.
|
|
3
|
+
|
|
4
|
+
This module provides high-level utilities for creating, converting,
|
|
5
|
+
and manipulating atomic structures used in ReaxFF workflows.
|
|
6
|
+
|
|
7
|
+
Capabilities include:
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
- Conversion of XYZ files to GEO/XTLGRF format with full ReaxFF-compliant
|
|
11
|
+
formatting.
|
|
12
|
+
- Structure I/O wrappers based on ASE (CIF, POSCAR, VASP, XYZ, etc.).
|
|
13
|
+
- Surface slab construction from bulk crystals.
|
|
14
|
+
- Supercell generation via repetition or full 3×3 transformation matrices.
|
|
15
|
+
- Hexagonal → orthorhombic cell transformations.
|
|
16
|
+
- A Python reimplementation of the Fortran `place2` algorithm for random
|
|
17
|
+
molecular packing with periodic boundary conditions.
|
|
18
|
+
- Programmatic insertion of sample restraint blocks into GEO files.
|
|
19
|
+
|
|
20
|
+
Generators in this module:
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
- operate deterministically on input structures
|
|
24
|
+
- write files or return ASE Atoms objects
|
|
25
|
+
- do not perform simulation, analysis, or parsing of ReaxFF output
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Iterable, Literal, Optional, Tuple, List, Dict, Any, Sequence
|
|
33
|
+
|
|
34
|
+
import pandas as pd
|
|
35
|
+
import numpy as np
|
|
36
|
+
from ase import Atoms
|
|
37
|
+
from ase.geometry import cellpar_to_cell
|
|
38
|
+
from ase.io import read, write
|
|
39
|
+
from ase.build import surface as ase_surface, make_supercell as ase_make_supercell
|
|
40
|
+
|
|
41
|
+
SortKey = Literal["x", "y", "z", "atom_type"]
|
|
42
|
+
TerminationSide = Literal["top", "bottom"]
|
|
43
|
+
Axis = Literal[0, 1, 2]
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Core reader for XYZ
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _read_xyz(xyz_path: str | Path) -> Tuple[str, pd.DataFrame]:
|
|
50
|
+
"""
|
|
51
|
+
Read a simple XYZ file and return (descriptor, atoms_df).
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
xyz_path : str or Path
|
|
56
|
+
Path to the .xyz file.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
descriptor : str
|
|
61
|
+
Descriptor derived from the second line (first token).
|
|
62
|
+
atoms_df : pandas.DataFrame
|
|
63
|
+
Columns: ["atom_type", "x", "y", "z"]
|
|
64
|
+
"""
|
|
65
|
+
xyz_path = Path(xyz_path)
|
|
66
|
+
|
|
67
|
+
with xyz_path.open("r") as fh:
|
|
68
|
+
# First non-empty line: number of atoms
|
|
69
|
+
first = ""
|
|
70
|
+
while first == "":
|
|
71
|
+
first = fh.readline()
|
|
72
|
+
if not first:
|
|
73
|
+
raise ValueError(f"❌ {xyz_path} appears to be empty.")
|
|
74
|
+
first = first.strip()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
nat_expected = int(first.split()[0])
|
|
78
|
+
except ValueError:
|
|
79
|
+
raise ValueError(f"❌ First line of {xyz_path} is not a valid atom count: {first!r}")
|
|
80
|
+
|
|
81
|
+
# Second non-empty line: descriptor line (first token used)
|
|
82
|
+
second = ""
|
|
83
|
+
while second == "":
|
|
84
|
+
second = fh.readline()
|
|
85
|
+
if not second:
|
|
86
|
+
raise ValueError(f"❌ {xyz_path} ended before descriptor line.")
|
|
87
|
+
second = second.strip()
|
|
88
|
+
|
|
89
|
+
descriptor_tokens = second.split()
|
|
90
|
+
descriptor = descriptor_tokens[0] if descriptor_tokens else ""
|
|
91
|
+
|
|
92
|
+
# Remaining lines: atoms
|
|
93
|
+
records: List[Dict[str, Any]] = []
|
|
94
|
+
for line in fh:
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
if not stripped:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
parts = stripped.split()
|
|
100
|
+
if len(parts) < 4:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
symbol = parts[0]
|
|
104
|
+
|
|
105
|
+
# NEW: skip non-atom lines (VEC1, VEC2, etc.)
|
|
106
|
+
if symbol.upper().startswith("VEC"):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# You can also skip any non-alphabetic symbol:
|
|
110
|
+
if not symbol[0].isalpha():
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
x, y, z = map(float, parts[1:4])
|
|
115
|
+
except ValueError:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
records.append({
|
|
119
|
+
"atom_type": symbol,
|
|
120
|
+
"x": x,
|
|
121
|
+
"y": y,
|
|
122
|
+
"z": z,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
atoms_df = pd.DataFrame(records, columns=["atom_type", "x", "y", "z"])
|
|
126
|
+
|
|
127
|
+
if len(atoms_df) != nat_expected:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"❌ Number of atoms in XYZ header ({nat_expected}) "
|
|
130
|
+
f"does not match coordinate lines found ({len(atoms_df)})."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return descriptor, atoms_df
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Sorting helper
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _sort_atoms(
|
|
141
|
+
atoms: pd.DataFrame,
|
|
142
|
+
sort_by: Optional[SortKey] = None,
|
|
143
|
+
ascending: bool = True,
|
|
144
|
+
) -> pd.DataFrame:
|
|
145
|
+
"""
|
|
146
|
+
Optionally sort atoms by coordinate or atom type.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
atoms : DataFrame
|
|
151
|
+
Columns ["atom_type", "x", "y", "z"].
|
|
152
|
+
sort_by : {"x", "y", "z", "atom_type"} or None, optional
|
|
153
|
+
Which column to sort by. If None, no sorting.
|
|
154
|
+
ascending : bool, default True
|
|
155
|
+
Sort direction.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
DataFrame
|
|
160
|
+
Sorted (or unchanged) DataFrame.
|
|
161
|
+
"""
|
|
162
|
+
if sort_by is None:
|
|
163
|
+
return atoms
|
|
164
|
+
|
|
165
|
+
if sort_by not in atoms.columns:
|
|
166
|
+
raise ValueError(f"❌ sort_by must be one of {list(atoms.columns)!r}, got {sort_by!r}.")
|
|
167
|
+
|
|
168
|
+
return atoms.sort_values(by=sort_by, ascending=ascending).reset_index(drop=True)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Writer for GEO / XTLGRF
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _format_crystx(
|
|
176
|
+
box_lengths: Iterable[float],
|
|
177
|
+
box_angles: Iterable[float],
|
|
178
|
+
) -> str:
|
|
179
|
+
"""
|
|
180
|
+
Format the CRYSTX line:
|
|
181
|
+
|
|
182
|
+
CRYSTX a b c alpha beta gamma
|
|
183
|
+
|
|
184
|
+
with each value as f11.5.
|
|
185
|
+
"""
|
|
186
|
+
a, b, c = list(box_lengths)
|
|
187
|
+
alpha, beta, gamma = list(box_angles)
|
|
188
|
+
|
|
189
|
+
nums = (a, b, c, alpha, beta, gamma)
|
|
190
|
+
return "CRYSTX" + "".join(f"{v:11.5f}" for v in nums)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _format_hetatm_line(atom_id: int, atom_type: str, x: float, y: float, z: float) -> str:
|
|
194
|
+
at2 = atom_type.strip()[:2] # a2 field
|
|
195
|
+
at5 = atom_type.strip()[:5] # a5 field
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
"HETATM" # literal
|
|
199
|
+
f" {atom_id:5d}" # 1x, i5
|
|
200
|
+
f" {at2:2s}" # 1x, a2
|
|
201
|
+
" " # 3x
|
|
202
|
+
" " # 1x
|
|
203
|
+
" " # 3x
|
|
204
|
+
" " # 1x
|
|
205
|
+
" " # 1x
|
|
206
|
+
" " # 1x
|
|
207
|
+
" " # 5x
|
|
208
|
+
f"{x:10.5f}{y:10.5f}{z:10.5f}" # 3f10.5
|
|
209
|
+
f" {at5:5s}" # 1x, a5
|
|
210
|
+
f"{0:3d}{0:2d}" # i3, i2 (0 0)
|
|
211
|
+
f" {0.0:8.5f}" # 1x, f8.5 (0.00000)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Public API
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def xtob(
|
|
220
|
+
xyz_file: str | Path,
|
|
221
|
+
geo_file: str | Path = "geo",
|
|
222
|
+
box_lengths: Iterable[float] = (1.0, 1.0, 1.0),
|
|
223
|
+
box_angles: Iterable[float] = (90.0, 90.0, 90.0),
|
|
224
|
+
sort_by: Optional[SortKey] = None,
|
|
225
|
+
ascending: bool = True,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Convert an XYZ file to ReaxFF GEO/XTLGRF format.
|
|
229
|
+
|
|
230
|
+
This function reads a simple XYZ structure, optionally sorts atoms,
|
|
231
|
+
assigns sequential atom IDs, and writes a fully formatted GEO file
|
|
232
|
+
compatible with ReaxFF.
|
|
233
|
+
|
|
234
|
+
Works on
|
|
235
|
+
---
|
|
236
|
+
XYZ structure files
|
|
237
|
+
|
|
238
|
+
Parameters
|
|
239
|
+
----------
|
|
240
|
+
xyz_file : str | Path
|
|
241
|
+
Input XYZ file.
|
|
242
|
+
geo_file : str | Path, optional
|
|
243
|
+
Output GEO file path (default: ``"geo"``).
|
|
244
|
+
box_lengths : iterable of float, optional
|
|
245
|
+
Periodic cell lengths (a, b, c) in Å.
|
|
246
|
+
box_angles : iterable of float, optional
|
|
247
|
+
Periodic cell angles (alpha, beta, gamma) in degrees.
|
|
248
|
+
sort_by : {"x","y","z","atom_type"} or None, optional
|
|
249
|
+
If provided, sort atoms before writing.
|
|
250
|
+
ascending : bool, default True
|
|
251
|
+
Sort direction.
|
|
252
|
+
|
|
253
|
+
Returns
|
|
254
|
+
-------
|
|
255
|
+
None
|
|
256
|
+
Writes the GEO file to disk.
|
|
257
|
+
|
|
258
|
+
Examples
|
|
259
|
+
---
|
|
260
|
+
>>> xtob("structure.xyz", "geo", box_lengths=(10,10,10))
|
|
261
|
+
"""
|
|
262
|
+
xyz_file = Path(xyz_file)
|
|
263
|
+
geo_file = Path(geo_file)
|
|
264
|
+
|
|
265
|
+
descriptor, atoms = _read_xyz(xyz_file)
|
|
266
|
+
|
|
267
|
+
# Normalize box inputs
|
|
268
|
+
box_lengths = list(box_lengths)
|
|
269
|
+
box_angles = list(box_angles)
|
|
270
|
+
|
|
271
|
+
if len(box_lengths) != 3 or len(box_angles) != 3:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"❌ box_lengths and box_angles must each contain exactly 3 values "
|
|
274
|
+
"(a, b, c) and (alpha, beta, gamma)."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Sort if requested
|
|
278
|
+
atoms_sorted = _sort_atoms(atoms, sort_by=sort_by, ascending=ascending)
|
|
279
|
+
|
|
280
|
+
# Reassign atom IDs after sorting
|
|
281
|
+
atoms_sorted = atoms_sorted.reset_index(drop=True)
|
|
282
|
+
atoms_sorted["atom_id"] = atoms_sorted.index + 1
|
|
283
|
+
|
|
284
|
+
# Prepare REMARK lines
|
|
285
|
+
sort_remark = None
|
|
286
|
+
if sort_by is not None:
|
|
287
|
+
direction = "ascending" if ascending else "descending"
|
|
288
|
+
if sort_by == "atom_type":
|
|
289
|
+
coord_label = "atom type"
|
|
290
|
+
else:
|
|
291
|
+
coord_label = f"{sort_by}-coordinate"
|
|
292
|
+
sort_remark = f"REMARK Structure sorted by {coord_label} ({direction})"
|
|
293
|
+
|
|
294
|
+
# Write GEO file
|
|
295
|
+
with geo_file.open("w") as fh:
|
|
296
|
+
# Header
|
|
297
|
+
fh.write("XTLGRF 200\n")
|
|
298
|
+
fh.write(f"DESCRP {descriptor}\n")
|
|
299
|
+
fh.write("REMARK .bgf-file generated by xtob-python\n")
|
|
300
|
+
if sort_remark:
|
|
301
|
+
fh.write(f"{sort_remark}\n")
|
|
302
|
+
fh.write(_format_crystx(box_lengths, box_angles) + "\n")
|
|
303
|
+
fh.write(
|
|
304
|
+
"FORMAT ATOM (a6,1x,i5,1x,a5,1x,a3,1x,a1,1x,a5,3f10.5,1x,a5,i3,i2,1x,f8.5)\n"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Atoms
|
|
308
|
+
for row in atoms_sorted.itertuples(index=False):
|
|
309
|
+
line = _format_hetatm_line(
|
|
310
|
+
atom_id=row.atom_id,
|
|
311
|
+
atom_type=row.atom_type,
|
|
312
|
+
x=row.x,
|
|
313
|
+
y=row.y,
|
|
314
|
+
z=row.z,
|
|
315
|
+
)
|
|
316
|
+
fh.write(line + "\n")
|
|
317
|
+
|
|
318
|
+
# Footer
|
|
319
|
+
fh.write("END\n")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
__all__ = ["xtob"]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# 1) Read structure (CIF, POSCAR, etc.)
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def read_structure(
|
|
330
|
+
path: str | Path,
|
|
331
|
+
format: Optional[str] = None,
|
|
332
|
+
index: int | str = 0,
|
|
333
|
+
) -> Atoms:
|
|
334
|
+
"""
|
|
335
|
+
Read a structure file using ASE.
|
|
336
|
+
|
|
337
|
+
Works on
|
|
338
|
+
---
|
|
339
|
+
ASE-supported structure formats (CIF, POSCAR, XYZ, etc.)
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
path : str | Path
|
|
344
|
+
Structure file path (CIF, POSCAR, XYZ, etc.).
|
|
345
|
+
format : str, optional
|
|
346
|
+
ASE format string. If None, ASE infers from file extension.
|
|
347
|
+
index : int | str, default 0
|
|
348
|
+
Image index if the file contains multiple structures.
|
|
349
|
+
|
|
350
|
+
Returns
|
|
351
|
+
-------
|
|
352
|
+
ase.Atoms
|
|
353
|
+
Loaded structure.
|
|
354
|
+
|
|
355
|
+
Examples
|
|
356
|
+
---
|
|
357
|
+
>>> atoms = read_structure("AlN.cif")
|
|
358
|
+
"""
|
|
359
|
+
path = Path(path)
|
|
360
|
+
return read(path, format=format, index=index)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# 2) Generate a surface slab
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def build_surface(
|
|
368
|
+
bulk: Atoms,
|
|
369
|
+
miller: Tuple[int, int, int],
|
|
370
|
+
layers: int,
|
|
371
|
+
vacuum: float = 15.0,
|
|
372
|
+
center: bool = True,
|
|
373
|
+
) -> Atoms:
|
|
374
|
+
"""
|
|
375
|
+
Build a surface slab from a bulk structure using Miller indices.
|
|
376
|
+
|
|
377
|
+
Works on
|
|
378
|
+
---
|
|
379
|
+
ASE Atoms bulk structures
|
|
380
|
+
|
|
381
|
+
Parameters
|
|
382
|
+
----------
|
|
383
|
+
bulk : ase.Atoms
|
|
384
|
+
Bulk crystal structure.
|
|
385
|
+
miller : (h, k, l)
|
|
386
|
+
Miller indices defining the surface orientation.
|
|
387
|
+
layers : int
|
|
388
|
+
Number of atomic layers.
|
|
389
|
+
vacuum : float, default 15.0
|
|
390
|
+
Vacuum thickness along the surface normal (Å).
|
|
391
|
+
center : bool, default True
|
|
392
|
+
Center the slab along the surface-normal direction.
|
|
393
|
+
|
|
394
|
+
Returns
|
|
395
|
+
-------
|
|
396
|
+
ase.Atoms
|
|
397
|
+
Surface slab structure.
|
|
398
|
+
|
|
399
|
+
Examples
|
|
400
|
+
---
|
|
401
|
+
>>> slab = build_surface(bulk, (0,0,1), layers=6)
|
|
402
|
+
"""
|
|
403
|
+
slab = ase_surface(bulk, miller, layers=layers, vacuum=vacuum)
|
|
404
|
+
if center:
|
|
405
|
+
# Surface normal is along cell c (axis 2) by ASE convention
|
|
406
|
+
slab.center(axis=2)
|
|
407
|
+
return slab
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# 3) Transform: generate supercells / apply transformation matrix
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def make_supercell(
|
|
415
|
+
atoms: Atoms,
|
|
416
|
+
transform: Iterable[Iterable[int]] | Tuple[int, int, int],
|
|
417
|
+
) -> Atoms:
|
|
418
|
+
"""
|
|
419
|
+
Generate a supercell or apply a lattice transformation.
|
|
420
|
+
|
|
421
|
+
Works on
|
|
422
|
+
---
|
|
423
|
+
ASE Atoms structures
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
atoms : ase.Atoms
|
|
428
|
+
Input structure.
|
|
429
|
+
transform : (nx, ny, nz) tuple or 3×3 integer matrix
|
|
430
|
+
Repetition factors or full transformation matrix.
|
|
431
|
+
|
|
432
|
+
Returns
|
|
433
|
+
-------
|
|
434
|
+
ase.Atoms
|
|
435
|
+
Transformed structure.
|
|
436
|
+
|
|
437
|
+
Examples
|
|
438
|
+
---
|
|
439
|
+
>>> sc = make_supercell(atoms, (2,2,1))
|
|
440
|
+
"""
|
|
441
|
+
# Simple repetition (e.g., (4, 4, 1))
|
|
442
|
+
if isinstance(transform, tuple) and len(transform) == 3:
|
|
443
|
+
return atoms * transform # ASE overloads * for repetition
|
|
444
|
+
|
|
445
|
+
# Full 3×3 integer matrix
|
|
446
|
+
P = np.array(transform, dtype=int)
|
|
447
|
+
if P.shape != (3, 3):
|
|
448
|
+
raise ValueError(
|
|
449
|
+
"transform must be either (nx, ny, nz) or a 3x3 integer matrix."
|
|
450
|
+
)
|
|
451
|
+
return ase_make_supercell(atoms, P)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
# 5) Convert / write to other formats (e.g., XYZ)
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
def write_structure(
|
|
459
|
+
atoms: Atoms,
|
|
460
|
+
path: str | Path,
|
|
461
|
+
format: Optional[str] = None,
|
|
462
|
+
comment: Optional[str] = None,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""
|
|
465
|
+
Write a structure to file in any ASE-supported format.
|
|
466
|
+
|
|
467
|
+
Works on
|
|
468
|
+
---
|
|
469
|
+
ASE Atoms objects
|
|
470
|
+
|
|
471
|
+
Parameters
|
|
472
|
+
----------
|
|
473
|
+
atoms : ase.Atoms
|
|
474
|
+
Structure to write.
|
|
475
|
+
path : str or Path
|
|
476
|
+
Output file path. Extension is used to guess format if `format` is None.
|
|
477
|
+
format : str, optional
|
|
478
|
+
This is based on ASE format support as explained here:
|
|
479
|
+
https://ase-lib.org/ase/io/io.html#ase.io.write
|
|
480
|
+
ASE format string (e.g., "xyz", "cif", "vasp", "xsf"). If None,
|
|
481
|
+
ASE guesses from the file extension.
|
|
482
|
+
|
|
483
|
+
Returns
|
|
484
|
+
-------
|
|
485
|
+
None
|
|
486
|
+
|
|
487
|
+
Examples
|
|
488
|
+
---
|
|
489
|
+
>>> write_structure(atoms, "out.xyz")
|
|
490
|
+
"""
|
|
491
|
+
path = Path(path)
|
|
492
|
+
write(path, atoms, format=format, comment=comment)
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# convert a hexagonal cell (90°, 90°, 120°) into an orthorhombic (90°, 90°, 90°) cell
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def orthogonalize_hexagonal_cell(atoms: Atoms) -> Atoms:
|
|
499
|
+
"""
|
|
500
|
+
Convert a hexagonal unit cell into an orthorhombic cell.
|
|
501
|
+
|
|
502
|
+
Convert a hexagonal cell (90°, 90°, 120°) into an orthorhombic cell (90°, 90°, 90°)
|
|
503
|
+
using the standard a-b, a+b transformation.
|
|
504
|
+
|
|
505
|
+
Works on
|
|
506
|
+
---
|
|
507
|
+
ASE Atoms structures with hexagonal lattice geometry
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
---
|
|
511
|
+
atoms : ase.Atoms
|
|
512
|
+
Hexagonal structure.
|
|
513
|
+
|
|
514
|
+
Returns
|
|
515
|
+
---
|
|
516
|
+
ase.Atoms
|
|
517
|
+
Orthorhombic structure.
|
|
518
|
+
|
|
519
|
+
Examples
|
|
520
|
+
---
|
|
521
|
+
>>> ortho = orthogonalize_hexagonal_cell(hex_atoms)
|
|
522
|
+
"""
|
|
523
|
+
import numpy as np
|
|
524
|
+
from ase.build.supercells import make_supercell
|
|
525
|
+
|
|
526
|
+
# Transformation matrix: new vectors = old vectors * T
|
|
527
|
+
T = np.array([
|
|
528
|
+
[1, -1, 0],
|
|
529
|
+
[1, 1, 0],
|
|
530
|
+
[0, 0, 1]
|
|
531
|
+
])
|
|
532
|
+
|
|
533
|
+
new_atoms = make_supercell(atoms, T)
|
|
534
|
+
return new_atoms
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
# place2 algorithm for placing n instances of a structure into a simulation box or within another structure
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def place2(
|
|
541
|
+
insert_molecule: str | Path,
|
|
542
|
+
base_structure: Optional[str | Path] = None,
|
|
543
|
+
*,
|
|
544
|
+
n_copies: int,
|
|
545
|
+
box_length_x: float,
|
|
546
|
+
box_length_y: float,
|
|
547
|
+
box_length_z: float,
|
|
548
|
+
alpha: float,
|
|
549
|
+
beta: float,
|
|
550
|
+
gamma: float,
|
|
551
|
+
min_interatomic_distance: float,
|
|
552
|
+
base_structure_placement_mode: str = "as-is", # "as-is", "center", "origin"
|
|
553
|
+
max_placement_attempts_per_copy: int = 50000,
|
|
554
|
+
random_seed: int | None = None,
|
|
555
|
+
) -> Atoms:
|
|
556
|
+
"""
|
|
557
|
+
Randomly place multiple copies of a molecule into a simulation cell.
|
|
558
|
+
|
|
559
|
+
Python reimplementation of the Fortran `place2` algorithm
|
|
560
|
+
|
|
561
|
+
Works on
|
|
562
|
+
---
|
|
563
|
+
Molecular structure files and simulation boxes
|
|
564
|
+
|
|
565
|
+
Parameters
|
|
566
|
+
---
|
|
567
|
+
insert_molecule : str or pathlib.Path
|
|
568
|
+
Structure to be duplicated.
|
|
569
|
+
base_structure : str or pathlib.Path or None, optional
|
|
570
|
+
Initial structure to insert into.
|
|
571
|
+
n_copies : int
|
|
572
|
+
Number of copies to place.
|
|
573
|
+
box_length_x, box_length_y, box_length_z : float
|
|
574
|
+
Simulation box lengths (Å).
|
|
575
|
+
alpha, beta, gamma : float
|
|
576
|
+
Cell angles (degrees).
|
|
577
|
+
min_interatomic_distance : float
|
|
578
|
+
Minimum allowed atomic separation.
|
|
579
|
+
base_structure_placement_mode : str, optional
|
|
580
|
+
Base structure positioning mode.
|
|
581
|
+
max_placement_attempts_per_copy : int, optional
|
|
582
|
+
Maximum placement attempts.
|
|
583
|
+
random_seed : int or None, optional
|
|
584
|
+
Random seed for reproducibility.
|
|
585
|
+
|
|
586
|
+
Returns
|
|
587
|
+
---
|
|
588
|
+
ase.Atoms
|
|
589
|
+
Combined atomic system.
|
|
590
|
+
|
|
591
|
+
Examples
|
|
592
|
+
---
|
|
593
|
+
>>> atoms = place2("H2O.xyz", n_copies=100, box_length_x=30,
|
|
594
|
+
... box_length_y=30, box_length_z=30,
|
|
595
|
+
... alpha=90, beta=90, gamma=90,
|
|
596
|
+
... min_interatomic_distance=1.5)
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
rng = np.random.default_rng(random_seed)
|
|
600
|
+
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
# 1) Build triclinic cell matrix
|
|
603
|
+
# ------------------------------------------------------------------
|
|
604
|
+
cell = cellpar_to_cell(
|
|
605
|
+
[box_length_x, box_length_y, box_length_z, alpha, beta, gamma]
|
|
606
|
+
)
|
|
607
|
+
inv_cell = np.linalg.inv(cell)
|
|
608
|
+
|
|
609
|
+
# ------------------------------------------------------------------
|
|
610
|
+
# 2) Read and center the insert_molecule at origin
|
|
611
|
+
# ------------------------------------------------------------------
|
|
612
|
+
insert_atoms = read_structure(insert_molecule)
|
|
613
|
+
insert_symbols = insert_atoms.get_chemical_symbols()
|
|
614
|
+
insert_coords = insert_atoms.get_positions()
|
|
615
|
+
|
|
616
|
+
insert_center = insert_coords.mean(axis=0)
|
|
617
|
+
insert_coords_centered = insert_coords - insert_center
|
|
618
|
+
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
# 3) Initialize with base_structure (if given)
|
|
621
|
+
# ------------------------------------------------------------------
|
|
622
|
+
system_symbols: List[str] = []
|
|
623
|
+
system_coords = np.empty((0, 3), dtype=float)
|
|
624
|
+
|
|
625
|
+
if base_structure is not None:
|
|
626
|
+
base_atoms = read_structure(base_structure)
|
|
627
|
+
base_coords = base_atoms.get_positions()
|
|
628
|
+
base_symbols = base_atoms.get_chemical_symbols()
|
|
629
|
+
|
|
630
|
+
if base_structure_placement_mode not in {"as-is", "center", "origin"}:
|
|
631
|
+
raise ValueError(
|
|
632
|
+
"base_structure_placement_mode must be one of "
|
|
633
|
+
'{"as-is", "center", "origin"}'
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
if base_structure_placement_mode in {"center", "origin"}:
|
|
637
|
+
base_center = base_coords.mean(axis=0)
|
|
638
|
+
if base_structure_placement_mode == "center":
|
|
639
|
+
box_center = np.array([
|
|
640
|
+
box_length_x / 2,
|
|
641
|
+
box_length_y / 2,
|
|
642
|
+
box_length_z / 2,
|
|
643
|
+
])
|
|
644
|
+
shift = box_center - base_center
|
|
645
|
+
else:
|
|
646
|
+
# "origin"
|
|
647
|
+
shift = -base_center
|
|
648
|
+
base_coords = base_coords + shift
|
|
649
|
+
|
|
650
|
+
system_symbols.extend(base_symbols)
|
|
651
|
+
system_coords = np.vstack([system_coords, base_coords])
|
|
652
|
+
|
|
653
|
+
# ------------------------------------------------------------------
|
|
654
|
+
# Helper: random rotation matrix
|
|
655
|
+
# ------------------------------------------------------------------
|
|
656
|
+
def random_rotation_matrix() -> np.ndarray:
|
|
657
|
+
phi = 2 * np.pi * rng.random()
|
|
658
|
+
theta = 2 * np.pi * rng.random()
|
|
659
|
+
psi = 2 * np.pi * rng.random()
|
|
660
|
+
|
|
661
|
+
c1, s1 = np.cos(phi), np.sin(phi)
|
|
662
|
+
c2, s2 = np.cos(theta), np.sin(theta)
|
|
663
|
+
c3, s3 = np.cos(psi), np.sin(psi)
|
|
664
|
+
|
|
665
|
+
Rz1 = np.array([[c1, -s1, 0], [s1, c1, 0], [0, 0, 1]])
|
|
666
|
+
Ry = np.array([[c2, 0, s2], [0, 1, 0], [-s2, 0, c2]])
|
|
667
|
+
Rz2 = np.array([[c3, -s3, 0], [s3, c3, 0], [0, 0, 1]])
|
|
668
|
+
|
|
669
|
+
return Rz2 @ Ry @ Rz1
|
|
670
|
+
|
|
671
|
+
# ------------------------------------------------------------------
|
|
672
|
+
# Helper: minimum distance to existing system with PBC
|
|
673
|
+
# ------------------------------------------------------------------
|
|
674
|
+
def min_distance(new: np.ndarray, existing: np.ndarray) -> float:
|
|
675
|
+
if existing.size == 0:
|
|
676
|
+
return float("inf")
|
|
677
|
+
|
|
678
|
+
diff = new[:, None, :] - existing[None, :, :]
|
|
679
|
+
frac = diff @ inv_cell
|
|
680
|
+
frac -= np.round(frac)
|
|
681
|
+
cart = frac @ cell
|
|
682
|
+
d2 = np.sum(cart ** 2, axis=-1)
|
|
683
|
+
return float(np.sqrt(d2.min()))
|
|
684
|
+
|
|
685
|
+
# ------------------------------------------------------------------
|
|
686
|
+
# 4) Insert N random copies
|
|
687
|
+
# ------------------------------------------------------------------
|
|
688
|
+
for n in range(n_copies):
|
|
689
|
+
attempts = 0
|
|
690
|
+
while True:
|
|
691
|
+
attempts += 1
|
|
692
|
+
if attempts > max_placement_attempts_per_copy:
|
|
693
|
+
raise RuntimeError(
|
|
694
|
+
f"Could not place copy {n+1} after "
|
|
695
|
+
f"{max_placement_attempts_per_copy} attempts."
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
R = random_rotation_matrix()
|
|
699
|
+
rotated = insert_coords_centered @ R.T
|
|
700
|
+
|
|
701
|
+
translate = np.array([
|
|
702
|
+
box_length_x * rng.random(),
|
|
703
|
+
box_length_y * rng.random(),
|
|
704
|
+
box_length_z * rng.random(),
|
|
705
|
+
])
|
|
706
|
+
new_coords = rotated + translate
|
|
707
|
+
|
|
708
|
+
if min_distance(new_coords, system_coords) > min_interatomic_distance:
|
|
709
|
+
system_coords = np.vstack([system_coords, new_coords])
|
|
710
|
+
system_symbols.extend(insert_symbols)
|
|
711
|
+
break
|
|
712
|
+
# else: try again
|
|
713
|
+
|
|
714
|
+
# ------------------------------------------------------------------
|
|
715
|
+
# 5) Build final ASE atoms
|
|
716
|
+
# ------------------------------------------------------------------
|
|
717
|
+
final_atoms = Atoms(
|
|
718
|
+
symbols=system_symbols,
|
|
719
|
+
positions=system_coords,
|
|
720
|
+
cell=cell,
|
|
721
|
+
pbc=True,
|
|
722
|
+
)
|
|
723
|
+
return final_atoms
|
|
724
|
+
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
# adding a restraint of any type (bond, angle, torsion, mascen) to a geo file
|
|
727
|
+
# ---------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
def add_restraints_to_geo(
|
|
730
|
+
geo_file: str | Path,
|
|
731
|
+
*,
|
|
732
|
+
out_file: str | Path | None = None,
|
|
733
|
+
kinds: Sequence[str],
|
|
734
|
+
params: Optional[Dict[str, str]] = None,
|
|
735
|
+
) -> Path:
|
|
736
|
+
"""
|
|
737
|
+
Insert sample restraint blocks into a GEO/XTLGRF file.
|
|
738
|
+
|
|
739
|
+
Insert BEFORE first line starting with any of:
|
|
740
|
+
CRYSTX, FORMAT ATOM, HETATM, ATOM
|
|
741
|
+
else before END, else EOF.
|
|
742
|
+
|
|
743
|
+
For each kind write exactly 3 lines (NO blank lines):
|
|
744
|
+
FORMAT ...
|
|
745
|
+
# (guide)
|
|
746
|
+
<KIND> RESTRAINT ... (fields LEFT-aligned under guide headers)
|
|
747
|
+
|
|
748
|
+
Works on
|
|
749
|
+
---
|
|
750
|
+
ReaxFF GEO/XTLGRF structure files
|
|
751
|
+
|
|
752
|
+
Parameters
|
|
753
|
+
---
|
|
754
|
+
geo_file : str or pathlib.Path
|
|
755
|
+
Input GEO file.
|
|
756
|
+
out_file : str or pathlib.Path or None, optional
|
|
757
|
+
Output file path.
|
|
758
|
+
kinds : sequence of str
|
|
759
|
+
Restraint kinds to insert (e.g. BOND, ANGLE).
|
|
760
|
+
params : dict or None, optional
|
|
761
|
+
Custom restraint parameter strings.
|
|
762
|
+
|
|
763
|
+
Returns
|
|
764
|
+
---
|
|
765
|
+
pathlib.Path
|
|
766
|
+
Path to the modified GEO file.
|
|
767
|
+
|
|
768
|
+
Examples
|
|
769
|
+
---
|
|
770
|
+
>>> add_restraints_to_geo("geo", kinds=["BOND", "ANGLE"])
|
|
771
|
+
"""
|
|
772
|
+
geo_file = Path(geo_file)
|
|
773
|
+
if not geo_file.is_file():
|
|
774
|
+
raise FileNotFoundError(f"Input GEO file not found: {geo_file}")
|
|
775
|
+
|
|
776
|
+
out_path = Path(out_file) if out_file is not None else geo_file
|
|
777
|
+
params = params or {}
|
|
778
|
+
|
|
779
|
+
wanted = [str(k).strip().upper() for k in kinds if str(k).strip()]
|
|
780
|
+
if not wanted:
|
|
781
|
+
raise ValueError("No restraint kinds provided (kinds is empty).")
|
|
782
|
+
|
|
783
|
+
order = ["BOND", "ANGLE", "TORSION", "MASCEN"]
|
|
784
|
+
wanted_sorted = [k for k in order if k in set(wanted)]
|
|
785
|
+
|
|
786
|
+
default_params: Dict[str, str] = {
|
|
787
|
+
"BOND": "1 2 1.0900 7500.00 0.25000 0.0000000",
|
|
788
|
+
"ANGLE": "1 2 3 109.5000 600.00 0.25000 0.0000000",
|
|
789
|
+
"TORSION": "1 2 3 4 180.0000 100.00 0.25000 0.0000000",
|
|
790
|
+
"MASCEN": "1 0.0000 0.0000 0.0000 500.00 0.25000 0.0000000",
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
format_lines: Dict[str, str] = {
|
|
794
|
+
"BOND": "FORMAT BOND RESTRAINT (15x,2i4,f8.4,f8.2,f8.5,f10.7)",
|
|
795
|
+
"ANGLE": "FORMAT ANGLE RESTRAINT (15x,3i4,f8.3,f8.2,f8.5,f10.7)",
|
|
796
|
+
"TORSION": "FORMAT TORSION RESTRAINT (15x,4i4,f8.3,f8.2,f8.5,f10.7)",
|
|
797
|
+
"MASCEN": "FORMAT MASCEN RESTRAINT (15x,i4,3f10.4,f8.2,f8.5,f10.7)",
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
# Keep these exactly like your working example style: a short "#", then spaces, then headers.
|
|
801
|
+
guide_lines: Dict[str, str] = {
|
|
802
|
+
"BOND": "# At1 At2 R12 Force1 Force2 dR12/dIteration(MD only)",
|
|
803
|
+
"ANGLE": "# At1 At2 At3 A123 Force1 Force2 dA123/dIteration(MD only)",
|
|
804
|
+
"TORSION": "# At1 At2 At3 At4 T1234 Force1 Force2 dT1234/dIteration(MD only)",
|
|
805
|
+
"MASCEN": "# At1 X Y Z Force1 Force2 dR/dIteration(MD only)",
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
# We will align under the *actual token starts* in the guide line (left-aligned).
|
|
809
|
+
# Each spec gives: token_name, formatter(kind-specific), min_width (fallback)
|
|
810
|
+
token_layout: Dict[str, List[Tuple[str, str, int]]] = {
|
|
811
|
+
"BOND": [
|
|
812
|
+
("At1", "i", 3),
|
|
813
|
+
("At2", "i", 3),
|
|
814
|
+
("R12", "f4", 7),
|
|
815
|
+
("Force1", "f2", 7),
|
|
816
|
+
("Force2", "f5", 7),
|
|
817
|
+
("dR12/dIteration(MD only)", "f7", 10),
|
|
818
|
+
],
|
|
819
|
+
"ANGLE": [
|
|
820
|
+
("At1", "i", 3),
|
|
821
|
+
("At2", "i", 3),
|
|
822
|
+
("At3", "i", 3),
|
|
823
|
+
("A123", "f3", 7),
|
|
824
|
+
("Force1", "f2", 7),
|
|
825
|
+
("Force2", "f5", 7),
|
|
826
|
+
("dA123/dIteration(MD only)", "f7", 10),
|
|
827
|
+
],
|
|
828
|
+
"TORSION": [
|
|
829
|
+
("At1", "i", 3),
|
|
830
|
+
("At2", "i", 3),
|
|
831
|
+
("At3", "i", 3),
|
|
832
|
+
("At4", "i", 3),
|
|
833
|
+
("T1234", "f3", 7),
|
|
834
|
+
("Force1", "f2", 7),
|
|
835
|
+
("Force2", "f5", 7),
|
|
836
|
+
("dT1234/dIteration(MD only)", "f7", 10),
|
|
837
|
+
],
|
|
838
|
+
"MASCEN": [
|
|
839
|
+
("At1", "i", 3),
|
|
840
|
+
("X", "f4_10", 10),
|
|
841
|
+
("Y", "f4_10", 10),
|
|
842
|
+
("Z", "f4_10", 10),
|
|
843
|
+
("Force1", "f2", 7),
|
|
844
|
+
("Force2", "f5", 7),
|
|
845
|
+
("dR/dIteration(MD only)", "f7", 10),
|
|
846
|
+
],
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
def _format_value(tok: str, fmt: str) -> str:
|
|
850
|
+
# ints
|
|
851
|
+
if fmt == "i":
|
|
852
|
+
return str(int(float(tok)))
|
|
853
|
+
# floats
|
|
854
|
+
if fmt == "f2":
|
|
855
|
+
return f"{float(tok):.2f}"
|
|
856
|
+
if fmt == "f3":
|
|
857
|
+
return f"{float(tok):.3f}"
|
|
858
|
+
if fmt == "f4":
|
|
859
|
+
return f"{float(tok):.4f}"
|
|
860
|
+
if fmt == "f5":
|
|
861
|
+
return f"{float(tok):.5f}"
|
|
862
|
+
if fmt == "f7":
|
|
863
|
+
return f"{float(tok):.7f}"
|
|
864
|
+
if fmt == "f4_10": # for X/Y/Z use wider 10.4 style
|
|
865
|
+
return f"{float(tok):.4f}"
|
|
866
|
+
return tok
|
|
867
|
+
|
|
868
|
+
def _token_starts(guide: str, names: List[str]) -> List[int]:
|
|
869
|
+
"""
|
|
870
|
+
Find start indices of each token name in the guide line.
|
|
871
|
+
Uses find() from left to right; if missing, falls back to spaced layout.
|
|
872
|
+
"""
|
|
873
|
+
starts: List[int] = []
|
|
874
|
+
cursor = 0
|
|
875
|
+
for nm in names:
|
|
876
|
+
j = guide.find(nm, cursor)
|
|
877
|
+
if j < 0:
|
|
878
|
+
# fallback: place after last start + 4
|
|
879
|
+
j = (starts[-1] + 4) if starts else guide.find("#") + 2
|
|
880
|
+
starts.append(j)
|
|
881
|
+
cursor = j + len(nm)
|
|
882
|
+
return starts
|
|
883
|
+
|
|
884
|
+
def _build_aligned_data_line(kind: str, param_str: str) -> str:
|
|
885
|
+
"""
|
|
886
|
+
Build:
|
|
887
|
+
"BOND RESTRAINT" + spaces + values
|
|
888
|
+
where each value is LEFT-aligned to the same column as the header token in guide line.
|
|
889
|
+
"""
|
|
890
|
+
guide = guide_lines[kind]
|
|
891
|
+
layout = token_layout[kind]
|
|
892
|
+
names = [x[0] for x in layout]
|
|
893
|
+
starts = _token_starts(guide, names)
|
|
894
|
+
|
|
895
|
+
toks = [t for t in (param_str or "").split() if t.strip()]
|
|
896
|
+
|
|
897
|
+
# Basic validation: ensure enough tokens
|
|
898
|
+
need = len(layout)
|
|
899
|
+
if len(toks) < need:
|
|
900
|
+
raise ValueError(f"{kind} params need {need} tokens but got {len(toks)}: {toks}")
|
|
901
|
+
toks = toks[:need]
|
|
902
|
+
|
|
903
|
+
label = f"{kind} RESTRAINT"
|
|
904
|
+
# Start with label, then pad up to the first header start
|
|
905
|
+
s = label
|
|
906
|
+
if len(s) < starts[0]:
|
|
907
|
+
s += " " * (starts[0] - len(s))
|
|
908
|
+
else:
|
|
909
|
+
s += " "
|
|
910
|
+
|
|
911
|
+
# Place each field at exact start column; LEFT-aligned, no right-justification
|
|
912
|
+
for (nm, fmt, minw), start, tok in zip(layout, starts, toks):
|
|
913
|
+
val = _format_value(tok, fmt)
|
|
914
|
+
|
|
915
|
+
if len(s) < start:
|
|
916
|
+
s += " " * (start - len(s))
|
|
917
|
+
# left align within a reasonable width so next field doesn't collide
|
|
918
|
+
# width = distance to next start (or minw for last token)
|
|
919
|
+
idx = names.index(nm)
|
|
920
|
+
if idx < len(starts) - 1:
|
|
921
|
+
width = max(minw, starts[idx + 1] - start - 1)
|
|
922
|
+
else:
|
|
923
|
+
width = max(minw, len(val))
|
|
924
|
+
s += val.ljust(width) + " "
|
|
925
|
+
|
|
926
|
+
return s.rstrip()
|
|
927
|
+
|
|
928
|
+
# Read file
|
|
929
|
+
lines = geo_file.read_text().splitlines()
|
|
930
|
+
|
|
931
|
+
# Insert BEFORE first CRYSTX / FORMAT ATOM / HETATM / ATOM
|
|
932
|
+
insert_idx: Optional[int] = None
|
|
933
|
+
triggers = ("CRYSTX", "FORMAT ATOM", "HETATM", "ATOM")
|
|
934
|
+
for i, ln in enumerate(lines):
|
|
935
|
+
s = ln.lstrip()
|
|
936
|
+
if any(s.startswith(t) for t in triggers):
|
|
937
|
+
insert_idx = i
|
|
938
|
+
break
|
|
939
|
+
if insert_idx is None:
|
|
940
|
+
for i, ln in enumerate(lines):
|
|
941
|
+
if ln.strip() == "END":
|
|
942
|
+
insert_idx = i
|
|
943
|
+
break
|
|
944
|
+
if insert_idx is None:
|
|
945
|
+
insert_idx = len(lines)
|
|
946
|
+
|
|
947
|
+
block: list[str] = []
|
|
948
|
+
block.append("REMARK Restraints added by ReaxKit (sample lines; edit as needed)")
|
|
949
|
+
|
|
950
|
+
for k in wanted_sorted:
|
|
951
|
+
block.append(format_lines[k])
|
|
952
|
+
block.append(guide_lines[k])
|
|
953
|
+
|
|
954
|
+
p = (params.get(k) or "").strip()
|
|
955
|
+
if not p:
|
|
956
|
+
p = default_params[k]
|
|
957
|
+
|
|
958
|
+
block.append(_build_aligned_data_line(k, p))
|
|
959
|
+
|
|
960
|
+
new_lines = lines[:insert_idx] + block + lines[insert_idx:]
|
|
961
|
+
|
|
962
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
963
|
+
out_path.write_text("\n".join(new_lines) + "\n")
|
|
964
|
+
return out_path
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
|