MolClusters 0.7.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.
@@ -0,0 +1,68 @@
1
+ # SPDX-FileCopyrightText: © 2024 Emanuel Mancio <emanuelmancio@usp.br>
2
+ #
3
+ # SPDX-License-Identifier: LGPL-3.0-or-later
4
+
5
+ """MolClusters: A Python tool for analyzing cluster formation in molecular dynamics simulations.
6
+
7
+ This module provides functionality to study and analyze the formation of molecular clusters
8
+ from simulation data. It includes tools for processing and analyzing cluster
9
+ dynamics, enabling researchers to gain insights into molecular behavior of clusters.
10
+
11
+ Attributes
12
+ ----------
13
+ __version__ (str): The version of the MolClusters package.
14
+ version (str): The version of the MolClusters package.
15
+
16
+ Modules
17
+ -------
18
+ cluster: The `MolGroup` and `Cluster` classes that analyses read.
19
+ molclusters: Core module defining the main `MolClusters` class.
20
+ tracker: The `ClusterTracker`, which keeps cluster ids stable over a trajectory.
21
+ analysis: The analyses run on the tracked clusters, and their `FrameAnalysis` base.
22
+ output: The files the analyses write.
23
+
24
+ Exports
25
+ -------
26
+ MolClusters: The primary class for performing cluster analysis.
27
+ ClusterTracker: Follows the clusters of a trajectory, without analysing them.
28
+ Transition: How the clusters of a frame came from those of the frame before.
29
+ FrameAnalysis: The base class of an analysis, to pass to `MolClusters`.
30
+ Frame, Run: What an analysis sees of the current frame and of the run.
31
+ OutputFile: The declaration of a file an analysis writes.
32
+ Cluster, MolGroup: A tracked cluster, and any group of molecules (e.g. a nucleus).
33
+ start_logging: Function to set up molclusters logger.
34
+
35
+ Example
36
+ -------
37
+ >>> from molclusters import MolClusters
38
+ >>> analyzer = MolClusters(universe, config)
39
+ >>> analyzer.run()
40
+ """
41
+
42
+ from loguru import logger
43
+
44
+ logger.disable("molclusters")
45
+
46
+ from . import cluster as cluster
47
+ from .analysis import Frame, FrameAnalysis, Run
48
+ from .cluster import Cluster, MolGroup
49
+ from .log import start_logging
50
+ from .molclusters import MolClusters
51
+ from .output import OutputFile
52
+ from .tracker import ClusterTracker, Transition
53
+ from .version import __version__, version
54
+
55
+ __all__ = [
56
+ "version",
57
+ "__version__",
58
+ "start_logging",
59
+ "MolClusters",
60
+ "ClusterTracker",
61
+ "Transition",
62
+ "FrameAnalysis",
63
+ "Frame",
64
+ "Run",
65
+ "OutputFile",
66
+ "Cluster",
67
+ "MolGroup",
68
+ ]
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: © 2024 Emanuel Mancio <emanuelmancio@usp.br>
2
+ #
3
+ # SPDX-License-Identifier: LGPL-3.0-or-later
4
+
5
+ """Entry point for the `molclusters` package.
6
+
7
+ The module imports and executes the `main` function from the `main` module,
8
+ serving as the starting point for the application when run as a script.
9
+
10
+ Usage:
11
+ To execute the `molclusters` application, run this module directly.
12
+
13
+ Example:
14
+ molclusters -h
15
+ """
16
+
17
+ from .main import main
18
+
19
+ main()
@@ -0,0 +1,40 @@
1
+ # SPDX-FileCopyrightText: © 2026 Emanuel Mancio <emanuelmancio@usp.br>
2
+ #
3
+ # SPDX-License-Identifier: LGPL-3.0-or-later
4
+
5
+ """The analyses run on the tracked clusters, each a `FrameAnalysis`.
6
+
7
+ Modules
8
+ -------
9
+ base: `FrameAnalysis`, and the `Run` and `Frame` an analysis sees.
10
+ builtins: `BUILTINS`, the built-in analyses in the order they run, and what
11
+ each needs from the config.
12
+ coordinates: `ClusterCoordinates`, the clusters holding solutes (.gro files).
13
+ lineage: `Lineage`, where the clusters came from, what became of them and when
14
+ (cluster_events.csv, cluster_lifetimes.csv).
15
+ nucleus: `Nucleus`, the nuclei inside the clusters (nucleus_data.csv).
16
+ report: `JsonReport`, every cluster of every frame (molclusters.jsonl).
17
+ size: `SizeEvolution`, the number and sizes of the clusters over time (evo.txt).
18
+ solute: `SoluteSolvent`, the clusters holding solutes and solvents
19
+ (solute_solvent.csv).
20
+ """
21
+
22
+ from .base import Frame, FrameAnalysis, Run
23
+ from .coordinates import ClusterCoordinates
24
+ from .lineage import Lineage
25
+ from .nucleus import Nucleus
26
+ from .report import JsonReport
27
+ from .size import SizeEvolution
28
+ from .solute import SoluteSolvent
29
+
30
+ __all__ = [
31
+ "ClusterCoordinates",
32
+ "Frame",
33
+ "FrameAnalysis",
34
+ "JsonReport",
35
+ "Lineage",
36
+ "Nucleus",
37
+ "Run",
38
+ "SizeEvolution",
39
+ "SoluteSolvent",
40
+ ]
@@ -0,0 +1,370 @@
1
+ # SPDX-FileCopyrightText: © 2026 Emanuel Mancio <emanuelmancio@usp.br>
2
+ #
3
+ # SPDX-License-Identifier: LGPL-3.0-or-later
4
+
5
+ """Provides `FrameAnalysis`, the base of the analyses run on the tracked clusters.
6
+
7
+ Classes:
8
+ --------
9
+ - FrameAnalysis: The base class of an analysis, run frame by frame.
10
+ - Run: What an analysis sees of the run as a whole.
11
+ - Frame: What an analysis sees of the current frame.
12
+ """
13
+
14
+ import time
15
+ from abc import ABC, abstractmethod
16
+ from collections.abc import Generator, Mapping, Sequence
17
+ from contextlib import contextmanager
18
+ from types import MappingProxyType
19
+ from typing import Any
20
+
21
+ import MDAnalysis as mda
22
+ from loguru import logger
23
+
24
+ from ..cluster import Cluster
25
+ from ..config import MolClsConfig
26
+ from ..output import OutputFile, RunOutput
27
+ from ..tracker import ClusterTracker, Transition
28
+
29
+
30
+ class FrameAnalysis(ABC):
31
+ """An analysis of the tracked clusters, run frame by frame.
32
+
33
+ A run calls `prepare` once, then `analyse` for every frame (the first one
34
+ included) once the clusters are up to date with it, and finally `finish`.
35
+ The analyses of a run are called in order, so an analysis sees the results of
36
+ the ones before it for the same frame.
37
+
38
+ Only `analyse` must be implemented. An analysis keeps its results as its own
39
+ attributes; one that writes files does so through `Run.output` and declares
40
+ them in `outputs`. The same analysis may be run more than once, so it should
41
+ start its results over in `prepare`, not only in ``__init__``.
42
+
43
+ An analysis logs with loguru's ``logger``, as the rest of the package does;
44
+ the run tags whatever is logged during its hooks with its class name (the
45
+ ``analysis`` key of the record's ``extra``), which the log shows. To keep the
46
+ log readable over long trajectories:
47
+
48
+ - `analyse` counts what's worth reporting (e.g. in a `collections.Counter`)
49
+ instead of logging it, and `finish` logs a summary: one INFO line, or one
50
+ warning saying how to fix the problem.
51
+ - Anything logged per frame is DEBUG or TRACE, with loguru's own arguments
52
+ (``logger.debug("cluster {}: ...", cls.id)``) rather than an f-string: a
53
+ message filtered out by the log level is then never built.
54
+ - A warning that could repeat is only logged once.
55
+
56
+ An analysis adds its own fields to the JSON report (`JsonReport`, which runs
57
+ after every other analysis) by overriding `report_frame`, `report_cluster`, or
58
+ both: what they return goes in each frame's, or each cluster's, record under
59
+ the analysis' class name, its floats rounded as the report's are.
60
+
61
+ Attributes
62
+ ----------
63
+ outputs : tuple[OutputFile, ...]
64
+ The files the analysis writes, to tell which results of an earlier run
65
+ they overwrite (or leave behind), and to report them at the end.
66
+ Usually a class attribute; an analysis whose files depend on its options
67
+ sets it in ``__init__`` instead, since it's read before `prepare`.
68
+ """
69
+
70
+ outputs: tuple[OutputFile, ...] = ()
71
+
72
+ def prepare(self, run: "Run") -> None: # noqa: B027 (optional hook)
73
+ """Get ready for a run, starting over any results. Does nothing unless overridden.
74
+
75
+ Parameters
76
+ ----------
77
+ run : Run
78
+ The run about to start.
79
+ """
80
+
81
+ @abstractmethod
82
+ def analyse(self, frame: "Frame") -> None:
83
+ """Analyse the clusters of the current frame.
84
+
85
+ Parameters
86
+ ----------
87
+ frame : Frame
88
+ The current frame.
89
+ """
90
+
91
+ def finish(self, run: "Run") -> None: # noqa: B027 (optional hook)
92
+ """Wrap up once every frame was analysed. Does nothing unless overridden.
93
+
94
+ Parameters
95
+ ----------
96
+ run : Run
97
+ The run that just ended.
98
+ """
99
+
100
+ def report_frame(self, frame: "Frame") -> Mapping[str, Any] | None:
101
+ """Give what to add to the report's record of the current frame.
102
+
103
+ Called by `JsonReport` after every analysis has analysed the frame.
104
+
105
+ Parameters
106
+ ----------
107
+ frame : Frame
108
+ The current frame.
109
+
110
+ Returns
111
+ -------
112
+ Mapping[str, Any] | None
113
+ The fields to add under the analysis' class name: JSON types, and numpy
114
+ scalars or arrays. None adds nothing (the default).
115
+ """
116
+ return None
117
+
118
+ def report_cluster(
119
+ self, frame: "Frame", cluster: Cluster
120
+ ) -> Mapping[str, Any] | None:
121
+ """Give what to add to the report's record of a cluster of the current frame.
122
+
123
+ Called by `JsonReport`, for every cluster of the frame, after every analysis
124
+ has analysed the frame.
125
+
126
+ Parameters
127
+ ----------
128
+ frame : Frame
129
+ The current frame.
130
+ cluster : Cluster
131
+ The cluster.
132
+
133
+ Returns
134
+ -------
135
+ Mapping[str, Any] | None
136
+ The fields to add under the analysis' class name: JSON types, and numpy
137
+ scalars or arrays. None adds nothing (the default).
138
+ """
139
+ return None
140
+
141
+
142
+ class Run:
143
+ """What an analysis sees of the run as a whole.
144
+
145
+ Attributes
146
+ ----------
147
+ universe : mda.Universe
148
+ The Universe being analysed.
149
+ config : MolClsConfig
150
+ The analysis configuration.
151
+ n_frames : int
152
+ The number of frames of the run.
153
+ output : RunOutput
154
+ Where the analyses write their files.
155
+ durations : list[float]
156
+ The time spent in each analysis' hooks so far, in seconds, in the order of
157
+ the analyses.
158
+ """
159
+
160
+ __slots__ = [
161
+ "universe",
162
+ "config",
163
+ "n_frames",
164
+ "output",
165
+ "durations",
166
+ "_analyses",
167
+ "_visible",
168
+ ]
169
+
170
+ def __init__(
171
+ self,
172
+ universe: mda.Universe,
173
+ config: MolClsConfig,
174
+ n_frames: int,
175
+ output: RunOutput,
176
+ analyses: Sequence[FrameAnalysis],
177
+ ) -> None:
178
+ """Initialize the context of a run.
179
+
180
+ Parameters
181
+ ----------
182
+ universe : mda.Universe
183
+ The Universe being analysed.
184
+ config : MolClsConfig
185
+ The analysis configuration.
186
+ n_frames : int
187
+ The number of frames of the run.
188
+ output : RunOutput
189
+ Where the analyses write their files.
190
+ analyses : Sequence[FrameAnalysis]
191
+ The analyses of the run, in the order they're called.
192
+ """
193
+ self.universe = universe
194
+ self.config = config
195
+ self.n_frames = n_frames
196
+ self.output = output
197
+ self.durations = [0.0] * len(analyses)
198
+ self._analyses = analyses
199
+ self._visible = len(analyses)
200
+
201
+ def prepare_analyses(self) -> None:
202
+ """Prepare every analysis of the run, in order (called by the runner)."""
203
+ for i, analysis in enumerate(self._analyses):
204
+ self._visible = i
205
+ with self._hook(i, "prepare"):
206
+ analysis.prepare(self)
207
+ self._visible = len(self._analyses)
208
+
209
+ def analyse_frame(self, frame: "Frame") -> None:
210
+ """Run every analysis of the run on `frame`, in order (called by the runner).
211
+
212
+ Parameters
213
+ ----------
214
+ frame : Frame
215
+ The current frame.
216
+ """
217
+ for i, analysis in enumerate(self._analyses):
218
+ with self._hook(i, "analyse", frame.index):
219
+ analysis.analyse(frame)
220
+
221
+ def finish_analyses(self) -> None:
222
+ """Finish every analysis of the run, in order (called by the runner)."""
223
+ for i, analysis in enumerate(self._analyses):
224
+ with self._hook(i, "finish"):
225
+ analysis.finish(self)
226
+
227
+ @property
228
+ def analyses(self) -> tuple[FrameAnalysis, ...]:
229
+ """The analyses of the run, in order; while preparing, only those before.
230
+
231
+ Returns
232
+ -------
233
+ tuple[FrameAnalysis, ...]
234
+ The analyses whose results `analysis` finds (see there).
235
+ """
236
+ return tuple(self._analyses[: self._visible])
237
+
238
+ def analysis[T: FrameAnalysis](self, kind: type[T]) -> T | None:
239
+ """Find the first analysis of the run of type `kind`, for its results.
240
+
241
+ While the analyses are being prepared, only those before the one being
242
+ prepared are found, since only their results for a frame are ready when
243
+ it analyses the frame.
244
+
245
+ Parameters
246
+ ----------
247
+ kind : type[T]
248
+ The analysis class to look for (subclasses match too).
249
+
250
+ Returns
251
+ -------
252
+ T | None
253
+ The analysis, or None if the run has none (before this one).
254
+ """
255
+ for analysis in self._analyses[: self._visible]:
256
+ if isinstance(analysis, kind):
257
+ return analysis
258
+ return None
259
+
260
+ @contextmanager
261
+ def _hook(
262
+ self, i: int, hook: str, frame: int | None = None
263
+ ) -> Generator[None, None, None]:
264
+ """Call a hook of the `i`-th analysis: tag its log, time it, blame its errors.
265
+
266
+ What's logged during the call is tagged with the analysis' class name, and
267
+ an error it raises is noted with it, so either can be told from the run's
268
+ own; the time taken is added to `durations`.
269
+
270
+ Parameters
271
+ ----------
272
+ i : int
273
+ The index of the analysis being called.
274
+ hook : str
275
+ The name of the method being called.
276
+ frame : int | None
277
+ The index of the frame being analysed, if any.
278
+
279
+ Yields
280
+ ------
281
+ None
282
+ Control, for the call to the analysis.
283
+ """
284
+ name = type(self._analyses[i]).__name__
285
+ start = time.perf_counter()
286
+ try:
287
+ with logger.contextualize(analysis=name):
288
+ yield
289
+ except Exception as err:
290
+ where = "" if frame is None else f" on frame {frame}"
291
+ err.add_note(f"Raised by {name}.{hook}(){where}")
292
+ raise
293
+ finally:
294
+ self.durations[i] += time.perf_counter() - start
295
+
296
+
297
+ class Frame:
298
+ """What an analysis sees of the current frame.
299
+
300
+ Attributes
301
+ ----------
302
+ index : int
303
+ The index of the frame in the run (0 for its first frame).
304
+ time : float
305
+ The time of the frame, as the trajectory reports it.
306
+ universe : mda.Universe
307
+ The Universe, positioned at this frame.
308
+ output : RunOutput
309
+ Where the run's analyses write their files (the same as `Run.output`),
310
+ e.g. to append this frame to a file.
311
+ """
312
+
313
+ __slots__ = ["index", "time", "universe", "output", "_tracker"]
314
+
315
+ def __init__(self, index: int, tracker: ClusterTracker, output: RunOutput) -> None:
316
+ """Initialize the current frame of a run.
317
+
318
+ Parameters
319
+ ----------
320
+ index : int
321
+ The index of the frame in the run.
322
+ tracker : ClusterTracker
323
+ The tracker, up to date with the frame.
324
+ output : RunOutput
325
+ Where the run's analyses write their files.
326
+ """
327
+ self.index = index
328
+ self.time: float = tracker.uni.coord.time
329
+ self.universe = tracker.uni
330
+ self.output = output
331
+ self._tracker = tracker
332
+
333
+ @property
334
+ def clusters(self) -> Mapping[int, Cluster]:
335
+ """The clusters of the frame, keyed by cluster id (read-only).
336
+
337
+ Returns
338
+ -------
339
+ Mapping[int, Cluster]
340
+ A read-only view of the clusters; the clusters must not be modified.
341
+ """
342
+ return MappingProxyType(self._tracker.clusters)
343
+
344
+ @property
345
+ def transition(self) -> Transition:
346
+ """How the frame's clusters came from the previous frame's.
347
+
348
+ Returns
349
+ -------
350
+ Transition
351
+ Where each cluster's molecules came from, which clusters are new, and
352
+ which ended, merged or dissolved (on the run's first frame, every
353
+ cluster is new).
354
+ """
355
+ return self._tracker.transition
356
+
357
+ def find(self, mol: int) -> int | None:
358
+ """Find the id of the cluster a molecule belongs to.
359
+
360
+ Parameters
361
+ ----------
362
+ mol : int
363
+ The molecule's resid.
364
+
365
+ Returns
366
+ -------
367
+ int | None
368
+ The cluster id, or None if the molecule is in no cluster.
369
+ """
370
+ return self._tracker.find(mol)
@@ -0,0 +1,124 @@
1
+ # SPDX-FileCopyrightText: © 2026 Emanuel Mancio <emanuelmancio@usp.br>
2
+ #
3
+ # SPDX-License-Identifier: LGPL-3.0-or-later
4
+
5
+ """Provides `BUILTINS`, the built-in analyses, in the order they run.
6
+
7
+ Classes:
8
+ --------
9
+ - Builtin: A built-in analysis, how the config builds it and what it needs.
10
+
11
+ Functions:
12
+ ----------
13
+ - build_builtins: Build the built-in analyses a config runs.
14
+
15
+ A built-in runs unless the config's `analyses` turns it off (by class name),
16
+ and only when the config options it needs are set. Adding one takes a line in
17
+ `BUILTINS`; the config checks `analyses` against it. They run in its order, the
18
+ report last (after the analyses given to `MolClusters` too).
19
+ """
20
+
21
+ from collections.abc import Callable, Iterable
22
+ from dataclasses import dataclass
23
+
24
+ from ..config import MolClsConfig
25
+ from .base import FrameAnalysis
26
+ from .coordinates import ClusterCoordinates
27
+ from .lineage import Lineage
28
+ from .nucleus import Nucleus
29
+ from .report import JsonReport
30
+ from .size import SizeEvolution
31
+ from .solute import SoluteSolvent
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Builtin:
36
+ """A built-in analysis, how the config builds it and what it needs.
37
+
38
+ Attributes
39
+ ----------
40
+ kind : type[FrameAnalysis]
41
+ The analysis class; its name turns it on or off in the config's `analyses`.
42
+ build : Callable[[MolClsConfig], FrameAnalysis]
43
+ Builds the analysis from the config, once the options it needs are set.
44
+ needs : tuple[str, ...]
45
+ The config options it needs, without which it doesn't run.
46
+ last : bool
47
+ Whether it runs after every other analysis, the ones given to
48
+ `MolClusters` included: the report, which the others add to.
49
+ """
50
+
51
+ kind: type[FrameAnalysis]
52
+ build: Callable[[MolClsConfig], FrameAnalysis]
53
+ needs: tuple[str, ...] = ()
54
+ last: bool = False
55
+
56
+ @property
57
+ def name(self) -> str:
58
+ """The analysis' class name, as the config's `analyses` and the log name it.
59
+
60
+ Returns
61
+ -------
62
+ str
63
+ E.g. ``"JsonReport"``.
64
+ """
65
+ return self.kind.__name__
66
+
67
+ def runs(self, config: MolClsConfig) -> bool:
68
+ """Whether the config runs this analysis.
69
+
70
+ Parameters
71
+ ----------
72
+ config : MolClsConfig
73
+ The analysis configuration.
74
+
75
+ Returns
76
+ -------
77
+ bool
78
+ True unless `analyses` turns it off or an option it needs isn't set.
79
+ """
80
+ return config.analyses.get(self.name, True) and all(
81
+ getattr(config, option) is not None for option in self.needs
82
+ )
83
+
84
+
85
+ BUILTINS: tuple[Builtin, ...] = (
86
+ Builtin(SizeEvolution, lambda c: SizeEvolution()),
87
+ Builtin(Lineage, lambda c: Lineage()),
88
+ Builtin(
89
+ SoluteSolvent, lambda c: SoluteSolvent(c.solute, c.solvent), needs=("solute",)
90
+ ),
91
+ Builtin(
92
+ ClusterCoordinates,
93
+ lambda c: ClusterCoordinates(c.solute, follow=c._follow_solute),
94
+ needs=("solute",),
95
+ ),
96
+ Builtin(Nucleus, lambda c: Nucleus(c.nucleus), needs=("nucleus",)),
97
+ Builtin(JsonReport, lambda c: JsonReport(c.report_compression), last=True),
98
+ )
99
+
100
+
101
+ def build_builtins(
102
+ config: MolClsConfig, extra: Iterable[FrameAnalysis] = ()
103
+ ) -> list[FrameAnalysis]:
104
+ """Build the built-in analyses a config runs, and order them with `extra`.
105
+
106
+ Parameters
107
+ ----------
108
+ config : MolClsConfig
109
+ The analysis configuration, with the topology-dependent defaults filled in.
110
+ extra : Iterable[FrameAnalysis]
111
+ Other analyses to run, after the built-ins but those that run `last`.
112
+
113
+ Returns
114
+ -------
115
+ list[FrameAnalysis]
116
+ The analyses in the order they run: the built-ins the config runs (see
117
+ `Builtin.runs`), then `extra`, then the built-ins that run last.
118
+ """
119
+ built = [(b.last, b.build(config)) for b in BUILTINS if b.runs(config)]
120
+ return [
121
+ *(analysis for last, analysis in built if not last),
122
+ *extra,
123
+ *(analysis for last, analysis in built if last),
124
+ ]