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.
Files changed (130) hide show
  1. reaxkit/__init__.py +0 -0
  2. reaxkit/analysis/__init__.py +0 -0
  3. reaxkit/analysis/composed/RDF_analyzer.py +560 -0
  4. reaxkit/analysis/composed/__init__.py +0 -0
  5. reaxkit/analysis/composed/connectivity_analyzer.py +706 -0
  6. reaxkit/analysis/composed/coordination_analyzer.py +144 -0
  7. reaxkit/analysis/composed/electrostatics_analyzer.py +687 -0
  8. reaxkit/analysis/per_file/__init__.py +0 -0
  9. reaxkit/analysis/per_file/control_analyzer.py +165 -0
  10. reaxkit/analysis/per_file/eregime_analyzer.py +108 -0
  11. reaxkit/analysis/per_file/ffield_analyzer.py +305 -0
  12. reaxkit/analysis/per_file/fort13_analyzer.py +79 -0
  13. reaxkit/analysis/per_file/fort57_analyzer.py +106 -0
  14. reaxkit/analysis/per_file/fort73_analyzer.py +61 -0
  15. reaxkit/analysis/per_file/fort74_analyzer.py +65 -0
  16. reaxkit/analysis/per_file/fort76_analyzer.py +191 -0
  17. reaxkit/analysis/per_file/fort78_analyzer.py +154 -0
  18. reaxkit/analysis/per_file/fort79_analyzer.py +83 -0
  19. reaxkit/analysis/per_file/fort7_analyzer.py +393 -0
  20. reaxkit/analysis/per_file/fort99_analyzer.py +411 -0
  21. reaxkit/analysis/per_file/molfra_analyzer.py +359 -0
  22. reaxkit/analysis/per_file/params_analyzer.py +258 -0
  23. reaxkit/analysis/per_file/summary_analyzer.py +84 -0
  24. reaxkit/analysis/per_file/trainset_analyzer.py +84 -0
  25. reaxkit/analysis/per_file/vels_analyzer.py +95 -0
  26. reaxkit/analysis/per_file/xmolout_analyzer.py +528 -0
  27. reaxkit/cli.py +181 -0
  28. reaxkit/count_loc.py +276 -0
  29. reaxkit/data/alias.yaml +89 -0
  30. reaxkit/data/constants.yaml +27 -0
  31. reaxkit/data/reaxff_input_files_contents.yaml +186 -0
  32. reaxkit/data/reaxff_output_files_contents.yaml +301 -0
  33. reaxkit/data/units.yaml +38 -0
  34. reaxkit/help/__init__.py +0 -0
  35. reaxkit/help/help_index_loader.py +531 -0
  36. reaxkit/help/introspection_utils.py +131 -0
  37. reaxkit/io/__init__.py +0 -0
  38. reaxkit/io/base_handler.py +165 -0
  39. reaxkit/io/generators/__init__.py +0 -0
  40. reaxkit/io/generators/control_generator.py +123 -0
  41. reaxkit/io/generators/eregime_generator.py +341 -0
  42. reaxkit/io/generators/geo_generator.py +967 -0
  43. reaxkit/io/generators/trainset_generator.py +1758 -0
  44. reaxkit/io/generators/tregime_generator.py +113 -0
  45. reaxkit/io/generators/vregime_generator.py +164 -0
  46. reaxkit/io/generators/xmolout_generator.py +304 -0
  47. reaxkit/io/handlers/__init__.py +0 -0
  48. reaxkit/io/handlers/control_handler.py +209 -0
  49. reaxkit/io/handlers/eregime_handler.py +122 -0
  50. reaxkit/io/handlers/ffield_handler.py +812 -0
  51. reaxkit/io/handlers/fort13_handler.py +123 -0
  52. reaxkit/io/handlers/fort57_handler.py +143 -0
  53. reaxkit/io/handlers/fort73_handler.py +145 -0
  54. reaxkit/io/handlers/fort74_handler.py +155 -0
  55. reaxkit/io/handlers/fort76_handler.py +195 -0
  56. reaxkit/io/handlers/fort78_handler.py +142 -0
  57. reaxkit/io/handlers/fort79_handler.py +227 -0
  58. reaxkit/io/handlers/fort7_handler.py +264 -0
  59. reaxkit/io/handlers/fort99_handler.py +128 -0
  60. reaxkit/io/handlers/geo_handler.py +224 -0
  61. reaxkit/io/handlers/molfra_handler.py +184 -0
  62. reaxkit/io/handlers/params_handler.py +137 -0
  63. reaxkit/io/handlers/summary_handler.py +135 -0
  64. reaxkit/io/handlers/trainset_handler.py +658 -0
  65. reaxkit/io/handlers/vels_handler.py +293 -0
  66. reaxkit/io/handlers/xmolout_handler.py +174 -0
  67. reaxkit/utils/__init__.py +0 -0
  68. reaxkit/utils/alias.py +219 -0
  69. reaxkit/utils/cache.py +77 -0
  70. reaxkit/utils/constants.py +75 -0
  71. reaxkit/utils/equation_of_states.py +96 -0
  72. reaxkit/utils/exceptions.py +27 -0
  73. reaxkit/utils/frame_utils.py +175 -0
  74. reaxkit/utils/log.py +43 -0
  75. reaxkit/utils/media/__init__.py +0 -0
  76. reaxkit/utils/media/convert.py +90 -0
  77. reaxkit/utils/media/make_video.py +91 -0
  78. reaxkit/utils/media/plotter.py +812 -0
  79. reaxkit/utils/numerical/__init__.py +0 -0
  80. reaxkit/utils/numerical/extrema_finder.py +96 -0
  81. reaxkit/utils/numerical/moving_average.py +103 -0
  82. reaxkit/utils/numerical/numerical_calcs.py +75 -0
  83. reaxkit/utils/numerical/signal_ops.py +135 -0
  84. reaxkit/utils/path.py +55 -0
  85. reaxkit/utils/units.py +104 -0
  86. reaxkit/webui/__init__.py +0 -0
  87. reaxkit/webui/app.py +0 -0
  88. reaxkit/webui/components.py +0 -0
  89. reaxkit/webui/layouts.py +0 -0
  90. reaxkit/webui/utils.py +0 -0
  91. reaxkit/workflows/__init__.py +0 -0
  92. reaxkit/workflows/composed/__init__.py +0 -0
  93. reaxkit/workflows/composed/coordination_workflow.py +393 -0
  94. reaxkit/workflows/composed/electrostatics_workflow.py +587 -0
  95. reaxkit/workflows/composed/xmolout_fort7_workflow.py +343 -0
  96. reaxkit/workflows/meta/__init__.py +0 -0
  97. reaxkit/workflows/meta/help_workflow.py +136 -0
  98. reaxkit/workflows/meta/introspection_workflow.py +235 -0
  99. reaxkit/workflows/meta/make_video_workflow.py +61 -0
  100. reaxkit/workflows/meta/plotter_workflow.py +601 -0
  101. reaxkit/workflows/per_file/__init__.py +0 -0
  102. reaxkit/workflows/per_file/control_workflow.py +110 -0
  103. reaxkit/workflows/per_file/eregime_workflow.py +267 -0
  104. reaxkit/workflows/per_file/ffield_workflow.py +390 -0
  105. reaxkit/workflows/per_file/fort13_workflow.py +86 -0
  106. reaxkit/workflows/per_file/fort57_workflow.py +137 -0
  107. reaxkit/workflows/per_file/fort73_workflow.py +151 -0
  108. reaxkit/workflows/per_file/fort74_workflow.py +88 -0
  109. reaxkit/workflows/per_file/fort76_workflow.py +188 -0
  110. reaxkit/workflows/per_file/fort78_workflow.py +135 -0
  111. reaxkit/workflows/per_file/fort79_workflow.py +314 -0
  112. reaxkit/workflows/per_file/fort7_workflow.py +592 -0
  113. reaxkit/workflows/per_file/fort83_workflow.py +60 -0
  114. reaxkit/workflows/per_file/fort99_workflow.py +223 -0
  115. reaxkit/workflows/per_file/geo_workflow.py +554 -0
  116. reaxkit/workflows/per_file/molfra_workflow.py +577 -0
  117. reaxkit/workflows/per_file/params_workflow.py +135 -0
  118. reaxkit/workflows/per_file/summary_workflow.py +161 -0
  119. reaxkit/workflows/per_file/trainset_workflow.py +356 -0
  120. reaxkit/workflows/per_file/tregime_workflow.py +79 -0
  121. reaxkit/workflows/per_file/vels_workflow.py +309 -0
  122. reaxkit/workflows/per_file/vregime_workflow.py +75 -0
  123. reaxkit/workflows/per_file/xmolout_workflow.py +678 -0
  124. reaxkit-1.0.0.dist-info/METADATA +128 -0
  125. reaxkit-1.0.0.dist-info/RECORD +130 -0
  126. reaxkit-1.0.0.dist-info/WHEEL +5 -0
  127. reaxkit-1.0.0.dist-info/entry_points.txt +2 -0
  128. reaxkit-1.0.0.dist-info/licenses/AUTHORS.md +20 -0
  129. reaxkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  130. 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
+