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/__init__.py
ADDED
|
@@ -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
|
+
]
|
conformatlas/__main__.py
ADDED
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
|