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.
- conformatlas/__init__.py +32 -0
- conformatlas/__main__.py +8 -0
- conformatlas/api.py +647 -0
- conformatlas/basins.py +396 -0
- conformatlas/bootstrap.py +439 -0
- conformatlas/cli.py +1388 -0
- conformatlas/comparison.py +450 -0
- conformatlas/comparison_stats.py +319 -0
- conformatlas/config.py +572 -0
- conformatlas/convergence.py +390 -0
- conformatlas/descriptors.py +143 -0
- conformatlas/errors.py +146 -0
- conformatlas/examples_gen.py +212 -0
- conformatlas/features.py +399 -0
- conformatlas/fel.py +200 -0
- conformatlas/gromacs.py +126 -0
- conformatlas/kinetics.py +210 -0
- conformatlas/mda_analysis.py +178 -0
- conformatlas/models.py +317 -0
- conformatlas/modes.py +192 -0
- conformatlas/msm.py +1087 -0
- conformatlas/ndx.py +53 -0
- conformatlas/pca.py +406 -0
- conformatlas/pipeline.py +2116 -0
- conformatlas/plotting.py +1554 -0
- conformatlas/plotting_extra.py +365 -0
- conformatlas/plotting_interactive.py +523 -0
- conformatlas/plotting_msm.py +336 -0
- conformatlas/plotting_residue.py +151 -0
- conformatlas/progress.py +198 -0
- conformatlas/provenance.py +172 -0
- conformatlas/py.typed +1 -0
- conformatlas/report.py +858 -0
- conformatlas/representatives.py +358 -0
- conformatlas/residue_analysis.py +716 -0
- conformatlas/results_io.py +736 -0
- conformatlas/reweighting.py +919 -0
- conformatlas/streaming.py +733 -0
- conformatlas/superpose.py +109 -0
- conformatlas/templates/assets/NGL_LICENSE.txt +21 -0
- conformatlas/templates/assets/ngl.js +88 -0
- conformatlas/templates/report.html.j2 +605 -0
- conformatlas/timeseries.py +168 -0
- conformatlas/trajectory.py +622 -0
- conformatlas/uncertainty.py +181 -0
- conformatlas/utils.py +185 -0
- conformatlas/xpm.py +214 -0
- conformatlas-1.0.0.dist-info/METADATA +224 -0
- conformatlas-1.0.0.dist-info/RECORD +53 -0
- conformatlas-1.0.0.dist-info/WHEEL +5 -0
- conformatlas-1.0.0.dist-info/entry_points.txt +2 -0
- conformatlas-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|