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,706 @@
1
+ """
2
+ Atomic connectivity and bond-event analysis utilities.
3
+
4
+ This module provides tools for extracting, aggregating, and analyzing
5
+ atomic connectivities derived from ReaxFF ``fort.7`` data.
6
+
7
+ Connectivities describe the bonding network between atoms in each frame,
8
+ including bond partners and bond orders. The utilities here convert raw
9
+ fort.7 connectivity columns into tidy edge lists, adjacency tables,
10
+ time-series bond traces, and discrete bond formation/breakage events.
11
+
12
+ Typical use cases include:
13
+
14
+ - building bond (edge) lists with bond orders for chemical analysis
15
+ - aggregating connectivity statistics across frames
16
+ - tracking bond-order time series for specific atom pairs
17
+ - detecting bond formation and breakage events with noise suppression
18
+ - visual debugging of bond-event detection parameters
19
+ """
20
+
21
+
22
+ from __future__ import annotations
23
+ from typing import Iterable, List, Optional, Union, Literal, Tuple
24
+ import pandas as pd
25
+ import numpy as np
26
+
27
+ from reaxkit.utils.frame_utils import resolve_indices
28
+ from reaxkit.utils.numerical.moving_average import simple_moving_average, exponential_moving_average
29
+ from reaxkit.utils.numerical.signal_ops import schmitt_hysteresis, clean_flicker
30
+ from reaxkit.utils.media.plotter import single_plot
31
+
32
+ Indexish = Union[int, Iterable[int], None]
33
+
34
+ # ---- moved from fort7_analyzer: connectivity ----
35
+ def connection_list(
36
+ handler,
37
+ frames: Indexish = None,
38
+ iterations: Indexish = None,
39
+ min_bo: float = 0.0,
40
+ undirected: bool = True,
41
+ aggregate: Literal["max", "mean"] = "max",
42
+ include_self: bool = False,
43
+ ) -> pd.DataFrame:
44
+ """Build a tidy bond (edge) list with bond orders as weights.
45
+
46
+ Works on
47
+ --------
48
+ Fort7Handler — ``fort.7``
49
+
50
+ Parameters
51
+ ----------
52
+ handler : Fort7Handler
53
+ Parsed ``fort.7`` handler.
54
+ frames, iterations
55
+ Frame indices or iteration numbers to include.
56
+ min_bo : float, default=0.0
57
+ Minimum bond order to keep.
58
+ undirected : bool, default=True
59
+ If True, treat bonds as undirected and merge A–B / B–A duplicates.
60
+ aggregate : {"max", "mean"}, default="max"
61
+ Aggregation rule for bond order when merging duplicates.
62
+ include_self : bool, default=False
63
+ If True, keep self-edges (usually False).
64
+
65
+ Returns
66
+ -------
67
+ pandas.DataFrame
68
+ Tidy edge list with columns:
69
+ ``frame_idx``, ``iter``, ``src``, ``dst``, ``bo``, ``j``.
70
+
71
+ Examples
72
+ --------
73
+ >>> edges = connection_list(f7, frames=[0, 1], min_bo=0.3)
74
+ """
75
+ sim_df = handler.dataframe()
76
+ idx_list = resolve_indices(handler, frames=frames, iterations=iterations)
77
+
78
+ all_edges: List[pd.DataFrame] = []
79
+
80
+ for fi in idx_list:
81
+ atoms = handler._frames[fi]
82
+ iter = int(sim_df.iloc[fi]["iter"])
83
+ # Find how many cnn/BO columns exist for this frame
84
+ nb = int(sim_df.iloc[fi]["num_of_bonds"])
85
+ cnn_cols = [f"atom_cnn{j}" for j in range(1, nb + 1)]
86
+ bo_cols = [f"BO{j}" for j in range(1, nb + 1)]
87
+
88
+ # Sanity: skip if columns are missing
89
+ missing = [c for c in cnn_cols + bo_cols if c not in atoms.columns]
90
+ if missing:
91
+ # Skip silently (or raise) — here we skip this frame
92
+ continue
93
+
94
+ # Build edge blocks for each neighbor slot j
95
+ blocks: List[pd.DataFrame] = []
96
+ src_series = atoms["atom_num"].astype(int)
97
+
98
+ for j, (cnn_c, bo_c) in enumerate(zip(cnn_cols, bo_cols), start=1):
99
+ dst_series = atoms[cnn_c].astype(int)
100
+ bo_series = atoms[bo_c].astype(float)
101
+
102
+ dfj = pd.DataFrame(
103
+ {
104
+ "src": src_series.values,
105
+ "dst": dst_series.values,
106
+ "bo": bo_series.values,
107
+ "j": j,
108
+ }
109
+ )
110
+ blocks.append(dfj)
111
+
112
+ edges = pd.concat(blocks, ignore_index=True)
113
+
114
+ # Filter invalid/empty connections:
115
+ # - Some datasets mark no-connection with 0 or negative dst or bo<=0
116
+ mask = edges["dst"] > 0
117
+ if not include_self:
118
+ mask &= edges["dst"] != edges["src"]
119
+ if min_bo is not None:
120
+ mask &= edges["bo"] >= float(min_bo)
121
+ edges = edges.loc[mask].copy()
122
+
123
+ # Attach frame metadata
124
+ edges.insert(0, "iter", iter)
125
+ edges.insert(0, "frame_idx", fi)
126
+
127
+ # Canonicalize for undirected graphs and collapse duplicates
128
+ if undirected:
129
+ # Ensure src <= dst
130
+ src_min = edges[["src", "dst"]].min(axis=1)
131
+ dst_max = edges[["src", "dst"]].max(axis=1)
132
+ edges["src"], edges["dst"] = src_min, dst_max
133
+
134
+ # Combine duplicates (the same bond appears from both atoms)
135
+ by = ["frame_idx", "iter", "src", "dst"]
136
+ if aggregate == "mean":
137
+ agg = edges.groupby(by, as_index=False)["bo"].mean()
138
+ else:
139
+ agg = edges.groupby(by, as_index=False)["bo"].max()
140
+
141
+ # Keep one representative j (optional; not meaningful when aggregated)
142
+ agg["j"] = -1 # indicates aggregated
143
+ edges = agg
144
+
145
+ all_edges.append(edges)
146
+
147
+ if not all_edges:
148
+ return pd.DataFrame(columns=["frame_idx", "iter", "src", "dst", "bo", "j"])
149
+
150
+ # Concatenate all frames
151
+ out = pd.concat(all_edges, ignore_index=True)
152
+ # Sort for stability
153
+ out = out.sort_values(["frame_idx", "src", "dst", "j"], kind="stable").reset_index(drop=True)
154
+ return out
155
+
156
+ def connection_table(
157
+ handler,
158
+ frame: int,
159
+ min_bo: float = 0.0,
160
+ undirected: bool = True,
161
+ fill_value: float = 0.0,
162
+ ) -> pd.DataFrame:
163
+ """Build a dense adjacency (connectivity) table for a single frame.
164
+
165
+ Works on
166
+ --------
167
+ Fort7Handler — ``fort.7``
168
+
169
+ Parameters
170
+ ----------
171
+ handler : Fort7Handler
172
+ Parsed ``fort.7`` handler.
173
+ frame : int
174
+ Frame index to extract.
175
+ min_bo : float, default=0.0
176
+ Minimum bond order to include.
177
+ undirected : bool, default=True
178
+ Treat bonds as undirected.
179
+ fill_value : float, default=0.0
180
+ Value used for absent bonds.
181
+
182
+ Returns
183
+ -------
184
+ pandas.DataFrame
185
+ Adjacency-like table with index=source atom and columns=destination atom,
186
+ values equal to bond order.
187
+
188
+ Examples
189
+ --------
190
+ >>> tbl = connection_table(f7, frame=0, min_bo=0.2)
191
+ """
192
+ edges = connection_list(
193
+ handler,
194
+ frames=[frame],
195
+ iterations=None,
196
+ min_bo=min_bo,
197
+ undirected=undirected,
198
+ )
199
+ if edges.empty:
200
+ return pd.DataFrame()
201
+
202
+ # Use pivot to form a (possibly sparse) adjacency matrix
203
+ tbl = edges.pivot_table(
204
+ index="src",
205
+ columns="dst",
206
+ values="bo",
207
+ aggfunc="max",
208
+ fill_value=fill_value,
209
+ )
210
+ # Make it a regular DataFrame with sorted axes
211
+ tbl = tbl.sort_index(axis=0).sort_index(axis=1)
212
+ return tbl
213
+
214
+ def connection_stats_over_frames(
215
+ handler,
216
+ frames: Indexish = None,
217
+ iterations: Indexish = None,
218
+ min_bo: float = 0.0,
219
+ undirected: bool = True,
220
+ how: Literal["mean", "max", "count"] = "mean",
221
+ ) -> pd.DataFrame:
222
+ """Aggregate bond statistics across selected frames.
223
+
224
+ Works on
225
+ --------
226
+ Fort7Handler — ``fort.7``
227
+
228
+ Parameters
229
+ ----------
230
+ handler : Fort7Handler
231
+ Parsed ``fort.7`` handler.
232
+ frames, iterations
233
+ Frame indices or iteration numbers to include.
234
+ min_bo : float, default=0.0
235
+ Minimum bond order to consider.
236
+ undirected : bool, default=True
237
+ Treat bonds as undirected.
238
+ how : {"mean", "max", "count"}, default="mean"
239
+ Aggregation rule across frames.
240
+
241
+ Returns
242
+ -------
243
+ pandas.DataFrame
244
+ Table with columns ``src``, ``dst``, ``value`` representing the
245
+ aggregated bond metric.
246
+
247
+ Examples
248
+ --------
249
+ >>> stats = connection_stats_over_frames(f7, how="count")
250
+ """
251
+ edges = connection_list(
252
+ handler,
253
+ frames=frames,
254
+ iterations=iterations,
255
+ min_bo=min_bo,
256
+ undirected=undirected,
257
+ )
258
+ if edges.empty:
259
+ return pd.DataFrame(columns=["src", "dst", "value"])
260
+
261
+ by = ["src", "dst"]
262
+ if how == "count":
263
+ out = edges.groupby(by, as_index=False).size().rename(columns={"size": "value"})
264
+ elif how == "max":
265
+ out = edges.groupby(by, as_index=False)["bo"].max().rename(columns={"bo": "value"})
266
+ else: # mean
267
+ out = edges.groupby(by, as_index=False)["bo"].mean().rename(columns={"bo": "value"})
268
+ return out.sort_values(["src", "dst"], kind="stable").reset_index(drop=True)
269
+
270
+ # ---- moved helper (keep here since it’s bond-table specific) ----
271
+ def _pivot_to_tidy_bo(pivot: pd.DataFrame) -> pd.DataFrame:
272
+ """
273
+ Convert a (frame_idx, iter) x (src,dst) pivot to tidy:
274
+ columns: frame_idx, iter, src, dst, bo
275
+ Works across pandas versions (Series/DataFrame stack behavior).
276
+ """
277
+ # Try the modern stack; fall back to legacy
278
+ try:
279
+ stacked = pivot.stack(future_stack=True)
280
+ except TypeError:
281
+ stacked = pivot.stack()
282
+
283
+ # Case 1: Series → easy
284
+ if isinstance(stacked, pd.Series):
285
+ tidy = stacked.rename("bo").reset_index()
286
+ else:
287
+ # Case 2: DataFrame (some pandas builds) → if 1 col, rename; else manual
288
+ if stacked.shape[1] == 1:
289
+ value_col = stacked.columns[0]
290
+ tidy = stacked.rename(columns={value_col: "bo"}).reset_index()
291
+ else:
292
+ # Manual, bulletproof & fast enough for moderate sizes
293
+ rows = []
294
+ for (fi, it), row in pivot.iterrows():
295
+ for (src, dst), bo in row.items():
296
+ rows.append([fi, it, src, dst, bo])
297
+ tidy = pd.DataFrame(rows, columns=["frame_idx", "iter", "src", "dst", "bo"])
298
+ return tidy
299
+
300
+ # Normalize column names for stacked levels
301
+ # After reset_index we expect: frame_idx, iter, <src_level>, <dst_level>, bo
302
+ want = {"src", "dst"}
303
+ got = set(tidy.columns)
304
+ if not want.issubset(got):
305
+ mi_names = pivot.columns.names or ["src", "dst"]
306
+ rename_map = {}
307
+ if mi_names[0] in tidy.columns:
308
+ rename_map[mi_names[0]] = "src"
309
+ if mi_names[1] in tidy.columns:
310
+ rename_map[mi_names[1]] = "dst"
311
+ tidy = tidy.rename(columns=rename_map)
312
+
313
+ # Final fallback for unnamed levels (e.g., level_2/level_3)
314
+ if "src" not in tidy.columns or "dst" not in tidy.columns:
315
+ for c in list(tidy.columns):
316
+ if c.startswith("level_") and "src" not in tidy.columns:
317
+ tidy = tidy.rename(columns={c: "src"})
318
+ elif c.startswith("level_") and "dst" not in tidy.columns:
319
+ tidy = tidy.rename(columns={c: "dst"})
320
+
321
+ return tidy.sort_values(["frame_idx", "src", "dst"], kind="stable").reset_index(drop=True)
322
+
323
+
324
+ def bond_timeseries(
325
+ handler,
326
+ frames: Indexish = None,
327
+ iterations: Indexish = None,
328
+ undirected: bool = True,
329
+ bo_threshold: float = 0.0,
330
+ as_wide: bool = False,
331
+ ) -> pd.DataFrame:
332
+ """Track bond-order time series for all bonds across selected frames.
333
+
334
+ Missing bonds in a frame are filled with bond order zero.
335
+
336
+ Works on
337
+ --------
338
+ Fort7Handler — ``fort.7``
339
+
340
+ Parameters
341
+ ----------
342
+ handler : Fort7Handler
343
+ Parsed ``fort.7`` handler.
344
+ frames, iterations
345
+ Frame indices or iteration numbers to include.
346
+ undirected : bool, default=True
347
+ Treat bonds as undirected.
348
+ bo_threshold : float, default=0.0
349
+ Values below this threshold are set to zero.
350
+ as_wide : bool, default=False
351
+ If True, return a wide matrix (frames × bonds).
352
+
353
+ Returns
354
+ -------
355
+ pandas.DataFrame
356
+ Bond-order time series in tidy (long) or wide format.
357
+
358
+ Examples
359
+ --------
360
+ >>> ts = bond_timeseries(f7, bo_threshold=0.1)
361
+ """
362
+ # 1) Resolve frame indices in order
363
+ idx_list = resolve_indices(handler, frames=frames, iterations=iterations)
364
+ if not idx_list:
365
+ return pd.DataFrame(columns=["frame_idx", "iter", "src", "dst", "bo"] if not as_wide else [])
366
+
367
+ # 2) Build edge list for selected frames (keep all reported edges; we'll threshold later)
368
+ edges = connection_list(
369
+ handler,
370
+ frames=idx_list,
371
+ iterations=None, # idx_list already chosen
372
+ min_bo=0.0, # keep every reported bond; we'll zero later via bo_threshold
373
+ undirected=undirected,
374
+ include_self=False,
375
+ )
376
+ # Ensure unique rows (max BO if duplicated)
377
+ if not edges.empty:
378
+ edges = (
379
+ edges.groupby(["frame_idx", "iter", "src", "dst"], as_index=False)["bo"]
380
+ .max()
381
+ )
382
+
383
+ # 3) Build the full frame index (even if a frame reported no edges)
384
+ sim_df = handler.dataframe()
385
+ frame_meta = pd.DataFrame({
386
+ "frame_idx": idx_list,
387
+ "iter": [int(sim_df.iloc[i]["iter"]) for i in idx_list],
388
+ })
389
+ frame_meta = frame_meta.drop_duplicates().sort_values(["frame_idx"]).reset_index(drop=True)
390
+
391
+ # 4) If no edges at all in these frames, return zeros-only structure
392
+ if edges.empty:
393
+ if as_wide:
394
+ # no bonds → empty columns; just return index of frames
395
+ wide = frame_meta.set_index(["frame_idx", "iter"])
396
+ return wide
397
+ else:
398
+ # no bonds → empty tidy table
399
+ return pd.DataFrame(columns=["frame_idx", "iter", "src", "dst", "bo"])
400
+
401
+ # 5) Create a pivot (frames × bonds), then reindex to include *all* bonds and *all* frames
402
+ pivot = edges.pivot_table(
403
+ index=["frame_idx", "iter"],
404
+ columns=["src", "dst"],
405
+ values="bo",
406
+ aggfunc="max",
407
+ )
408
+
409
+ # All bonds observed in any selected frame
410
+ all_bonds: List[Tuple[int, int]] = sorted(set(zip(edges["src"], edges["dst"])))
411
+ # Reindex rows to ensure all frames present in the matrix (even those w/o edges)
412
+ pivot = pivot.reindex(
413
+ index=pd.MultiIndex.from_frame(frame_meta[["frame_idx", "iter"]]),
414
+ fill_value=0.0
415
+ )
416
+ # Reindex columns to ensure all bonds present
417
+ pivot = pivot.reindex(
418
+ columns=pd.MultiIndex.from_tuples(all_bonds, names=["src", "dst"]),
419
+ fill_value=0.0
420
+ )
421
+
422
+ # Force any remaining gaps to zero (handles rare pandas edge cases)
423
+ pivot = pivot.fillna(0.0)
424
+
425
+ # 6) Threshold-to-zero for small BO (noise floor)
426
+ if bo_threshold > 0.0:
427
+ pivot = pivot.mask(pivot < float(bo_threshold), 0.0)
428
+
429
+ # 7) Return in desired shape
430
+ if as_wide:
431
+ pivot.columns = [f"{s}-{d}" for (s, d) in pivot.columns.to_list()]
432
+ pivot = pivot.sort_index(level=[0, 1])
433
+ return pivot
434
+
435
+ # Robust tidy conversion
436
+ tidy = _pivot_to_tidy_bo(pivot)
437
+ return tidy
438
+
439
+ # ---- events: inline smoothing, use utils for hysteresis/flicker ----
440
+ def bond_events(
441
+ handler,
442
+ frames: Indexish = None,
443
+ iterations: Indexish = None,
444
+ *,
445
+ src: Optional[int] = None,
446
+ dst: Optional[int] = None,
447
+ threshold: float = 0.35,
448
+ hysteresis: float = 0.05,
449
+ smooth: Optional[Literal["ma","ema"]] = "ma",
450
+ window: int = 7,
451
+ ema_alpha: Optional[float] = None,
452
+ min_run: int = 3,
453
+ xaxis: Literal["iter","frame"] = "iter",
454
+ undirected: bool = True,
455
+ ) -> pd.DataFrame:
456
+ """Detect bond formation and breakage events from bond-order time series.
457
+
458
+ Events are identified using optional smoothing, Schmitt-trigger hysteresis,
459
+ and flicker suppression to avoid noise-induced toggling.
460
+
461
+ Works on
462
+ --------
463
+ Fort7Handler — ``fort.7``
464
+
465
+ Parameters
466
+ ----------
467
+ handler : Fort7Handler
468
+ Parsed ``fort.7`` handler.
469
+ src, dst : int, optional
470
+ Restrict analysis to a specific atom pair.
471
+ threshold : float, default=0.35
472
+ Bond-order threshold for bonded state.
473
+ hysteresis : float, default=0.05
474
+ Hysteresis half-width around the threshold.
475
+ smooth : {"ma", "ema"}, optional
476
+ Smoothing method applied before event detection.
477
+ window : int, default=7
478
+ Smoothing window size.
479
+ min_run : int, default=3
480
+ Minimum run length to suppress flicker.
481
+ xaxis : {"iter", "frame"}, default="iter"
482
+ X-axis used in the output.
483
+ undirected : bool, default=True
484
+ Treat bonds as undirected.
485
+
486
+ Returns
487
+ -------
488
+ pandas.DataFrame
489
+ Event table with columns:
490
+ ``src``, ``dst``, ``event``, ``frame_idx``, ``iter``,
491
+ ``x_axis``, ``bo_at_event``.
492
+
493
+ Examples
494
+ --------
495
+ >>> ev = bond_events(f7, src=1, dst=2)
496
+ """
497
+ ts = bond_timeseries(
498
+ handler,
499
+ frames=frames,
500
+ iterations=iterations,
501
+ undirected=undirected,
502
+ bo_threshold=0.0,
503
+ as_wide=False,
504
+ )
505
+ if ts.empty:
506
+ return pd.DataFrame(columns=["src","dst","event","frame_idx","iter","x_axis","bo_at_event","threshold","hysteresis"])
507
+
508
+ if src is not None and dst is not None:
509
+ a, b = (src, dst) if (not undirected or src <= dst) else (dst, src)
510
+ ts = ts[(ts["src"] == a) & (ts["dst"] == b)].copy()
511
+ if ts.empty:
512
+ return pd.DataFrame(columns=["src","dst","event","frame_idx","iter","x_axis","bo_at_event","threshold","hysteresis"])
513
+
514
+ groups = ts.groupby(["src","dst"], sort=False)
515
+ xcol = "iter" if xaxis == "iter" else "frame_idx"
516
+ out_rows: List[pd.DataFrame] = []
517
+
518
+ for (a, b), g in groups:
519
+ g = g.sort_values(["frame_idx"]).reset_index(drop=True)
520
+ x = g[xcol].to_numpy()
521
+ bo = g["bo"].to_numpy(dtype=float)
522
+
523
+ # --- inline smoothing (replaces _smooth_series) ---
524
+ if smooth is None:
525
+ bo_s = bo
526
+ elif smooth == "ema":
527
+ bo_s = exponential_moving_average(pd.Series(bo), window=window, alpha=ema_alpha, adjust=False).to_numpy()
528
+ else: # "ma"
529
+ bo_s = simple_moving_average(pd.Series(bo), window=window, center=True, min_periods=1).to_numpy()
530
+
531
+ # --- hysteresis & flicker clean via utils ---
532
+ st = schmitt_hysteresis(bo_s, th=threshold, hys=hysteresis)
533
+ st = clean_flicker(st, min_run=min_run)
534
+
535
+ prev = np.r_[st[0], st[:-1]]
536
+ rising = (~prev) & st
537
+ falling = prev & (~st)
538
+ mask = rising | falling
539
+ if not mask.any():
540
+ continue
541
+
542
+ ev = pd.DataFrame({
543
+ xcol: x[mask],
544
+ "event": np.where(rising[mask], "formation", "breakage"),
545
+ "bo_at_event": bo_s[mask],
546
+ })
547
+ ev.insert(0, "dst", b)
548
+ ev.insert(0, "src", a)
549
+
550
+ merge_cols = ["frame_idx", "iter"]
551
+ if xcol not in merge_cols:
552
+ merge_cols.append(xcol)
553
+ meta = g[merge_cols].drop_duplicates(subset=[xcol])
554
+ ev = ev.merge(meta, on=xcol, how="left")
555
+ ev["x_axis"] = ev[xcol]
556
+ ev["threshold"] = float(threshold)
557
+ ev["hysteresis"] = float(hysteresis)
558
+
559
+ out_rows.append(ev[["src","dst","event","frame_idx","iter","x_axis","bo_at_event","threshold","hysteresis"]])
560
+
561
+ if not out_rows:
562
+ return pd.DataFrame(columns=["src","dst","event","frame_idx","iter","x_axis","bo_at_event","threshold","hysteresis"])
563
+
564
+ out = pd.concat(out_rows, ignore_index=True)
565
+ return out.sort_values(["src","dst","x_axis","event"], kind="stable").reset_index(drop=True)
566
+
567
+ def _bond_events_single(handler, src: int, dst: int, **kwargs) -> pd.DataFrame:
568
+ """Convenience wrapper for bond-event detection on a single atom pair.
569
+
570
+ Works on
571
+ --------
572
+ Fort7Handler — ``fort.7``
573
+
574
+ Parameters
575
+ ----------
576
+ handler : Fort7Handler
577
+ Parsed ``fort.7`` handler.
578
+ src, dst : int
579
+ Atom indices defining the bond.
580
+
581
+ Returns
582
+ -------
583
+ pandas.DataFrame
584
+ Bond formation and breakage events for the specified pair.
585
+
586
+ Examples
587
+ --------
588
+ >>> ev = _bond_events_single(f7, 1, 2)
589
+ """
590
+ return bond_events(handler, src=src, dst=dst, **kwargs)
591
+
592
+ def debug_bond_trace_overlay(
593
+ handler,
594
+ src: int,
595
+ dst: int,
596
+ *,
597
+ smooth: str = "ema", # "ema" or "ma"
598
+ window: int = 8,
599
+ hysteresis: float = 0.05,
600
+ threshold: float = 0.10,
601
+ min_run: int = 0, # >=2 to match bond_events behavior
602
+ xaxis: str = "iter", # "iter" or "frame"
603
+ save: str | None = None, # file path OR directory; None -> show
604
+ ):
605
+ """Plot a diagnostic overlay for a single bond-order time series.
606
+
607
+ The plot shows raw and smoothed bond order, hysteresis bands, and
608
+ detected formation/breakage events. This is intended for tuning
609
+ event-detection parameters.
610
+
611
+ Works on
612
+ --------
613
+ Fort7Handler — ``fort.7``
614
+
615
+ Parameters
616
+ ----------
617
+ handler : Fort7Handler
618
+ Parsed ``fort.7`` handler.
619
+ src, dst : int
620
+ Atom indices defining the bond.
621
+ smooth, window
622
+ Smoothing method and window size.
623
+ hysteresis, threshold
624
+ Event-detection parameters.
625
+ save : str, optional
626
+ File path to save the plot. If None, the plot is shown interactively.
627
+
628
+ Returns
629
+ -------
630
+ None
631
+
632
+ Examples
633
+ --------
634
+ >>> debug_bond_trace_overlay(f7, 1, 2, threshold=0.2)
635
+ """
636
+
637
+ # --- data ---
638
+ a, b = (src, dst) if src <= dst else (dst, src)
639
+ ts = bond_timeseries(handler, as_wide=False)
640
+ g = ts[(ts["src"] == a) & (ts["dst"] == b)].sort_values("iter")
641
+ if g.empty:
642
+ print(f"No data for bond {a}-{b}.")
643
+ return
644
+
645
+ x = g["iter"].to_numpy() if xaxis == "iter" else g["frame_idx"].to_numpy()
646
+ y = g["bo"].to_numpy(dtype=float)
647
+
648
+ # --- smoothing ---
649
+ y_s = (exponential_moving_average(y, window=window)
650
+ if smooth == "ema" else simple_moving_average(y, window=window)).to_numpy()
651
+
652
+ th, hys = float(threshold), float(hysteresis)
653
+ th_on, th_off = th + hys/2.0, th - hys/2.0
654
+
655
+ # --- hysteresis state & events on *smoothed* series ---
656
+ def _schmitt(sig: np.ndarray, base_th: float, band: float) -> np.ndarray:
657
+ on, off = base_th + band/2.0, base_th - band/2.0
658
+ st = np.zeros_like(sig, dtype=bool)
659
+ cur = sig[0] >= on
660
+ for i, v in enumerate(sig):
661
+ if not cur and v >= on:
662
+ cur = True
663
+ elif cur and v <= off:
664
+ cur = False
665
+ st[i] = cur
666
+ return st
667
+
668
+ st = _schmitt(y_s, th, hys)
669
+ if min_run and min_run > 1:
670
+ st = clean_flicker(st, min_run=min_run)
671
+
672
+ prev = np.r_[st[0], st[:-1]]
673
+ rising = (~prev) & st # formation
674
+ falling = prev & (~st) # breakage
675
+ n_form, n_break = int(rising.sum()), int(falling.sum())
676
+
677
+ # --- build series for single_plot (multi-series) ---
678
+ series = [
679
+ {'x': x, 'y': y, 'label': 'raw', 'marker': '.', 'linewidth': 0, 'markersize': 3, 'alpha': 0.75},
680
+ {'x': x, 'y': y_s, 'label': f'{smooth} (w={window})', 'marker': None, 'linewidth': 1.6, 'alpha': 1.0},
681
+ ]
682
+ # event markers (as point-only series)
683
+ if n_form:
684
+ series.append({'x': x[rising], 'y': y_s[rising], 'label': f'formation ×{n_form}', 'marker': '^', 'linewidth': 0, 'markersize': 7, 'alpha': 1.0})
685
+ if n_break:
686
+ series.append({'x': x[falling], 'y': y_s[falling], 'label': f'breakage ×{n_break}', 'marker': 'v', 'linewidth': 0, 'markersize': 7, 'alpha': 1.0})
687
+
688
+ # horizontal ON/OFF bands
689
+ hlines = [
690
+ {'y': th_on, 'label': f'ON ≥ {th_on:.3f}', 'linestyle': '--', 'linewidth': 1},
691
+ {'y': th_off, 'label': f'OFF ≤ {th_off:.3f}', 'linestyle': '--', 'linewidth': 1},
692
+ ]
693
+
694
+ title = (f"Bond {a}-{b} | th={th:.3f}, hyst={hys:.3f} | "
695
+ f"min_run={min_run} | events: +{n_form}/-{n_break}")
696
+
697
+ single_plot(
698
+ series=series,
699
+ hlines=hlines,
700
+ title=title,
701
+ xlabel=("iter" if xaxis == "iter" else "frame"),
702
+ ylabel="BO",
703
+ save=save,
704
+ legend=True,
705
+ figsize=(9.0, 3.8),
706
+ )