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
@@ -0,0 +1,32 @@
1
+ """ConformAtlas: mapping conformational landscapes from molecular simulations."""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "Atharva Tilewale"
5
+
6
+ from conformatlas import api # noqa: E402,F401 (imports pipeline lazily)
7
+ from conformatlas.models import (
8
+ AnalysisConfig,
9
+ BasinState,
10
+ ComparisonConfig,
11
+ ConvergenceResults,
12
+ FELGrid,
13
+ PCAResults,
14
+ )
15
+ from conformatlas.msm import build_msm, save_msm # noqa: E402
16
+ from conformatlas.results_io import load_results, load_results_dir, save_results # noqa: E402
17
+
18
+ __all__ = [
19
+ "AnalysisConfig",
20
+ "BasinState",
21
+ "ComparisonConfig",
22
+ "ConvergenceResults",
23
+ "FELGrid",
24
+ "PCAResults",
25
+ "__version__",
26
+ "api",
27
+ "build_msm",
28
+ "save_msm",
29
+ "load_results",
30
+ "load_results_dir",
31
+ "save_results",
32
+ ]
@@ -0,0 +1,8 @@
1
+ """Module entry point for python -m conformatlas."""
2
+
3
+ import sys
4
+
5
+ from conformatlas.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
conformatlas/api.py ADDED
@@ -0,0 +1,647 @@
1
+ """High-level Python API returning in-memory objects.
2
+
3
+ The command line (``conformatlas analyze``) runs the whole pipeline and writes a
4
+ results directory. This module exposes the same building blocks as plain
5
+ functions for scripts and notebooks::
6
+
7
+ from conformatlas import api
8
+
9
+ ens = api.load_trajectories("topol.pdb", ["rep1.xtc", "rep2.xtc"], group="C-alpha")
10
+ pcs = api.pca(ens, n_components=10) # PCAResults
11
+ land = api.landscape(pcs, temperature=300, bins=32) # Landscape (FELGrid + CV table)
12
+ st = api.states(land, min_population=0.05) # StateAssignment
13
+ kin = api.kinetics(st) # dict of DataFrames
14
+ conv = api.convergence(ens, pcs, st) # ConvergenceResults
15
+ fig, ax = api.plot_landscape(land, states=st.states)
16
+
17
+ results = api.analyze("topol.pdb", ["rep1.xtc"], bins=32, output_dir="out") # full pipeline
18
+
19
+ Every function is a thin, documented wrapper around the validated engines in
20
+ :mod:`conformatlas.pca`, :mod:`conformatlas.fel`, :mod:`conformatlas.basins`,
21
+ :mod:`conformatlas.kinetics` and :mod:`conformatlas.convergence`; no numerics
22
+ are re-implemented here. Units: Å, ps, kJ/mol; R = 0.008314462618 kJ/(mol K).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import logging
29
+ from collections.abc import Callable, Mapping, Sequence
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import numpy as np
35
+ import pandas as pd
36
+
37
+ from conformatlas.basins import compute_state_barriers, detect_energy_basins
38
+ from conformatlas.convergence import compute_convergence_diagnostics
39
+ from conformatlas.fel import calculate_fel, choose_bins
40
+ from conformatlas.kinetics import compute_state_kinetics
41
+ from conformatlas.models import (
42
+ AnalysisConfig,
43
+ AnalysisResults,
44
+ BasinState,
45
+ ConvergenceResults,
46
+ FELGrid,
47
+ PCAResults,
48
+ )
49
+ from conformatlas.ndx import parse_ndx
50
+ from conformatlas.pca import compute_cartesian_pca, compute_tica, project_coordinates
51
+ from conformatlas.superpose import superpose_frame
52
+ from conformatlas.trajectory import (
53
+ atom_info_for_indices,
54
+ load_reference_coordinates,
55
+ resolve_selection_indices,
56
+ stream_trajectory_coordinates,
57
+ )
58
+
59
+ logger = logging.getLogger("conformatlas.api")
60
+
61
+ __all__ = [
62
+ "Ensemble",
63
+ "Landscape",
64
+ "StateAssignment",
65
+ "load_trajectories",
66
+ "pca",
67
+ "landscape",
68
+ "states",
69
+ "kinetics",
70
+ "convergence",
71
+ "analyze",
72
+ "plot_landscape",
73
+ ]
74
+
75
+
76
+ # =============================================================================
77
+ # Containers
78
+ # =============================================================================
79
+
80
+
81
+ @dataclass
82
+ class Ensemble:
83
+ """Superposed coordinates of one or more replicates (output of :func:`load_trajectories`).
84
+
85
+ Attributes
86
+ ----------
87
+ coords : dict replicate_id -> (n_frames, n_atoms, 3) float32, Å, superposed
88
+ times : dict replicate_id -> (n_frames,) ps
89
+ fit_rmsd : dict replicate_id -> (n_frames,) Å, RMSD of the fit group after superposition
90
+ atom_indices, fit_indices : 0-based atom indices of the analysis / fit groups
91
+ atom_info : table (atom_index, chain, residue_number, residue_name, atom_name, mass)
92
+ reference : (n_atoms, 3) analysis-group reference coordinates in the fitted frame
93
+ ligand_mask : boolean mask over analysis atoms (None without a ligand group)
94
+ """
95
+
96
+ topology: Path
97
+ trajectories: dict[str, Path]
98
+ coords: dict[str, np.ndarray]
99
+ times: dict[str, np.ndarray]
100
+ fit_rmsd: dict[str, np.ndarray]
101
+ atom_indices: np.ndarray
102
+ fit_indices: np.ndarray
103
+ atom_info: pd.DataFrame
104
+ reference: np.ndarray
105
+ ligand_mask: np.ndarray | None = None
106
+ group: str = "Backbone"
107
+ fit_group: str = "Backbone"
108
+ system_name: str = "System"
109
+
110
+ @property
111
+ def replicate_ids(self) -> list[str]:
112
+ return list(self.coords)
113
+
114
+ @property
115
+ def n_frames(self) -> int:
116
+ return int(sum(len(t) for t in self.times.values()))
117
+
118
+ @property
119
+ def n_atoms(self) -> int:
120
+ return int(len(self.atom_indices))
121
+
122
+ @property
123
+ def dt_ps(self) -> float:
124
+ diffs = [np.diff(t) for t in self.times.values() if len(t) > 1]
125
+ return float(np.median(np.concatenate(diffs))) if diffs else 1.0
126
+
127
+
128
+ @dataclass
129
+ class Landscape:
130
+ """Free-energy landscape plus the collective-variable table it was built from."""
131
+
132
+ fel: FELGrid
133
+ cv: pd.DataFrame # projections table plus CV1, CV2 columns
134
+ cv_names: tuple[str, str]
135
+ cv_unit: str = "Å"
136
+ bins: int = 0
137
+ weights: np.ndarray | None = None
138
+
139
+
140
+ @dataclass
141
+ class StateAssignment:
142
+ """Basins, per-frame state labels and barriers (output of :func:`states`)."""
143
+
144
+ states: list[BasinState]
145
+ unassigned_population: float
146
+ frame_assignments: pd.DataFrame
147
+ basin_map: np.ndarray
148
+ barriers: pd.DataFrame
149
+ landscape: Landscape = field(repr=False)
150
+
151
+ @property
152
+ def labels(self) -> list[str]:
153
+ return [s.label for s in self.states]
154
+
155
+ def populations(self) -> pd.DataFrame:
156
+ """State populations (fraction) and minimum free energies as a table."""
157
+ return pd.DataFrame(
158
+ {
159
+ "state": [s.label for s in self.states],
160
+ "population": [s.population for s in self.states],
161
+ "min_cv1": [s.min_pc1 for s in self.states],
162
+ "min_cv2": [s.min_pc2 for s in self.states],
163
+ "min_free_energy_kJ_mol": [s.min_free_energy for s in self.states],
164
+ }
165
+ )
166
+
167
+
168
+ # =============================================================================
169
+ # 1. Trajectories
170
+ # =============================================================================
171
+
172
+
173
+ def load_trajectories(
174
+ topology: str | Path,
175
+ trajectories: Sequence[str | Path] | Mapping[str, str | Path],
176
+ *,
177
+ group: str = "Backbone",
178
+ fit_group: str | None = None,
179
+ lig_group: str | None = None,
180
+ index_file: str | Path | None = None,
181
+ stride: int = 1,
182
+ begin_ps: float | None = None,
183
+ end_ps: float | None = None,
184
+ fit: bool = True,
185
+ mass_weighted_fit: bool = False,
186
+ system_name: str = "System",
187
+ ) -> Ensemble:
188
+ """Read, superpose and hold the analysis-group coordinates of every replicate.
189
+
190
+ Parameters
191
+ ----------
192
+ topology : structure file (.pdb/.gro; also the superposition reference) or .tpr
193
+ trajectories : trajectory paths; a mapping gives explicit replicate ids,
194
+ a sequence is labelled ``Rep_1, Rep_2, …``
195
+ group : analysis group (GROMACS group name, MDAnalysis selection or index group)
196
+ fit_group : superposition group (defaults to ``group``)
197
+ lig_group : extra group (e.g. ligand residue name) appended to the analysis group
198
+ index_file : GROMACS ``.ndx`` file providing named groups
199
+ stride, begin_ps, end_ps : frame selection
200
+ fit : least-squares superposition onto the reference before storing frames
201
+ mass_weighted_fit : mass-weight the superposition
202
+ system_name : label used in result tables
203
+
204
+ Returns
205
+ -------
206
+ Ensemble
207
+ Coordinates are float32 (n_frames, n_atoms, 3) in Å, as in the pipeline.
208
+ """
209
+ topology = Path(topology)
210
+ if isinstance(trajectories, Mapping):
211
+ traj_map = {str(k): Path(v) for k, v in trajectories.items()}
212
+ else:
213
+ traj_map = {f"Rep_{i + 1}": Path(p) for i, p in enumerate(trajectories)}
214
+ if not traj_map:
215
+ raise ValueError("At least one trajectory is required.")
216
+ fit_group = fit_group or group
217
+ ndx = parse_ndx(index_file) if index_file else None
218
+ first = next(iter(traj_map.values()))
219
+ analysis_idx = resolve_selection_indices(topology, group, ndx, extra_group=lig_group)
220
+ fit_idx = resolve_selection_indices(topology, fit_group, ndx)
221
+ atom_info = atom_info_for_indices(topology, analysis_idx)
222
+ ligand_mask = None
223
+ if lig_group:
224
+ lig_idx = resolve_selection_indices(topology, lig_group, ndx)
225
+ ligand_mask = np.isin(analysis_idx, lig_idx)
226
+
227
+ reference_fit = load_reference_coordinates(
228
+ topology, atom_indices=fit_idx, fallback_trajectory=first
229
+ )
230
+ reference = load_reference_coordinates(
231
+ topology, atom_indices=analysis_idx, fallback_trajectory=first
232
+ )
233
+ if fit:
234
+ reference = superpose_frame(reference_fit, reference, reference_fit)
235
+
236
+ coords: dict[str, np.ndarray] = {}
237
+ times: dict[str, np.ndarray] = {}
238
+ fit_rmsd: dict[str, np.ndarray] = {}
239
+ for rep_id, path in traj_map.items():
240
+ c, t, r = stream_trajectory_coordinates(
241
+ topology,
242
+ path,
243
+ atom_indices=analysis_idx,
244
+ stride=stride,
245
+ fit=fit,
246
+ fit_atom_indices=fit_idx,
247
+ reference_fit_coords=reference_fit if fit else None,
248
+ mass_weighted=mass_weighted_fit,
249
+ begin_ps=begin_ps,
250
+ end_ps=end_ps,
251
+ return_fit_rmsd=True,
252
+ )
253
+ coords[rep_id], times[rep_id], fit_rmsd[rep_id] = c, t, r
254
+ return Ensemble(
255
+ topology=topology,
256
+ trajectories=traj_map,
257
+ coords=coords,
258
+ times=times,
259
+ fit_rmsd=fit_rmsd,
260
+ atom_indices=np.asarray(analysis_idx),
261
+ fit_indices=np.asarray(fit_idx),
262
+ atom_info=atom_info,
263
+ reference=reference,
264
+ ligand_mask=ligand_mask,
265
+ group=group,
266
+ fit_group=fit_group,
267
+ system_name=system_name,
268
+ )
269
+
270
+
271
+ # =============================================================================
272
+ # 2. Decomposition
273
+ # =============================================================================
274
+
275
+
276
+ def _coords_and_times(
277
+ data: Ensemble | Mapping[str, np.ndarray],
278
+ times: Mapping[str, np.ndarray] | None,
279
+ ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], str]:
280
+ if isinstance(data, Ensemble):
281
+ return dict(data.coords), dict(data.times), data.system_name
282
+ coords = {str(k): np.asarray(v) for k, v in data.items()}
283
+ if times is None:
284
+ times = {k: np.arange(v.shape[0], dtype=float) for k, v in coords.items()}
285
+ return coords, {str(k): np.asarray(v, dtype=float) for k, v in times.items()}, "System"
286
+
287
+
288
+ def pca(
289
+ data: Ensemble | Mapping[str, np.ndarray],
290
+ *,
291
+ n_components: int = 10,
292
+ weighting: str = "equal-replicate",
293
+ method: str = "cartesian",
294
+ lag: int = 10,
295
+ times: Mapping[str, np.ndarray] | None = None,
296
+ system_name: str | None = None,
297
+ ) -> PCAResults:
298
+ """Principal component (or TICA) decomposition of an ensemble.
299
+
300
+ Parameters
301
+ ----------
302
+ data : :class:`Ensemble` or mapping replicate_id -> (n_frames, n_atoms, 3) / (n_frames, n_features)
303
+ n_components : components retained
304
+ weighting : ``'equal-replicate'`` (each replicate total weight 1/K) or ``'frames'`` (1/N)
305
+ method : ``'cartesian'`` (covariance PCA) or ``'tica'`` (time-lagged ICA at ``lag`` frames)
306
+ lag : TICA lag in frames
307
+ times : per-replicate times (ps) when ``data`` is a plain mapping (defaults to frame index)
308
+ system_name : label in the projection table (defaults to the ensemble's)
309
+
310
+ Returns
311
+ -------
312
+ PCAResults
313
+ ``eigenvalues`` (Ų for Cartesian PCA; autocorrelations for TICA),
314
+ ``eigenvectors`` (3·n_atoms, k), ``explained_variance`` (%),
315
+ ``projections`` table with columns system, replicate, frame, time_ps, PC1…PCk,
316
+ ``mean_coords`` (3·n_atoms,).
317
+ """
318
+ coords, times_d, default_name = _coords_and_times(data, times)
319
+ name = system_name or default_name
320
+ if method == "cartesian":
321
+ evals, evecs, exp_var, cum_var, mean = compute_cartesian_pca(
322
+ coords, weighting=weighting, n_components=n_components
323
+ )
324
+ elif method == "tica":
325
+ evals, evecs, exp_var, cum_var, mean = compute_tica(
326
+ coords, lag=lag, n_components=n_components, weighting=weighting
327
+ )
328
+ else:
329
+ raise ValueError("method must be 'cartesian' or 'tica'")
330
+ dfs = []
331
+ for rep, c in coords.items():
332
+ proj = project_coordinates(c, mean, evecs)
333
+ df = pd.DataFrame(proj, columns=[f"PC{i + 1}" for i in range(proj.shape[1])])
334
+ df.insert(0, "system", name)
335
+ df.insert(1, "replicate", rep)
336
+ df.insert(2, "frame", np.arange(proj.shape[0]))
337
+ df.insert(3, "time_ps", times_d[rep])
338
+ dfs.append(df)
339
+ return PCAResults(
340
+ eigenvalues=evals,
341
+ eigenvectors=evecs,
342
+ explained_variance=exp_var,
343
+ cumulative_variance=cum_var,
344
+ projections=pd.concat(dfs, ignore_index=True),
345
+ mean_coords=mean,
346
+ )
347
+
348
+
349
+ # =============================================================================
350
+ # 3. Landscape
351
+ # =============================================================================
352
+
353
+
354
+ def landscape(
355
+ source: PCAResults | pd.DataFrame | AnalysisResults,
356
+ *,
357
+ temperature: float = 300.0,
358
+ bins: int | None = None,
359
+ smooth_sigma: float = 0.0,
360
+ pcs: tuple[int, int] = (1, 2),
361
+ cv_columns: tuple[str, str] | None = None,
362
+ cv_names: tuple[str, str] | None = None,
363
+ cv_unit: str = "Å",
364
+ weights: np.ndarray | None = None,
365
+ ) -> Landscape:
366
+ """Boltzmann-inverted 2-D free-energy landscape ΔG = −RT ln(P/P_max).
367
+
368
+ Parameters
369
+ ----------
370
+ source : :class:`PCAResults`, a projection table, or an :class:`AnalysisResults`
371
+ temperature : K
372
+ bins : square grid size; ``None`` applies the Freedman–Diaconis rule
373
+ (:func:`conformatlas.fel.choose_bins`)
374
+ smooth_sigma : Gaussian smoothing of the probability (grid units); 0 = none
375
+ pcs : 1-based component indices used as CV1/CV2 (ignored when ``cv_columns`` is given)
376
+ cv_columns : explicit column names to use as CV1 / CV2
377
+ cv_names, cv_unit : axis labels for plots
378
+ weights : per-frame statistical weights (reweighted ensembles)
379
+
380
+ Returns
381
+ -------
382
+ Landscape
383
+ ``fel`` is a :class:`FELGrid` (kJ/mol, NaN where unsampled); ``cv`` is the
384
+ projection table with CV1/CV2 columns appended (input to :func:`states`).
385
+ """
386
+ if isinstance(source, AnalysisResults):
387
+ table = source.pca.projections.copy()
388
+ elif isinstance(source, PCAResults):
389
+ table = source.projections.copy()
390
+ else:
391
+ table = pd.DataFrame(source).copy()
392
+ if cv_columns is None:
393
+ c1, c2 = f"PC{pcs[0]}", f"PC{pcs[1]}"
394
+ names = cv_names or (c1, c2)
395
+ else:
396
+ c1, c2 = cv_columns
397
+ names = cv_names or (str(c1), str(c2))
398
+ for c in (c1, c2):
399
+ if c not in table.columns:
400
+ raise ValueError(f"column {c!r} not found in the projection table")
401
+ table["CV1"], table["CV2"] = table[c1].to_numpy(), table[c2].to_numpy()
402
+ cv1, cv2 = table["CV1"].to_numpy(), table["CV2"].to_numpy()
403
+ nbins = int(bins) if bins is not None else choose_bins(cv1, cv2)
404
+ fel = calculate_fel(
405
+ cv1, cv2, temperature=temperature, bins=nbins, smooth_sigma=smooth_sigma, weights=weights
406
+ )
407
+ return Landscape(
408
+ fel=fel, cv=table, cv_names=tuple(names), cv_unit=cv_unit, bins=nbins, weights=weights
409
+ )
410
+
411
+
412
+ # =============================================================================
413
+ # 4. States
414
+ # =============================================================================
415
+
416
+
417
+ def states(
418
+ land: Landscape,
419
+ *,
420
+ min_population: float = 0.05,
421
+ max_states: int = 10,
422
+ min_barrier: float | None = None,
423
+ min_peak_distance: int = 3,
424
+ ) -> StateAssignment:
425
+ """Watershed segmentation of the landscape into basins and per-frame state labels.
426
+
427
+ Parameters
428
+ ----------
429
+ land : output of :func:`landscape`
430
+ min_population : minimum fraction for a basin to be reported as a major state
431
+ max_states : maximum number of major states
432
+ min_barrier : kJ/mol; adjacent basins separated by a lower saddle are merged
433
+ (default 1 kT at the landscape temperature)
434
+ min_peak_distance : minimum grid distance between seed minima
435
+
436
+ Returns
437
+ -------
438
+ StateAssignment
439
+ ``states`` (:class:`BasinState` list, populations as fractions),
440
+ ``frame_assignments`` (CV table + ``basin_id`` + ``state``), ``basin_map``
441
+ (grid labels) and ``barriers`` (saddles between adjacent major states).
442
+ """
443
+ st, unassigned, assignments, basin_map = detect_energy_basins(
444
+ land.fel,
445
+ land.cv,
446
+ min_state_population=min_population,
447
+ max_states=max_states,
448
+ min_peak_distance=min_peak_distance,
449
+ min_barrier=min_barrier,
450
+ weights=land.weights,
451
+ cv_columns=("CV1", "CV2"),
452
+ )
453
+ barriers = compute_state_barriers(basin_map, land.fel.free_energy, st)
454
+ return StateAssignment(
455
+ states=st,
456
+ unassigned_population=unassigned,
457
+ frame_assignments=assignments,
458
+ basin_map=basin_map,
459
+ barriers=barriers,
460
+ landscape=land,
461
+ )
462
+
463
+
464
+ # =============================================================================
465
+ # 5. Kinetics
466
+ # =============================================================================
467
+
468
+
469
+ def kinetics(assignment: StateAssignment, dt_ps: float | None = None) -> dict[str, pd.DataFrame]:
470
+ """Transition counts, lag-1 transition matrix, dwell times and Markov MFPTs.
471
+
472
+ Assumptions: transitions are counted between consecutive major-state frames of
473
+ the same replicate (unassigned frames are skipped); the mean first-passage
474
+ times assume a Markov chain at lag one frame and are indicative only.
475
+
476
+ Returns the dictionary of tables described in
477
+ :func:`conformatlas.kinetics.compute_state_kinetics`.
478
+ """
479
+ if assignment.landscape.weights is not None:
480
+ raise ValueError("kinetics are not defined for reweighted (weighted) ensembles")
481
+ return compute_state_kinetics(assignment.frame_assignments, assignment.labels, dt_ps=dt_ps)
482
+
483
+
484
+ # =============================================================================
485
+ # 6. Convergence
486
+ # =============================================================================
487
+
488
+
489
+ def convergence(
490
+ data: Ensemble | Mapping[str, np.ndarray],
491
+ pcs: PCAResults,
492
+ assignment: StateAssignment | None = None,
493
+ *,
494
+ n_modes: int = 10,
495
+ ) -> ConvergenceResults:
496
+ """Sampling diagnostics: half-vs-half and progressive RMSIP, cosine content,
497
+ statistical inefficiency / N_eff and state-population stability.
498
+
499
+ Parameters
500
+ ----------
501
+ data : :class:`Ensemble` or mapping replicate_id -> coordinates used for the PCA
502
+ pcs : the :class:`PCAResults` of that ensemble
503
+ assignment : optional :class:`StateAssignment` (population stability needs it)
504
+ n_modes : size of the essential subspace compared
505
+ """
506
+ coords = (
507
+ dict(data.coords)
508
+ if isinstance(data, Ensemble)
509
+ else {str(k): np.asarray(v) for k, v in data.items()}
510
+ )
511
+ if assignment is not None:
512
+ fa = assignment.frame_assignments
513
+ else:
514
+ fa = pcs.projections.copy()
515
+ fa["state"] = "Unassigned"
516
+ return compute_convergence_diagnostics(coords, pcs.projections, fa, n_modes=n_modes)
517
+
518
+
519
+ # =============================================================================
520
+ # 7. Full pipeline
521
+ # =============================================================================
522
+
523
+
524
+ def analyze(
525
+ topology: str | Path,
526
+ trajectories: Sequence[str | Path],
527
+ *,
528
+ output_dir: str | Path = "Outputs",
529
+ echo: Callable[[str], None] | None = None,
530
+ **options: Any,
531
+ ) -> AnalysisResults:
532
+ """Run the complete ``conformatlas analyze`` pipeline and return its results.
533
+
534
+ Keyword options are the fields of :class:`~conformatlas.models.AnalysisConfig`
535
+ (``temperature``, ``lsq_group``, ``cov_group``, ``lig_group``, ``bins``,
536
+ ``smooth_sigma``, ``pca_weighting``, ``stride``, ``min_state_population``,
537
+ ``max_states``, ``n_components``, ``pca_type``, ``tica_lag``, ``cv``,
538
+ ``fel_pcs``, ``index_file``, ``n_bootstrap``, ``seed``, ``begin_ps``,
539
+ ``end_ps``, ``equilibration``, ``fit``, ``mass_weighted_fit``,
540
+ ``n_representatives``, ``representative_method``, ``system_name``, …).
541
+ Unknown names raise ``TypeError``. ``echo`` receives progress lines.
542
+ """
543
+ from conformatlas.pipeline import run_analysis
544
+
545
+ valid = {f.name for f in dataclasses.fields(AnalysisConfig)}
546
+ unknown = sorted(set(options) - valid)
547
+ if unknown:
548
+ raise TypeError(f"Unknown analyze() options: {unknown}. Valid: {sorted(valid)}")
549
+ cfg = AnalysisConfig(
550
+ topology=Path(topology),
551
+ trajectories=[Path(p) for p in trajectories],
552
+ output_dir=Path(output_dir),
553
+ **options,
554
+ )
555
+ return run_analysis(cfg, echo=echo) if echo is not None else run_analysis(cfg)
556
+
557
+
558
+ # =============================================================================
559
+ # 8. Plotting for notebooks
560
+ # =============================================================================
561
+
562
+
563
+ def plot_landscape(
564
+ source: Landscape | FELGrid | AnalysisResults,
565
+ ax=None,
566
+ *,
567
+ states: Sequence[BasinState] | None = None,
568
+ vmax: float | None = None,
569
+ cmap: str = "turbo",
570
+ levels: int = 40,
571
+ contour: bool = True,
572
+ annotate: bool = True,
573
+ cv_names: tuple[str, str] | None = None,
574
+ cv_unit: str | None = None,
575
+ ):
576
+ """Draw the 2-D free-energy landscape on a matplotlib axis.
577
+
578
+ Parameters
579
+ ----------
580
+ source : :class:`Landscape`, :class:`FELGrid` or :class:`AnalysisResults`
581
+ ax : existing axis (a new figure is created when None)
582
+ states : basin minima to mark (defaults to ``source.states`` for AnalysisResults)
583
+ vmax : colour-scale ceiling in kJ/mol (default: 98th percentile, at most 35)
584
+ cmap, levels, contour, annotate : appearance
585
+ cv_names, cv_unit : axis labels (default from the source)
586
+
587
+ Returns
588
+ -------
589
+ (fig, ax, mappable)
590
+ The figure, the axis and the filled-contour mappable (for colour bars).
591
+ """
592
+ import matplotlib.pyplot as plt
593
+
594
+ if isinstance(source, AnalysisResults):
595
+ fel = source.fel
596
+ names = cv_names or tuple(
597
+ source.run_metadata.get("collective_variables", {}).get("names", ("PC1", "PC2"))
598
+ )
599
+ unit = cv_unit if cv_unit is not None else "Å"
600
+ if states is None:
601
+ states = source.states
602
+ elif isinstance(source, Landscape):
603
+ fel = source.fel
604
+ names = cv_names or source.cv_names
605
+ unit = cv_unit if cv_unit is not None else source.cv_unit
606
+ else:
607
+ fel = source
608
+ names = cv_names or ("PC1", "PC2")
609
+ unit = cv_unit if cv_unit is not None else "Å"
610
+
611
+ if ax is None:
612
+ fig, ax = plt.subplots(figsize=(6.5, 5.2))
613
+ else:
614
+ fig = ax.figure
615
+ X, Y = np.meshgrid(fel.x_centers, fel.y_centers)
616
+ Z = fel.free_energy
617
+ finite = Z[np.isfinite(Z)]
618
+ top = (
619
+ vmax
620
+ if vmax is not None
621
+ else (min(float(np.percentile(finite, 98)), 35.0) if finite.size else 25.0)
622
+ )
623
+ top = max(top, 1e-6)
624
+ lv = np.linspace(0.0, top, int(levels))
625
+ ax.set_facecolor("#f2f2f2")
626
+ cf = ax.contourf(X, Y, Z, levels=lv, cmap=cmap, extend="max")
627
+ if contour:
628
+ ax.contour(X, Y, Z, levels=lv[::4], colors="black", linewidths=0.4, alpha=0.5)
629
+ if states:
630
+ for s in states:
631
+ ax.plot(s.min_pc1, s.min_pc2, marker="*", ms=12, mec="black", mfc="white", ls="none")
632
+ if annotate:
633
+ ax.annotate(
634
+ f"{s.label} ({s.population * 100:.0f}%)",
635
+ (s.min_pc1, s.min_pc2),
636
+ xytext=(6, 6),
637
+ textcoords="offset points",
638
+ fontsize=8,
639
+ bbox={"boxstyle": "round,pad=0.2", "fc": "white", "alpha": 0.8, "lw": 0.5},
640
+ )
641
+ suffix = f" ({unit})" if unit else ""
642
+ ax.set_xlabel(f"{names[0]}{suffix}")
643
+ ax.set_ylabel(f"{names[1]}{suffix}")
644
+ ax.set_title(f"Free energy landscape (T = {fel.temperature:.0f} K)")
645
+ cb = fig.colorbar(cf, ax=ax)
646
+ cb.set_label("ΔG (kJ/mol)")
647
+ return fig, ax, cf