conformatlas 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 (53) hide show
  1. conformatlas/__init__.py +32 -0
  2. conformatlas/__main__.py +8 -0
  3. conformatlas/api.py +647 -0
  4. conformatlas/basins.py +396 -0
  5. conformatlas/bootstrap.py +439 -0
  6. conformatlas/cli.py +1388 -0
  7. conformatlas/comparison.py +450 -0
  8. conformatlas/comparison_stats.py +319 -0
  9. conformatlas/config.py +572 -0
  10. conformatlas/convergence.py +390 -0
  11. conformatlas/descriptors.py +143 -0
  12. conformatlas/errors.py +146 -0
  13. conformatlas/examples_gen.py +212 -0
  14. conformatlas/features.py +399 -0
  15. conformatlas/fel.py +200 -0
  16. conformatlas/gromacs.py +126 -0
  17. conformatlas/kinetics.py +210 -0
  18. conformatlas/mda_analysis.py +178 -0
  19. conformatlas/models.py +317 -0
  20. conformatlas/modes.py +192 -0
  21. conformatlas/msm.py +1087 -0
  22. conformatlas/ndx.py +53 -0
  23. conformatlas/pca.py +406 -0
  24. conformatlas/pipeline.py +2116 -0
  25. conformatlas/plotting.py +1554 -0
  26. conformatlas/plotting_extra.py +365 -0
  27. conformatlas/plotting_interactive.py +523 -0
  28. conformatlas/plotting_msm.py +336 -0
  29. conformatlas/plotting_residue.py +151 -0
  30. conformatlas/progress.py +198 -0
  31. conformatlas/provenance.py +172 -0
  32. conformatlas/py.typed +1 -0
  33. conformatlas/report.py +858 -0
  34. conformatlas/representatives.py +358 -0
  35. conformatlas/residue_analysis.py +716 -0
  36. conformatlas/results_io.py +736 -0
  37. conformatlas/reweighting.py +919 -0
  38. conformatlas/streaming.py +733 -0
  39. conformatlas/superpose.py +109 -0
  40. conformatlas/templates/assets/NGL_LICENSE.txt +21 -0
  41. conformatlas/templates/assets/ngl.js +88 -0
  42. conformatlas/templates/report.html.j2 +605 -0
  43. conformatlas/timeseries.py +168 -0
  44. conformatlas/trajectory.py +622 -0
  45. conformatlas/uncertainty.py +181 -0
  46. conformatlas/utils.py +185 -0
  47. conformatlas/xpm.py +214 -0
  48. conformatlas-1.0.0.dist-info/METADATA +224 -0
  49. conformatlas-1.0.0.dist-info/RECORD +53 -0
  50. conformatlas-1.0.0.dist-info/WHEEL +5 -0
  51. conformatlas-1.0.0.dist-info/entry_points.txt +2 -0
  52. conformatlas-1.0.0.dist-info/licenses/LICENSE +21 -0
  53. conformatlas-1.0.0.dist-info/top_level.txt +1 -0
conformatlas/basins.py ADDED
@@ -0,0 +1,396 @@
1
+ """Topological basin detection, watershed segmentation, and state assignment."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from scipy.ndimage import distance_transform_edt
9
+ from scipy.ndimage import label as ndi_label
10
+
11
+ try:
12
+ from skimage.feature import peak_local_max
13
+ from skimage.segmentation import watershed
14
+
15
+ HAS_SKIMAGE = True
16
+ except ImportError:
17
+ HAS_SKIMAGE = False
18
+
19
+ from conformatlas.models import BasinState, FELGrid
20
+ from conformatlas.utils import R_GAS_CONSTANT
21
+
22
+ logger = logging.getLogger("conformatlas.basins")
23
+
24
+
25
+ def pairwise_saddles(labels: np.ndarray, free_energy: np.ndarray) -> dict[tuple[int, int], float]:
26
+ """Lowest crossing height between every pair of 4-connected neighbouring basins.
27
+
28
+ The crossing height of a boundary edge is the higher of its two bins; the saddle
29
+ of a pair is the minimum over all its boundary edges. Keys are (low_id, high_id).
30
+ """
31
+ fe = np.where(np.isfinite(free_energy), free_energy, np.inf)
32
+ saddles: dict[tuple[int, int], float] = {}
33
+ for a, b, fa, fb in (
34
+ (labels[:, :-1], labels[:, 1:], fe[:, :-1], fe[:, 1:]),
35
+ (labels[:-1, :], labels[1:, :], fe[:-1, :], fe[1:, :]),
36
+ ):
37
+ diff = (a != b) & (a > 0) & (b > 0)
38
+ if not np.any(diff):
39
+ continue
40
+ crossing = np.maximum(fa[diff], fb[diff])
41
+ for la, lb, h in zip(a[diff], b[diff], crossing):
42
+ key = (int(min(la, lb)), int(max(la, lb)))
43
+ if np.isfinite(h) and h < saddles.get(key, np.inf):
44
+ saddles[key] = float(h)
45
+ return saddles
46
+
47
+
48
+ def merge_shallow_basins(
49
+ basin_labels: np.ndarray,
50
+ free_energy: np.ndarray,
51
+ min_barrier: float,
52
+ ) -> np.ndarray:
53
+ """Merge adjacent watershed basins separated by a barrier lower than ``min_barrier``.
54
+
55
+ Watershed segmentation splits a single broad minimum into several basins
56
+ whenever histogram noise creates shallow ripples. Two basins are only kept
57
+ apart if the lowest saddle on their shared boundary lies at least
58
+ ``min_barrier`` (kJ/mol) above the minimum of the shallower basin.
59
+ Merging proceeds iteratively from the shallowest barrier upwards.
60
+ """
61
+ labels = basin_labels.copy()
62
+ fe = np.where(np.isfinite(free_energy), free_energy, np.inf)
63
+
64
+ while True:
65
+ ids = [int(i) for i in np.unique(labels) if i > 0]
66
+ if len(ids) < 2:
67
+ return labels
68
+ minima = {i: float(np.min(fe[labels == i])) for i in ids}
69
+ saddles = pairwise_saddles(labels, fe)
70
+ if not saddles:
71
+ return labels
72
+
73
+ best_pair, best_barrier = None, np.inf
74
+ for (i, j), h in saddles.items():
75
+ barrier = h - max(minima[i], minima[j])
76
+ if barrier < best_barrier:
77
+ best_pair, best_barrier = (i, j), barrier
78
+
79
+ if best_pair is None or best_barrier >= min_barrier:
80
+ return labels
81
+
82
+ i, j = best_pair
83
+ keep, drop = (i, j) if minima[i] <= minima[j] else (j, i)
84
+ labels[labels == drop] = keep
85
+ logger.debug(
86
+ f"Merged basin {drop} into {keep} (barrier {best_barrier:.2f} kJ/mol < {min_barrier:.2f})."
87
+ )
88
+
89
+
90
+ def frame_bin_indices(
91
+ fel_grid: FELGrid, cv1: np.ndarray, cv2: np.ndarray
92
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
93
+ """Grid bin (x_idx, y_idx) of every frame plus a validity mask.
94
+
95
+ ``np.histogram2d`` treats the right-most edge as inclusive, so a value exactly
96
+ on it belongs to the last bin.
97
+ """
98
+ cv1 = np.asarray(cv1, dtype=np.float64)
99
+ cv2 = np.asarray(cv2, dtype=np.float64)
100
+ x_idx = np.digitize(cv1, fel_grid.x_edges) - 1
101
+ y_idx = np.digitize(cv2, fel_grid.y_edges) - 1
102
+ nx, ny = len(fel_grid.x_centers), len(fel_grid.y_centers)
103
+ x_idx[(x_idx == nx) & (cv1 == fel_grid.x_edges[-1])] = nx - 1
104
+ y_idx[(y_idx == ny) & (cv2 == fel_grid.y_edges[-1])] = ny - 1
105
+ valid = (x_idx >= 0) & (x_idx < nx) & (y_idx >= 0) & (y_idx < ny)
106
+ return x_idx, y_idx, valid
107
+
108
+
109
+ def compute_state_barriers(
110
+ basin_labels: np.ndarray,
111
+ free_energy: np.ndarray,
112
+ states: list[BasinState],
113
+ ) -> pd.DataFrame:
114
+ """Saddle heights and barriers between every pair of ADJACENT major states.
115
+
116
+ Columns: state_i, state_j, saddle_kJ_mol, barrier_from_i (saddle - min_i),
117
+ barrier_from_j. Pairs without a shared boundary are absent (separated by
118
+ unsampled space, so no barrier estimate is possible).
119
+ """
120
+ saddles = pairwise_saddles(basin_labels, free_energy)
121
+ by_id = {s.basin_id: s for s in states if s.basin_id > 0}
122
+ rows = []
123
+ for (a, b), h in sorted(saddles.items()):
124
+ if a in by_id and b in by_id:
125
+ si, sj = by_id[a], by_id[b]
126
+ rows.append(
127
+ {
128
+ "state_i": si.label,
129
+ "state_j": sj.label,
130
+ "saddle_kJ_mol": h,
131
+ "barrier_from_i_kJ_mol": h - si.min_free_energy,
132
+ "barrier_from_j_kJ_mol": h - sj.min_free_energy,
133
+ }
134
+ )
135
+ return pd.DataFrame(
136
+ rows,
137
+ columns=[
138
+ "state_i",
139
+ "state_j",
140
+ "saddle_kJ_mol",
141
+ "barrier_from_i_kJ_mol",
142
+ "barrier_from_j_kJ_mol",
143
+ ],
144
+ )
145
+
146
+
147
+ def detect_energy_basins(
148
+ fel_grid: FELGrid,
149
+ projections: pd.DataFrame,
150
+ min_state_population: float = 0.05,
151
+ max_states: int = 10,
152
+ min_peak_distance: int = 3,
153
+ min_barrier: float | None = None,
154
+ weights: np.ndarray | None = None,
155
+ cv_columns: tuple[str, str] = ("PC1", "PC2"),
156
+ ) -> tuple[list[BasinState], float, pd.DataFrame, np.ndarray]:
157
+ """Detect local free energy minima and segment conformational basins using watershed.
158
+
159
+ Parameters
160
+ ----------
161
+ fel_grid : FELGrid
162
+ The computed free energy landscape.
163
+ projections : pd.DataFrame
164
+ DataFrame containing frame projections: ['system', 'replicate', 'frame', 'time_ps', 'PC1', 'PC2', ...].
165
+ min_state_population : float
166
+ Minimum population fraction required to classify as a major state (default: 0.05 / 5%).
167
+ max_states : int
168
+ Maximum number of major states to report.
169
+ min_peak_distance : int
170
+ Minimum grid bin distance between adjacent local minima.
171
+ min_barrier : float, optional
172
+ Minimum free-energy barrier (kJ/mol) separating two basins; shallower
173
+ neighbours are merged. Defaults to 1 kT at the grid temperature.
174
+ weights : array, optional
175
+ Per-frame statistical weights (reweighted ensembles); populations become
176
+ weighted fractions.
177
+
178
+ Returns
179
+ -------
180
+ Tuple[List[BasinState], float, pd.DataFrame, np.ndarray]
181
+ (states_list, unassigned_fraction, frame_assignments_df, basin_map_2d)
182
+ """
183
+ if not HAS_SKIMAGE:
184
+ raise RuntimeError("scikit-image is required for topological watershed basin detection.")
185
+
186
+ prob = fel_grid.probability.copy()
187
+ fe = fel_grid.free_energy.copy()
188
+ occupancy = fel_grid.occupancy_mask if fel_grid.occupancy_mask is not None else ~np.isnan(fe)
189
+
190
+ # Clean free energy for watershed: replace NaN with high energy barrier
191
+ fe_clean = np.where(
192
+ np.isnan(fe), np.nanmax(fe) * 1.5 if not np.all(np.isnan(fe)) else 1000.0, fe
193
+ )
194
+
195
+ # Detect peaks in probability density (corresponds to energy minima)
196
+ # peak_local_max returns (row, col) = (y_idx, x_idx)
197
+ min_dist = max(1, min_peak_distance)
198
+ # exclude_border must be False: a basin at the extreme of PC1/PC2 sits at
199
+ # the edge of the grid by construction and would otherwise lose its marker.
200
+ peak_coords = peak_local_max(
201
+ prob, min_distance=min_dist, threshold_rel=0.01, exclude_border=False
202
+ )
203
+
204
+ if len(peak_coords) == 0:
205
+ # Fallback: global maximum of probability
206
+ max_idx = np.unravel_index(np.nanargmax(prob), prob.shape)
207
+ peak_coords = np.array([max_idx])
208
+
209
+ # Create markers image for watershed
210
+ support = np.isfinite(fe) | occupancy
211
+
212
+ # Every connected region of sampled space must own at least one marker;
213
+ # otherwise watershed cannot label it. Add the densest bin of any region
214
+ # that has none.
215
+ components, n_components = ndi_label(support)
216
+ peak_list = [tuple(pc) for pc in peak_coords]
217
+ marked = {components[r, c] for r, c in peak_list}
218
+ for comp_id in range(1, n_components + 1):
219
+ if comp_id in marked:
220
+ continue
221
+ comp_prob = np.where(components == comp_id, prob, -np.inf)
222
+ peak_list.append(tuple(np.unravel_index(np.argmax(comp_prob), prob.shape)))
223
+
224
+ markers = np.zeros(prob.shape, dtype=np.int32)
225
+ for idx, (r, c) in enumerate(peak_list, start=1):
226
+ markers[r, c] = idx
227
+
228
+ # Run watershed segmentation on the clean free energy surface. The mask is
229
+ # the finite-energy support (the smoothed support when smoothing is on),
230
+ # which is connected; the raw occupancy mask is a sparse scatter of islands
231
+ # for short trajectories and would leave most occupied bins unlabelled.
232
+ basin_labels = watershed(fe_clean, markers=markers, mask=support)
233
+
234
+ # Merge basins separated by barriers below the significance threshold.
235
+ if min_barrier is None:
236
+ min_barrier = R_GAS_CONSTANT * fel_grid.temperature # 1 kT
237
+ n_before = len(np.unique(basin_labels[basin_labels > 0]))
238
+ basin_labels = merge_shallow_basins(basin_labels, fe, min_barrier)
239
+ n_after = len(np.unique(basin_labels[basin_labels > 0]))
240
+ if n_after < n_before:
241
+ logger.info(
242
+ f"Merged {n_before - n_after} shallow watershed basins "
243
+ f"(barrier < {min_barrier:.2f} kJ/mol); {n_after} remain."
244
+ )
245
+
246
+ # Any occupied bin that watershed could not reach from a marker (an island
247
+ # disconnected from the support) inherits the label of the nearest labelled
248
+ # bin, so every sampled frame belongs to a basin.
249
+ orphan = occupancy & (basin_labels == 0)
250
+ if np.any(orphan) and np.any(basin_labels > 0):
251
+ _, (near_r, near_c) = distance_transform_edt(basin_labels == 0, return_indices=True)
252
+ basin_labels[orphan] = basin_labels[near_r[orphan], near_c[orphan]]
253
+ basin_labels = np.where(occupancy, basin_labels, 0)
254
+
255
+ # Assign each frame in projections to its basin
256
+ pc1_vals = projections[cv_columns[0]].to_numpy()
257
+ pc2_vals = projections[cv_columns[1]].to_numpy()
258
+ total_frames = len(projections)
259
+
260
+ # Bin indices
261
+ x_idx, y_idx, valid_bounds = frame_bin_indices(fel_grid, pc1_vals, pc2_vals)
262
+ frame_basin_ids = np.full(total_frames, -1, dtype=np.int32)
263
+ valid_indices = np.where(valid_bounds)[0]
264
+ frame_basin_ids[valid_indices] = basin_labels[y_idx[valid_indices], x_idx[valid_indices]]
265
+
266
+ # Count population per basin
267
+ if weights is not None:
268
+ w = np.asarray(weights, dtype=np.float64)
269
+ if len(w) != total_frames:
270
+ raise ValueError(f"weights has {len(w)} entries for {total_frames} frames.")
271
+ w_total = w.sum()
272
+ else:
273
+ w = np.ones(total_frames, dtype=np.float64)
274
+ w_total = float(total_frames)
275
+ unique_ids = np.unique(frame_basin_ids)
276
+ basin_pop_dict = {
277
+ int(uid): float(w[frame_basin_ids == uid].sum() / w_total) for uid in unique_ids if uid > 0
278
+ }
279
+
280
+ # Sort basins by population descending
281
+ sorted_basins = sorted(basin_pop_dict.items(), key=lambda item: item[1], reverse=True)
282
+
283
+ # Filter major states
284
+ major_basins = [b_id for b_id, pop in sorted_basins if pop >= min_state_population][:max_states]
285
+ if not major_basins and sorted_basins:
286
+ # If no basin passes threshold, retain top basin as State A
287
+ major_basins = [sorted_basins[0][0]]
288
+
289
+ states: list[BasinState] = []
290
+ state_letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
291
+
292
+ # Map frame assignments to human readable state labels
293
+ assigned_labels = []
294
+ state_lookup = {}
295
+ for idx, b_id in enumerate(major_basins):
296
+ label = f"State {state_letters[idx % len(state_letters)]}"
297
+ state_lookup[b_id] = label
298
+
299
+ for b_id in frame_basin_ids:
300
+ if b_id in state_lookup:
301
+ assigned_labels.append(state_lookup[b_id])
302
+ else:
303
+ assigned_labels.append("Unassigned")
304
+
305
+ frame_assignments_df = projections.copy()
306
+ frame_assignments_df["basin_id"] = frame_basin_ids
307
+ frame_assignments_df["state"] = assigned_labels
308
+
309
+ major_total_pop = 0.0
310
+
311
+ for state_idx, b_id in enumerate(major_basins):
312
+ label = state_lookup[b_id]
313
+ pop_fraction = basin_pop_dict[b_id]
314
+ major_total_pop += pop_fraction
315
+
316
+ # Locate minimum energy within this basin
317
+ basin_mask = basin_labels == b_id
318
+ sub_fe = np.where(basin_mask, fe, np.nan)
319
+ min_y_idx, min_x_idx = np.unravel_index(np.nanargmin(sub_fe), sub_fe.shape)
320
+
321
+ min_pc1 = float(fel_grid.x_centers[min_x_idx])
322
+ min_pc2 = float(fel_grid.y_centers[min_y_idx])
323
+ min_fe_val = float(fel_grid.free_energy[min_y_idx, min_x_idx])
324
+
325
+ # Find closest real trajectory frame
326
+ state_frames = frame_assignments_df[frame_assignments_df["state"] == label]
327
+ st_pc1 = state_frames[cv_columns[0]].to_numpy()
328
+ st_pc2 = state_frames[cv_columns[1]].to_numpy()
329
+ distances = np.sqrt((st_pc1 - min_pc1) ** 2 + (st_pc2 - min_pc2) ** 2)
330
+
331
+ best_idx = np.argmin(distances)
332
+ best_row = state_frames.iloc[best_idx]
333
+
334
+ states.append(
335
+ BasinState(
336
+ state_id=state_idx + 1,
337
+ basin_id=int(b_id),
338
+ label=label,
339
+ population=pop_fraction,
340
+ min_pc1=min_pc1,
341
+ min_pc2=min_pc2,
342
+ min_free_energy=min_fe_val,
343
+ representative_replicate=str(best_row["replicate"]),
344
+ representative_frame=int(best_row["frame"]),
345
+ representative_time_ps=float(best_row["time_ps"]),
346
+ representative_distance=float(distances[best_idx]),
347
+ )
348
+ )
349
+
350
+ unassigned_fraction = max(0.0, 1.0 - major_total_pop)
351
+
352
+ logger.info(
353
+ f"Identified {len(states)} major states (pop >= {min_state_population * 100:.1f}%): "
354
+ + ", ".join([f"{s.label}: {s.population * 100:.1f}%" for s in states])
355
+ + f" (Unassigned / Minor: {unassigned_fraction * 100:.1f}%)"
356
+ )
357
+
358
+ return states, unassigned_fraction, frame_assignments_df, basin_labels
359
+
360
+
361
+ def save_basin_results(
362
+ states: list[BasinState],
363
+ frame_assignments: pd.DataFrame,
364
+ basin_map: np.ndarray,
365
+ output_dir: str | Path,
366
+ ) -> Path:
367
+ """Save basin metadata, frame assignments, and segmentation map."""
368
+ states_dir = Path(output_dir) / "states"
369
+ states_dir.mkdir(parents=True, exist_ok=True)
370
+
371
+ # Save basins.csv
372
+ records = []
373
+ for s in states:
374
+ records.append(
375
+ {
376
+ "State": s.label,
377
+ "Population_Percent": s.population * 100.0,
378
+ "Min_PC1": s.min_pc1,
379
+ "Min_PC2": s.min_pc2,
380
+ "Min_FreeEnergy_kJ_mol": s.min_free_energy,
381
+ "Representative_Replicate": s.representative_replicate,
382
+ "Representative_Frame": s.representative_frame,
383
+ "Representative_Time_ps": s.representative_time_ps,
384
+ "Representative_Distance": s.representative_distance,
385
+ }
386
+ )
387
+ basins_df = pd.DataFrame(records)
388
+ basins_df.to_csv(states_dir / "basins.csv", index=False)
389
+
390
+ # Save frame_assignments.csv
391
+ frame_assignments.to_csv(states_dir / "frame_assignments.csv", index=False)
392
+
393
+ # Save basin_map.npy
394
+ np.save(states_dir / "basin_map.npy", basin_map)
395
+
396
+ return states_dir