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,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
|