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,687 @@
1
+ """
2
+ Electrostatics analysis utilities (dipole and polarization).
3
+
4
+ This module provides fast, vectorized electrostatics calculations for ReaxFF
5
+ simulations, including total and local dipoles, polarization, and
6
+ polarization–electric-field hysteresis analysis.
7
+
8
+ Design notes
9
+ ------------
10
+ - Heavy computations are vectorized with NumPy for performance.
11
+ - Coordinates, charges, and connectivity are preloaded once per frame batch.
12
+ - Local electrostatics operate on atom-centered clusters using connectivity.
13
+ - Polarization volumes can be estimated via convex hull or bounding box.
14
+
15
+ Typical use cases include:
16
+ ------------
17
+ - computing total dipole or polarization vs time/frame
18
+ - computing local dipoles around selected core atom types
19
+ - generating polarization–field hysteresis loops
20
+ """
21
+
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Literal, Sequence, Optional, Tuple, Dict, Any, List
26
+
27
+ import re
28
+
29
+ import numpy as np
30
+ import pandas as pd
31
+ from scipy.spatial import ConvexHull
32
+
33
+ from reaxkit.io.handlers.xmolout_handler import XmoloutHandler
34
+ from reaxkit.io.handlers.fort7_handler import Fort7Handler
35
+ from reaxkit.io.handlers.fort78_handler import Fort78Handler
36
+ from reaxkit.io.handlers.control_handler import ControlHandler
37
+
38
+ from reaxkit.analysis.per_file.fort7_analyzer import (
39
+ get_partial_charges_conv_fnc,
40
+ get_all_atoms_cnn_conv_fnc,
41
+ )
42
+ from reaxkit.analysis.per_file.fort78_analyzer import match_electric_field_to_iout2
43
+
44
+ from reaxkit.utils.constants import const
45
+ from reaxkit.utils.numerical.numerical_calcs import find_zero_crossings
46
+
47
+
48
+ Scope = Literal["total", "local"]
49
+ Mode = Literal["dipole", "polarization"]
50
+ VolumeMethod = Literal["hull", "bbox"]
51
+ AggregateKind = Optional[Literal["mean", "max", "min", "last"]]
52
+
53
+
54
+ # -------------------------------------------------------------------------------------
55
+ # Volume helpers
56
+ # -------------------------------------------------------------------------------------
57
+
58
+ def _convex_hull_volume(coords: np.ndarray) -> float:
59
+ """Convex-hull volume; NaN if not computable."""
60
+ coords = np.asarray(coords, float)
61
+ if coords.ndim != 2 or coords.shape[1] != 3 or coords.shape[0] < 4:
62
+ return np.nan
63
+ try:
64
+ return float(ConvexHull(coords).volume)
65
+ except Exception:
66
+ return np.nan
67
+
68
+
69
+ def _bbox_volume(coords: np.ndarray) -> float:
70
+ """Axis-aligned bounding box volume; NaN if empty/invalid."""
71
+ coords = np.asarray(coords, float)
72
+ if coords.ndim != 2 or coords.shape[1] != 3 or coords.shape[0] == 0:
73
+ return np.nan
74
+ mn = np.nanmin(coords, axis=0)
75
+ mx = np.nanmax(coords, axis=0)
76
+ if np.any(~np.isfinite(mn)) or np.any(~np.isfinite(mx)):
77
+ return np.nan
78
+ d = mx - mn
79
+ if np.any(d < 0):
80
+ return np.nan
81
+ return float(d[0] * d[1] * d[2])
82
+
83
+
84
+ # -------------------------------------------------------------------------------------
85
+ # Core dipole/polarization primitive (single set of coords/charges)
86
+ # -------------------------------------------------------------------------------------
87
+
88
+ def _dipole_and_polarization(
89
+ coords: np.ndarray,
90
+ charges: np.ndarray,
91
+ *,
92
+ mode: Mode = "dipole",
93
+ volume_method: VolumeMethod = "hull",
94
+ ) -> Tuple[pd.DataFrame, float]:
95
+ """Compute dipole moment and optional polarization for a single atomic cluster.
96
+
97
+ Works on
98
+ --------
99
+ Raw arrays — coordinates + charges (no handler required)
100
+
101
+ Parameters
102
+ ----------
103
+ coords : (N, 3) ndarray
104
+ Atomic coordinates in Ă….
105
+ charges : (N,) ndarray
106
+ Atomic partial charges in units of e.
107
+ mode : {"dipole", "polarization"}, default="dipole"
108
+ Whether to compute dipole only or dipole + polarization.
109
+ volume_method : {"hull", "bbox"}, default="hull"
110
+ Method used to estimate volume for polarization.
111
+
112
+ Returns
113
+ -------
114
+ pandas.DataFrame
115
+ One-row table with dipole components (and polarization if requested).
116
+ float
117
+ Estimated volume in Ă…Âł (NaN if not computable).
118
+
119
+ Examples
120
+ --------
121
+ >>> df, vol = _dipole_and_polarization(coords, charges, mode="polarization")"""
122
+ coords = np.asarray(coords, float)
123
+ charges = np.asarray(charges, float)
124
+
125
+ if coords.shape[0] != charges.shape[0]:
126
+ raise ValueError("coords and charges must have same length")
127
+
128
+ mu_ea = (coords * charges[:, None]).sum(axis=0) # (3,) in e·Å
129
+ mu_debye = mu_ea * const["ea_to_debye"]
130
+
131
+ data: Dict[str, List[float]] = {
132
+ "mu_x (debye)": [float(mu_debye[0])],
133
+ "mu_y (debye)": [float(mu_debye[1])],
134
+ "mu_z (debye)": [float(mu_debye[2])],
135
+ }
136
+
137
+ volume = np.nan
138
+ if mode == "polarization":
139
+ if volume_method == "bbox":
140
+ volume = _bbox_volume(coords)
141
+ else:
142
+ volume = _convex_hull_volume(coords)
143
+
144
+ if np.isfinite(volume) and volume > 0:
145
+ P = mu_ea / volume * const["ea3_to_uC_cm2"]
146
+ data["P_x (uC/cm^2)"] = [float(P[0])]
147
+ data["P_y (uC/cm^2)"] = [float(P[1])]
148
+ data["P_z (uC/cm^2)"] = [float(P[2])]
149
+ else:
150
+ data["P_x (uC/cm^2)"] = [np.nan]
151
+ data["P_y (uC/cm^2)"] = [np.nan]
152
+ data["P_z (uC/cm^2)"] = [np.nan]
153
+
154
+ return pd.DataFrame(data), float(volume)
155
+
156
+
157
+ # -------------------------------------------------------------------------------------
158
+ # Preload (bulk) electrostatics
159
+ # -------------------------------------------------------------------------------------
160
+
161
+ def _preload_electrostatics(
162
+ xh: XmoloutHandler,
163
+ f7: Fort7Handler,
164
+ *,
165
+ frames: Optional[Sequence[int]] = None,
166
+ every: int = 1,
167
+ ) -> Dict[str, Any]:
168
+ """Preload coordinates, charges, atom types, and connectivity for multiple frames.
169
+
170
+ Works on
171
+ --------
172
+ XmoloutHandler + Fort7Handler — ``xmolout`` + ``fort.7``
173
+
174
+ Parameters
175
+ ----------
176
+ xh : XmoloutHandler
177
+ Parsed trajectory handler.
178
+ f7 : Fort7Handler
179
+ Parsed connectivity/charge handler.
180
+ frames : sequence of int, optional
181
+ Frame indices to include. If None, all frames are used.
182
+ every : int, default=1
183
+ Subsampling stride (e.g. every=10 → every 10th frame).
184
+
185
+ Returns
186
+ -------
187
+ dict
188
+ Dictionary containing NumPy arrays for:
189
+ ``coords``, ``charges``, ``atom_types``, ``cnn``, ``frame_index``, ``iter``.
190
+
191
+ Notes
192
+ -----
193
+ Intended as an internal fast path for electrostatics calculations.
194
+ """
195
+ df_sim = xh.dataframe()
196
+ if df_sim.empty:
197
+ return {
198
+ "frame_index": np.asarray([], dtype=int),
199
+ "iter": np.asarray([], dtype=int),
200
+ "coords": np.zeros((0, 0, 3), dtype=float),
201
+ "atom_types": [],
202
+ "charges": np.zeros((0, 0), dtype=float),
203
+ "cnn": np.zeros((0, 0, 0), dtype=np.int32),
204
+ "cnn_cols": [],
205
+ }
206
+
207
+ if frames is None:
208
+ frame_list = list(range(len(df_sim)))
209
+ else:
210
+ frame_list = [int(f) for f in frames]
211
+
212
+ frame_list = frame_list[::max(1, int(every))]
213
+ frame_list = [f for f in frame_list if 0 <= f < len(df_sim)]
214
+ if not frame_list:
215
+ return {
216
+ "frame_index": np.asarray([], dtype=int),
217
+ "iter": np.asarray([], dtype=int),
218
+ "coords": np.zeros((0, 0, 3), dtype=float),
219
+ "atom_types": [],
220
+ "charges": np.zeros((0, 0), dtype=float),
221
+ "cnn": np.zeros((0, 0, 0), dtype=np.int32),
222
+ "cnn_cols": [],
223
+ }
224
+
225
+ iters = np.asarray(
226
+ [int(df_sim.iloc[fi]["iter"]) if "iter" in df_sim.columns else int(fi) for fi in frame_list],
227
+ dtype=int,
228
+ )
229
+
230
+ # --- coords + types from xmolout (per frame) ---
231
+ coords_list: List[np.ndarray] = []
232
+ types_list: List[np.ndarray] = []
233
+
234
+ nA: Optional[int] = None
235
+ for fi in frame_list:
236
+ fr = xh.frame(int(fi))
237
+ coords = np.asarray(fr["coords"], dtype=float)
238
+ types = np.asarray([str(t) for t in fr["atom_types"]], dtype=object)
239
+ if nA is None:
240
+ nA = int(coords.shape[0])
241
+ if coords.shape[0] != nA:
242
+ raise ValueError(f"Atom count changes across frames (frame {fi} has {coords.shape[0]} vs {nA}).")
243
+ coords_list.append(coords)
244
+ types_list.append(types)
245
+
246
+ coords_arr = np.stack(coords_list, axis=0) # (nF, nA, 3)
247
+
248
+ # --- charges from fort.7 (bulk by iterations) ---
249
+ q_df = get_partial_charges_conv_fnc(f7, iterations=iters.tolist())
250
+ if q_df.empty:
251
+ raise ValueError("No partial charges found in fort.7 for requested iterations.")
252
+
253
+ q_df = q_df[q_df["iter"].isin(iters)].copy()
254
+ q_df = q_df.sort_values(["iter", "atom_idx"]).reset_index(drop=True)
255
+
256
+ charges_by_iter: Dict[int, np.ndarray] = {}
257
+ for it in iters.tolist():
258
+ sub = q_df[q_df["iter"] == it]
259
+ if sub.empty:
260
+ charges_by_iter[it] = np.full((nA,), np.nan, dtype=float)
261
+ continue
262
+ arr = sub["partial_charge"].to_numpy(dtype=float)
263
+ if arr.shape[0] != nA:
264
+ raise ValueError(f"Charges atom count mismatch at iter={it}: {arr.shape[0]} vs {nA}")
265
+ charges_by_iter[it] = arr
266
+
267
+ charges_arr = np.stack([charges_by_iter[int(it)] for it in iters], axis=0) # (nF, nA)
268
+
269
+ # --- connectivity (cnn) from fort.7 (bulk) ---
270
+ cnn_df = get_all_atoms_cnn_conv_fnc(f7, iterations=iters.tolist())
271
+ if cnn_df.empty:
272
+ cnn_arr = np.zeros((len(frame_list), nA, 0), dtype=np.int32)
273
+ cnn_cols: List[str] = []
274
+ else:
275
+ cnn_df = cnn_df[cnn_df["iter"].isin(iters)].copy()
276
+ cnn_cols = [c for c in cnn_df.columns if c.startswith("atom_cnn")]
277
+ cnn_cols = sorted(cnn_cols, key=lambda s: int(re.sub(r"\D+", "", s) or 0))
278
+
279
+ cnn_by_iter: Dict[int, np.ndarray] = {}
280
+ for it in iters.tolist():
281
+ sub = cnn_df[cnn_df["iter"] == it].sort_values("atom_idx").reset_index(drop=True)
282
+ if sub.empty:
283
+ cnn_by_iter[it] = np.zeros((nA, len(cnn_cols)), dtype=np.int32)
284
+ continue
285
+ mat = sub[cnn_cols].to_numpy(dtype=np.int32, copy=True)
286
+ if mat.shape[0] != nA:
287
+ raise ValueError(f"Connectivity atom count mismatch at iter={it}: {mat.shape[0]} vs {nA}")
288
+ cnn_by_iter[it] = mat
289
+
290
+ cnn_arr = np.stack([cnn_by_iter[int(it)] for it in iters], axis=0) # (nF, nA, max_cnn)
291
+
292
+ return {
293
+ "frame_index": np.asarray(frame_list, dtype=int),
294
+ "iter": iters,
295
+ "coords": coords_arr,
296
+ "atom_types": types_list,
297
+ "charges": charges_arr,
298
+ "cnn": cnn_arr,
299
+ "cnn_cols": cnn_cols,
300
+ }
301
+
302
+
303
+ # -------------------------------------------------------------------------------------
304
+ # Fast total/local calculators
305
+ # -------------------------------------------------------------------------------------
306
+
307
+ def _total_dipole_calc(
308
+ coords: np.ndarray, # (nF, nA, 3)
309
+ charges: np.ndarray, # (nF, nA)
310
+ *,
311
+ mode: Mode = "dipole",
312
+ volume_method: VolumeMethod = "hull",
313
+ ) -> pd.DataFrame:
314
+ """Compute total dipole (and optional polarization) over many frames.
315
+
316
+ Works on
317
+ --------
318
+ Preloaded NumPy arrays — multiple frames
319
+
320
+ Parameters
321
+ ----------
322
+ coords : (nF, nA, 3) ndarray
323
+ Atomic coordinates for each frame.
324
+ charges : (nF, nA) ndarray
325
+ Atomic partial charges for each frame.
326
+ mode : {"dipole", "polarization"}, default="dipole"
327
+ Quantity to compute.
328
+ volume_method : {"hull", "bbox"}, default="hull"
329
+ Volume estimator for polarization.
330
+
331
+ Returns
332
+ -------
333
+ pandas.DataFrame
334
+ One row per frame with dipole (and polarization if requested).
335
+
336
+ Examples
337
+ --------
338
+ >>> df = _total_dipole_calc(coords, charges, mode="dipole")"""
339
+ coords = np.asarray(coords, float)
340
+ charges = np.asarray(charges, float)
341
+ if coords.ndim != 3 or coords.shape[2] != 3:
342
+ raise ValueError("coords must be (nF, nA, 3)")
343
+ if charges.shape != coords.shape[:2]:
344
+ raise ValueError("charges must be (nF, nA) matching coords")
345
+
346
+ mu_ea = (coords * charges[..., None]).sum(axis=1) # (nF, 3) in e·Å
347
+ mu_debye = mu_ea * const["ea_to_debye"]
348
+
349
+ out: Dict[str, Any] = {
350
+ "mu_x (debye)": mu_debye[:, 0],
351
+ "mu_y (debye)": mu_debye[:, 1],
352
+ "mu_z (debye)": mu_debye[:, 2],
353
+ }
354
+
355
+ volumes = np.full((coords.shape[0],), np.nan, dtype=float)
356
+ if mode == "polarization":
357
+ if volume_method == "bbox":
358
+ mn = np.min(coords, axis=1)
359
+ mx = np.max(coords, axis=1)
360
+ d = mx - mn
361
+ volumes = d[:, 0] * d[:, 1] * d[:, 2]
362
+ else:
363
+ for i in range(coords.shape[0]):
364
+ volumes[i] = _convex_hull_volume(coords[i])
365
+
366
+ P = np.full_like(mu_ea, np.nan, dtype=float)
367
+ good = np.isfinite(volumes) & (volumes > 0)
368
+ if np.any(good):
369
+ P[good] = (mu_ea[good] / volumes[good, None]) * const["ea3_to_uC_cm2"]
370
+
371
+ out["P_x (uC/cm^2)"] = P[:, 0]
372
+ out["P_y (uC/cm^2)"] = P[:, 1]
373
+ out["P_z (uC/cm^2)"] = P[:, 2]
374
+
375
+ out["volume (angstrom^3)"] = volumes
376
+ return pd.DataFrame(out)
377
+
378
+
379
+ def _local_dipole_calc(
380
+ coords: np.ndarray, # (nA, 3)
381
+ charges: np.ndarray, # (nA,)
382
+ atom_types: np.ndarray, # (nA,)
383
+ cnn_mat: np.ndarray, # (nA, max_cnn) 1-based neighbors, 0 padded
384
+ *,
385
+ core_types: Sequence[str],
386
+ mode: Mode = "dipole",
387
+ volume_method: VolumeMethod = "bbox",
388
+ ) -> pd.DataFrame:
389
+ """Compute local dipoles (or polarization) around selected core atoms for one frame.
390
+
391
+ Works on
392
+ --------
393
+ Raw arrays — single frame + connectivity
394
+
395
+ Parameters
396
+ ----------
397
+ coords : (nA, 3) ndarray
398
+ Atomic coordinates.
399
+ charges : (nA,) ndarray
400
+ Partial charges.
401
+ atom_types : (nA,) ndarray
402
+ Atom type labels.
403
+ cnn_mat : (nA, k) ndarray
404
+ Connectivity matrix (1-based indices, zero-padded).
405
+ core_types : sequence of str
406
+ Atom types treated as cluster centers.
407
+ mode : {"dipole", "polarization"}, default="dipole"
408
+ Quantity to compute.
409
+ volume_method : {"bbox", "hull"}, default="bbox"
410
+ Volume estimator (bbox is faster for local clusters).
411
+
412
+ Returns
413
+ -------
414
+ pandas.DataFrame
415
+ One row per core atom with local electrostatic quantities.
416
+ """
417
+ coords = np.asarray(coords, float)
418
+ charges = np.asarray(charges, float)
419
+ atom_types = np.asarray(atom_types)
420
+ cnn_mat = np.asarray(cnn_mat, dtype=np.int32)
421
+
422
+ if coords.ndim != 2 or coords.shape[1] != 3:
423
+ raise ValueError("coords must be (nA, 3)")
424
+ nA = coords.shape[0]
425
+ if charges.shape != (nA,):
426
+ raise ValueError("charges must be (nA,)")
427
+ if atom_types.shape[0] != nA:
428
+ raise ValueError("atom_types must be (nA,)")
429
+ if cnn_mat.ndim != 2 or cnn_mat.shape[0] != nA:
430
+ raise ValueError("cnn_mat must be (nA, max_cnn)")
431
+
432
+ core_set = {str(t) for t in core_types}
433
+ core_mask = np.array([str(t) in core_set for t in atom_types], dtype=bool)
434
+ core_idx0 = np.nonzero(core_mask)[0] # 0-based
435
+ if core_idx0.size == 0:
436
+ cols = ["core_atom_type", "core_atom_id", "mu_x (debye)", "mu_y (debye)", "mu_z (debye)"]
437
+ if mode == "polarization":
438
+ cols += ["P_x (uC/cm^2)", "P_y (uC/cm^2)", "P_z (uC/cm^2)", "volume (angstrom^3)"]
439
+ return pd.DataFrame(columns=cols)
440
+
441
+ neigh_1b = cnn_mat[core_idx0] # (n_core, max_cnn)
442
+ neigh0 = neigh_1b.astype(np.int64) - 1 # 0-based, -1 for padded zeros
443
+ neigh0[neigh_1b == 0] = -1
444
+
445
+ k = 1 + neigh0.shape[1]
446
+ cluster_idx0 = np.empty((core_idx0.size, k), dtype=np.int64)
447
+ cluster_idx0[:, 0] = core_idx0
448
+ cluster_idx0[:, 1:] = neigh0
449
+
450
+ idx_clip = cluster_idx0.copy()
451
+ idx_clip[idx_clip < 0] = 0
452
+
453
+ coords_g = coords[idx_clip] # (n_core, k, 3)
454
+ q_g = charges[idx_clip] # (n_core, k)
455
+ mask = (cluster_idx0 >= 0) # (n_core, k)
456
+
457
+ q_g = q_g * mask
458
+
459
+ n_neigh = mask[:, 1:].sum(axis=1).astype(float) # (n_core,)
460
+ scale = np.where(n_neigh > 0, 1.0 / n_neigh, 0.0)
461
+ q_g[:, 1:] = q_g[:, 1:] * scale[:, None]
462
+
463
+ mu_ea = (coords_g * q_g[..., None]).sum(axis=1) # (n_core, 3)
464
+ mu_debye = mu_ea * CONSTANTS["ea_to_debye"]
465
+
466
+ out: Dict[str, Any] = {
467
+ "core_atom_type": [str(atom_types[i]) for i in core_idx0],
468
+ "core_atom_id": (core_idx0 + 1).astype(int),
469
+ "mu_x (debye)": mu_debye[:, 0],
470
+ "mu_y (debye)": mu_debye[:, 1],
471
+ "mu_z (debye)": mu_debye[:, 2],
472
+ }
473
+
474
+ volumes = np.full((core_idx0.size,), np.nan, dtype=float)
475
+ if mode == "polarization":
476
+ if volume_method == "bbox":
477
+ # broadcast-safe masking: (n_core, k, 3)
478
+ cc = np.where(mask[..., None], coords_g, np.nan)
479
+ mn = np.nanmin(cc, axis=1)
480
+ mx = np.nanmax(cc, axis=1)
481
+ d = mx - mn
482
+ volumes = d[:, 0] * d[:, 1] * d[:, 2]
483
+ else:
484
+ for i in range(core_idx0.size):
485
+ pts = coords_g[i][mask[i]]
486
+ volumes[i] = _convex_hull_volume(pts)
487
+
488
+ P = np.full_like(mu_ea, np.nan, dtype=float)
489
+ good = np.isfinite(volumes) & (volumes > 0)
490
+ if np.any(good):
491
+ P[good] = (mu_ea[good] / volumes[good, None]) * CONSTANTS["ea3_to_uC_cm2"]
492
+
493
+ out["P_x (uC/cm^2)"] = P[:, 0]
494
+ out["P_y (uC/cm^2)"] = P[:, 1]
495
+ out["P_z (uC/cm^2)"] = P[:, 2]
496
+ out["volume (angstrom^3)"] = volumes
497
+
498
+ return pd.DataFrame(out)
499
+
500
+
501
+ # -------------------------------------------------------------------------------------
502
+ # Public API: over multiple frames (fast)
503
+ # -------------------------------------------------------------------------------------
504
+
505
+ def dipoles_polarizations_over_multiple_frames(
506
+ xh: XmoloutHandler,
507
+ f7: Fort7Handler,
508
+ *,
509
+ scope: Scope = "total",
510
+ core_types: Optional[Sequence[str]] = None,
511
+ mode: Mode = "dipole",
512
+ volume_method: Optional[VolumeMethod] = None,
513
+ frames: Optional[Sequence[int]] = None,
514
+ every: int = 1,
515
+ ) -> pd.DataFrame:
516
+ """Compute dipoles or polarization over multiple frames (fast path).
517
+
518
+ Works on
519
+ --------
520
+ XmoloutHandler + Fort7Handler — ``xmolout`` + ``fort.7``
521
+
522
+ Parameters
523
+ ----------
524
+ scope : {"total", "local"}, default="total"
525
+ Whether to compute total system electrostatics or local clusters.
526
+ core_types : sequence of str, optional
527
+ Required when ``scope="local"``.
528
+ mode : {"dipole", "polarization"}, default="dipole"
529
+ Quantity to compute.
530
+ frames : sequence of int, optional
531
+ Frame indices to include.
532
+ every : int, default=1
533
+ Subsampling stride.
534
+
535
+ Returns
536
+ -------
537
+ pandas.DataFrame
538
+ Electrostatics results with frame and iteration metadata.
539
+
540
+ Examples
541
+ --------
542
+ >>> df = dipoles_polarizations_over_multiple_frames(xh, f7, mode="dipole")
543
+ """
544
+ if scope == "local" and (core_types is None or len(core_types) == 0):
545
+ raise ValueError("core_types must be provided when scope='local'.")
546
+
547
+ if volume_method is None:
548
+ volume_method = "bbox" if scope == "local" else "hull"
549
+
550
+ pre = _preload_electrostatics(xh, f7, frames=frames, every=every)
551
+ if pre["frame_index"].size == 0:
552
+ return pd.DataFrame()
553
+
554
+ fidx = pre["frame_index"]
555
+ iters = pre["iter"]
556
+ coords = pre["coords"]
557
+ charges = pre["charges"]
558
+
559
+ if scope == "total":
560
+ df = _total_dipole_calc(coords, charges, mode=mode, volume_method=volume_method)
561
+ df.insert(0, "iter", iters)
562
+ df.insert(0, "frame_index", fidx)
563
+ return df.reset_index(drop=True)
564
+
565
+ cnn = pre["cnn"]
566
+ types_list = pre["atom_types"]
567
+
568
+ rows: List[pd.DataFrame] = []
569
+ for i in range(len(fidx)):
570
+ df_one = _local_dipole_calc(
571
+ coords[i],
572
+ charges[i],
573
+ types_list[i],
574
+ cnn[i] if cnn.shape[2] > 0 else np.zeros((coords.shape[1], 0), dtype=np.int32),
575
+ core_types=core_types or [],
576
+ mode=mode,
577
+ volume_method=volume_method,
578
+ )
579
+ if df_one.empty:
580
+ continue
581
+ df_one.insert(0, "iter", int(iters[i]))
582
+ df_one.insert(0, "frame_index", int(fidx[i]))
583
+ rows.append(df_one)
584
+
585
+ if not rows:
586
+ return pd.DataFrame()
587
+ return pd.concat(rows, ignore_index=True)
588
+
589
+
590
+ # -------------------------------------------------------------------------------------
591
+ # Hysteresis: polarization vs electric field
592
+ # -------------------------------------------------------------------------------------
593
+
594
+ def polarization_field_analysis(
595
+ xh: XmoloutHandler,
596
+ f7: Fort7Handler,
597
+ f78: Fort78Handler,
598
+ ctrl: ControlHandler,
599
+ *,
600
+ field_var: str = "field_z",
601
+ aggregate: AggregateKind = None,
602
+ x_variable: str = "field_z",
603
+ y_variable: str = "P_z (uC/cm^2)",
604
+ ) -> Tuple[pd.DataFrame, pd.DataFrame, list[float], list[float]]:
605
+ """
606
+ Perform polarization–electric-field hysteresis analysis.
607
+
608
+ Works on
609
+ --------
610
+ XmoloutHandler + Fort7Handler + Fort78Handler + ControlHandler
611
+
612
+ Parameters
613
+ ----------
614
+ field_var : str, default="field_z"
615
+ Electric-field component to use.
616
+ aggregate : {"mean","max","min","last"}, optional
617
+ Aggregation mode over identical field values.
618
+ x_variable, y_variable : str
619
+ Columns used for zero-crossing detection.
620
+
621
+ Returns
622
+ -------
623
+ joint_df : pandas.DataFrame
624
+ Polarization and electric field per frame.
625
+ aggregated_df : pandas.DataFrame
626
+ Aggregated hysteresis curve.
627
+ y_zeros : list[float]
628
+ Zero crossings of polarization.
629
+ x_zeros : list[float]
630
+ Zero crossings of electric field.
631
+
632
+ Examples
633
+ --------
634
+ >>> joint, agg, p0, e0 = polarization_field_analysis(xh, f7, f78, ctrl)
635
+ """
636
+ pol_df = dipoles_polarizations_over_multiple_frames(
637
+ xh,
638
+ f7,
639
+ scope="total",
640
+ core_types=None,
641
+ mode="polarization",
642
+ volume_method="hull",
643
+ )
644
+ if pol_df.empty:
645
+ raise ValueError("No polarization data produced by electrostatics_over_frames.")
646
+ if "iter" not in pol_df.columns:
647
+ raise KeyError("electrostatics_over_frames output has no 'iter' column.")
648
+
649
+ pol_df = pol_df.sort_values("iter").reset_index(drop=True)
650
+
651
+ target_iters = pol_df["iter"].to_list()
652
+ series_E = match_electric_field_to_iout2(
653
+ f78,
654
+ ctrl,
655
+ target_iters=target_iters,
656
+ field_var=field_var,
657
+ )
658
+
659
+ # align by iter to be robust + convert units
660
+ series_E = series_E.reindex(pol_df["iter"].values) * const["electric_field_VA_to_MVcm"]
661
+ joint = pol_df.copy()
662
+ joint[field_var] = series_E.to_numpy(dtype=float)
663
+
664
+ if aggregate is None:
665
+ agg_df = joint.copy()
666
+ else:
667
+ if aggregate not in {"mean", "max", "min", "last"}:
668
+ raise ValueError("aggregate must be one of: mean|max|min|last (or None).")
669
+
670
+ g = joint.groupby(field_var, as_index=False)
671
+ if aggregate == "mean":
672
+ agg_df = g.mean(numeric_only=True)
673
+ elif aggregate == "max":
674
+ agg_df = g.max(numeric_only=True)
675
+ elif aggregate == "min":
676
+ agg_df = g.min(numeric_only=True)
677
+ else:
678
+ joint2 = joint.sort_values("iter").reset_index(drop=True)
679
+ agg_df = joint2.groupby(field_var, as_index=False).tail(1).reset_index(drop=True)
680
+
681
+ if x_variable not in agg_df.columns or y_variable not in agg_df.columns:
682
+ raise KeyError(f"Missing required columns '{x_variable}' or '{y_variable}' in aggregated data.")
683
+
684
+ y_zeros = find_zero_crossings(agg_df[x_variable].to_numpy(float), agg_df[y_variable].to_numpy(float))
685
+ x_zeros = find_zero_crossings(agg_df[y_variable].to_numpy(float), agg_df[x_variable].to_numpy(float))
686
+
687
+ return joint, agg_df, y_zeros, x_zeros
File without changes