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.
- molclusters/__init__.py +68 -0
- molclusters/__main__.py +19 -0
- molclusters/analysis/__init__.py +40 -0
- molclusters/analysis/base.py +370 -0
- molclusters/analysis/builtins.py +124 -0
- molclusters/analysis/coordinates.py +186 -0
- molclusters/analysis/lineage.py +306 -0
- molclusters/analysis/nucleus.py +168 -0
- molclusters/analysis/report.py +191 -0
- molclusters/analysis/size.py +83 -0
- molclusters/analysis/solute.py +144 -0
- molclusters/cluster.py +1092 -0
- molclusters/config.py +727 -0
- molclusters/conntable.py +725 -0
- molclusters/log.py +220 -0
- molclusters/main.py +587 -0
- molclusters/molclusters.py +328 -0
- molclusters/output.py +320 -0
- molclusters/report.py +504 -0
- molclusters/symdict.py +177 -0
- molclusters/tracker.py +435 -0
- molclusters/version.py +10 -0
- molclusters-0.7.0.dist-info/METADATA +127 -0
- molclusters-0.7.0.dist-info/RECORD +31 -0
- molclusters-0.7.0.dist-info/WHEEL +4 -0
- molclusters-0.7.0.dist-info/entry_points.txt +3 -0
- molclusters-0.7.0.dist-info/licenses/COPYING +674 -0
- molclusters-0.7.0.dist-info/licenses/LICENSE +165 -0
- molclusters-0.7.0.dist-info/licenses/LICENSES/CC0-1.0.txt +121 -0
- molclusters-0.7.0.dist-info/licenses/LICENSES/LGPL-3.0-or-later.txt +304 -0
- molclusters-0.7.0.dist-info/licenses/LICENSES/OFL-1.1.txt +43 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Emanuel Mancio <emanuelmancio@usp.br>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Provides `ClusterCoordinates`, the coordinates of the clusters holding solutes (.gro files)."""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from pathlib import PurePosixPath
|
|
11
|
+
|
|
12
|
+
import MDAnalysis as mda
|
|
13
|
+
from loguru import logger
|
|
14
|
+
from MDAnalysis.lib.util import NamedStream
|
|
15
|
+
|
|
16
|
+
from ..cluster import Cluster
|
|
17
|
+
from ..output import OutputFile
|
|
18
|
+
from .base import Frame, FrameAnalysis, Run
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClusterCoordinates(FrameAnalysis):
|
|
22
|
+
"""Writes the coordinates of every cluster holding a solute, frame by frame.
|
|
23
|
+
|
|
24
|
+
Each such cluster, made whole, is appended to ``cls-n<size>.gro`` (the clusters
|
|
25
|
+
of one size, pooled) and ``cls-id<id>.gro`` (one cluster's own trajectory). When
|
|
26
|
+
following the solutes, a cluster holding exactly one solute is also appended to
|
|
27
|
+
``solute-<resid>.gro``. The files go in the ``folder`` of the output directory.
|
|
28
|
+
|
|
29
|
+
Attributes
|
|
30
|
+
----------
|
|
31
|
+
solutes : set[str]
|
|
32
|
+
The residue names of the solutes.
|
|
33
|
+
follow : bool
|
|
34
|
+
Whether to write each solute's cluster to its own file.
|
|
35
|
+
folder : str
|
|
36
|
+
The folder of the output directory the files go in.
|
|
37
|
+
solute_ids : set[int]
|
|
38
|
+
The resids of the solute molecules, found by `prepare`.
|
|
39
|
+
follow_skipped : Counter[int]
|
|
40
|
+
Cluster id -> number of frames it wasn't followed in, for holding more than
|
|
41
|
+
one solute.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
solutes: Iterable[str],
|
|
47
|
+
*,
|
|
48
|
+
follow: bool = False,
|
|
49
|
+
folder: str = "coordinates",
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize the analysis.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
solutes : Iterable[str]
|
|
56
|
+
The residue names of the solutes.
|
|
57
|
+
follow : bool
|
|
58
|
+
Whether to write each solute's cluster to its own file.
|
|
59
|
+
folder : str
|
|
60
|
+
The folder of the output directory the files go in ("" for the
|
|
61
|
+
output directory itself).
|
|
62
|
+
"""
|
|
63
|
+
self.solutes = set(solutes)
|
|
64
|
+
self.follow = follow
|
|
65
|
+
self.folder = folder
|
|
66
|
+
self.solute_ids: set[int] = set()
|
|
67
|
+
self.follow_skipped: Counter[int] = Counter()
|
|
68
|
+
|
|
69
|
+
self.outputs = (
|
|
70
|
+
OutputFile(self._file("cls-n<size>.gro")),
|
|
71
|
+
OutputFile(self._file("cls-id<id>.gro")),
|
|
72
|
+
)
|
|
73
|
+
if follow:
|
|
74
|
+
self.outputs += (OutputFile(self._file("solute-<resid>.gro")),)
|
|
75
|
+
|
|
76
|
+
def _file(self, name: str) -> str:
|
|
77
|
+
"""Place the file `name` in `folder`.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
name : str
|
|
82
|
+
The file name.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
str
|
|
87
|
+
The file's path relative to the output directory.
|
|
88
|
+
"""
|
|
89
|
+
return str(PurePosixPath(self.folder, name))
|
|
90
|
+
|
|
91
|
+
def prepare(self, run: Run) -> None:
|
|
92
|
+
"""Find the solute molecules.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
run : Run
|
|
97
|
+
The run about to start.
|
|
98
|
+
"""
|
|
99
|
+
self.solute_ids = {
|
|
100
|
+
int(resid)
|
|
101
|
+
for name in sorted(self.solutes)
|
|
102
|
+
for resid in run.universe.select_atoms(f"resname {name}").residues.resids
|
|
103
|
+
}
|
|
104
|
+
self.follow_skipped = Counter()
|
|
105
|
+
|
|
106
|
+
def analyse(self, frame: Frame) -> None:
|
|
107
|
+
"""Append the clusters of the current frame that hold a solute.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
frame : Frame
|
|
112
|
+
The current frame.
|
|
113
|
+
"""
|
|
114
|
+
for cls in frame.clusters.values():
|
|
115
|
+
sol_ids = self.solute_ids.intersection(cls.resids)
|
|
116
|
+
if not sol_ids:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
text = self._render(cls, frame.time)
|
|
120
|
+
|
|
121
|
+
# pooled by size: an ensemble of what an N-mer looks like, across all
|
|
122
|
+
# clusters that were ever that size, independent of cluster identity
|
|
123
|
+
frame.output.append(self._file(f"cls-n{cls.size}.gro"), text)
|
|
124
|
+
|
|
125
|
+
# pooled by identity: this specific cluster's own trajectory, tracked
|
|
126
|
+
# across frames via the dominance algorithm regardless of size changes
|
|
127
|
+
frame.output.append(self._file(f"cls-id{cls.id}.gro"), text)
|
|
128
|
+
|
|
129
|
+
# TODO: change to support merges
|
|
130
|
+
# FIXME: with changes in config this needs to be updated
|
|
131
|
+
if self.follow:
|
|
132
|
+
if len(sol_ids) > 1:
|
|
133
|
+
# TODO: make more feature-rich follow procedure
|
|
134
|
+
# counted, not logged: `finish` sums them up
|
|
135
|
+
self.follow_skipped[cls.id] += 1
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
(sol_id,) = sol_ids
|
|
139
|
+
|
|
140
|
+
frame.output.append(self._file(f"solute-{sol_id}.gro"), text)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _render(cls: Cluster, time: float) -> str:
|
|
144
|
+
"""Render a cluster, made whole, as one frame of a .gro file.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
cls : Cluster
|
|
149
|
+
The cluster to render.
|
|
150
|
+
time : float
|
|
151
|
+
The time of the current frame, for the title line.
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
str
|
|
156
|
+
The frame's text, title line included.
|
|
157
|
+
"""
|
|
158
|
+
# the GRO writer only writes whole files, so render the frame in memory
|
|
159
|
+
# (NamedStream keeps the buffer open when the writer closes it)
|
|
160
|
+
buf = io.StringIO()
|
|
161
|
+
with (
|
|
162
|
+
mda.Writer(NamedStream(buf, "cluster.gro"), multiframe=False) as w,
|
|
163
|
+
cls.whole() as atoms,
|
|
164
|
+
):
|
|
165
|
+
w.write(atoms.sort())
|
|
166
|
+
|
|
167
|
+
# replace the writer's fixed "Written by MDAnalysis" title line
|
|
168
|
+
_, body = buf.getvalue().split("\n", 1)
|
|
169
|
+
return f"Cluster-{cls.id} - Time = {time}\n{body}"
|
|
170
|
+
|
|
171
|
+
def finish(self, run: Run) -> None:
|
|
172
|
+
"""Warn about the frames in which solutes weren't followed.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
run : Run
|
|
177
|
+
The run that just ended.
|
|
178
|
+
"""
|
|
179
|
+
if self.follow_skipped:
|
|
180
|
+
logger.warning(
|
|
181
|
+
f"Solutes were not followed in {self.follow_skipped.total()} "
|
|
182
|
+
f"frame(s) of {len(self.follow_skipped)} cluster(s) holding more than "
|
|
183
|
+
"one solute: those frames are missing from the solute-<resid>.gro "
|
|
184
|
+
"files. The cluster ids are logged at DEBUG level (--log-level DEBUG)."
|
|
185
|
+
)
|
|
186
|
+
logger.debug(f"Frames not followed, by cluster id: {self.follow_skipped}")
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Emanuel Mancio <emanuelmancio@usp.br>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Provides `Lineage`, where the clusters came from, what became of them and when.
|
|
6
|
+
|
|
7
|
+
It writes cluster_events.csv, the clusters' formations, splits, merges and
|
|
8
|
+
dissolutions frame by frame, and cluster_lifetimes.csv, each cluster's birth,
|
|
9
|
+
end and lifetime.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
from ..output import OutputFile
|
|
22
|
+
from .base import Frame, FrameAnalysis, Run
|
|
23
|
+
|
|
24
|
+
EVENTS = "cluster_events.csv"
|
|
25
|
+
LIFETIMES = "cluster_lifetimes.csv"
|
|
26
|
+
|
|
27
|
+
EVENT_COLUMNS = ["Frame", "Time", "Event", "Cluster", "Other", "NMols"]
|
|
28
|
+
|
|
29
|
+
LIFETIME_COLUMNS = [
|
|
30
|
+
"Id",
|
|
31
|
+
"BirthTime",
|
|
32
|
+
"DeathTime",
|
|
33
|
+
"Lifetime",
|
|
34
|
+
"NFrames",
|
|
35
|
+
"BornAtStart",
|
|
36
|
+
"AliveAtEnd",
|
|
37
|
+
"Origin",
|
|
38
|
+
"Parents",
|
|
39
|
+
"Fate",
|
|
40
|
+
"MergedInto",
|
|
41
|
+
"BirthSize",
|
|
42
|
+
"MaxSize",
|
|
43
|
+
"LastSize",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# what each event row says, for the log
|
|
47
|
+
_DESCRIPTIONS = {
|
|
48
|
+
"formation": "cluster {cluster} formed of {n} free molecule(s)",
|
|
49
|
+
"split": "cluster {cluster} split off cluster {other} with {n} molecule(s)",
|
|
50
|
+
"merge": "cluster {other} merged into cluster {cluster} with {n} molecule(s)",
|
|
51
|
+
"dissolution": "cluster {cluster} ({n} molecule(s)) dissolved",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class _Life:
|
|
57
|
+
"""What `Lineage` knows of a cluster: its birth, its size so far and its end."""
|
|
58
|
+
|
|
59
|
+
birth_time: float
|
|
60
|
+
born_at_start: bool
|
|
61
|
+
origin: str
|
|
62
|
+
parents: tuple[int, ...]
|
|
63
|
+
birth_size: int
|
|
64
|
+
max_size: int
|
|
65
|
+
last_size: int
|
|
66
|
+
n_frames: int = 1
|
|
67
|
+
death_time: float = math.nan
|
|
68
|
+
fate: str = "alive"
|
|
69
|
+
merged_into: int | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Lineage(FrameAnalysis):
|
|
73
|
+
"""Records where each cluster came from, what became of it, and when.
|
|
74
|
+
|
|
75
|
+
Events are read from each frame's `Frame.transition` and stamped with the
|
|
76
|
+
first frame that shows them, so a cluster's `DeathTime` is the time of the
|
|
77
|
+
first frame it's gone from, and its lifetime, ``DeathTime - BirthTime``, a
|
|
78
|
+
whole number of frame spacings (a cluster seen in a single frame lived one
|
|
79
|
+
spacing).
|
|
80
|
+
|
|
81
|
+
cluster_events.csv has one row per event (see `EVENT_COLUMNS`); ``Other`` is
|
|
82
|
+
the other cluster involved, if any, and ``NMols`` the molecules it concerns:
|
|
83
|
+
|
|
84
|
+
- ``formation``: `Cluster` is new, made only of free molecules (``NMols``, its
|
|
85
|
+
size);
|
|
86
|
+
- ``split``: `Cluster` is new, and took ``NMols`` molecules from cluster
|
|
87
|
+
``Other``. A cluster made of pieces of several clusters has a row for each,
|
|
88
|
+
and one breaking into several pieces a row for each new piece;
|
|
89
|
+
- ``merge``: cluster ``Other`` ended, merged into `Cluster` with ``NMols`` of
|
|
90
|
+
its molecules; a merge of several clusters has a row for each one absorbed;
|
|
91
|
+
- ``dissolution``: `Cluster` ended without merging (``NMols``, its last size).
|
|
92
|
+
|
|
93
|
+
The clusters of the run's first frame have no event: whatever formed them
|
|
94
|
+
happened before the run.
|
|
95
|
+
|
|
96
|
+
cluster_lifetimes.csv has one row per cluster (see `LIFETIME_COLUMNS`):
|
|
97
|
+
``Origin`` is "initial" (there at the first frame), "formation" or "split",
|
|
98
|
+
``Parents`` the clusters it took molecules from (";"-separated, most
|
|
99
|
+
molecules first), ``Fate`` "merge", "dissolution" or "alive" (at the last
|
|
100
|
+
frame), and ``MergedInto`` the cluster that absorbed it. The lifetimes of
|
|
101
|
+
clusters born at the start or alive at the end are censored: they lived at
|
|
102
|
+
least that long. One alive at the end has no ``DeathTime``, and its
|
|
103
|
+
``Lifetime`` is up to the last frame.
|
|
104
|
+
|
|
105
|
+
Each frame's `Frame.transition` also goes in the report (see
|
|
106
|
+
`report_frame`), for the flows of molecules between clusters.
|
|
107
|
+
|
|
108
|
+
Attributes
|
|
109
|
+
----------
|
|
110
|
+
lives : dict[int, _Life]
|
|
111
|
+
Cluster id -> what is known of the cluster so far, for every cluster seen.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
__slots__ = ["lives", "_time"]
|
|
115
|
+
|
|
116
|
+
outputs = (OutputFile(EVENTS), OutputFile(LIFETIMES))
|
|
117
|
+
|
|
118
|
+
def __init__(self) -> None:
|
|
119
|
+
"""Initialize an empty record, started over by `prepare`."""
|
|
120
|
+
self.lives: dict[int, _Life] = {}
|
|
121
|
+
self._time = math.nan
|
|
122
|
+
|
|
123
|
+
def prepare(self, run: Run) -> None:
|
|
124
|
+
"""Start the record over, and cluster_events.csv with its header.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
run : Run
|
|
129
|
+
The run about to start.
|
|
130
|
+
"""
|
|
131
|
+
self.lives = {}
|
|
132
|
+
self._time = math.nan
|
|
133
|
+
run.output.append(EVENTS, ",".join(EVENT_COLUMNS) + "\n")
|
|
134
|
+
|
|
135
|
+
def analyse(self, frame: Frame) -> None:
|
|
136
|
+
"""Record the current frame's events, and the clusters' sizes.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
frame : Frame
|
|
141
|
+
The current frame.
|
|
142
|
+
"""
|
|
143
|
+
transition = frame.transition
|
|
144
|
+
time = float(frame.time)
|
|
145
|
+
self._time = time
|
|
146
|
+
rows: list[tuple[str, int, int | None, int]] = []
|
|
147
|
+
|
|
148
|
+
for cid in sorted(transition.born):
|
|
149
|
+
size = frame.clusters[cid].size
|
|
150
|
+
parents = sorted(
|
|
151
|
+
((prev, n) for prev, n in transition.sources(cid).items() if prev),
|
|
152
|
+
key=lambda source: (-source[1], source[0]),
|
|
153
|
+
)
|
|
154
|
+
if frame.index == 0:
|
|
155
|
+
origin = "initial"
|
|
156
|
+
elif parents:
|
|
157
|
+
origin = "split"
|
|
158
|
+
rows.extend(("split", cid, prev, n) for prev, n in parents)
|
|
159
|
+
else:
|
|
160
|
+
origin = "formation"
|
|
161
|
+
rows.append(("formation", cid, None, size))
|
|
162
|
+
self.lives[cid] = _Life(
|
|
163
|
+
birth_time=time,
|
|
164
|
+
born_at_start=frame.index == 0,
|
|
165
|
+
origin=origin,
|
|
166
|
+
parents=tuple(prev for prev, _ in parents),
|
|
167
|
+
birth_size=size,
|
|
168
|
+
max_size=size,
|
|
169
|
+
last_size=size,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
for lost, into in sorted(transition.merged.items()):
|
|
173
|
+
rows.append(("merge", into, lost, transition.flows[lost, into]))
|
|
174
|
+
self._end(lost, time, "merge", into)
|
|
175
|
+
for cid in sorted(transition.dissolved):
|
|
176
|
+
rows.append(("dissolution", cid, None, self.lives[cid].last_size))
|
|
177
|
+
self._end(cid, time, "dissolution")
|
|
178
|
+
|
|
179
|
+
for cid, cls in frame.clusters.items():
|
|
180
|
+
if cid not in transition.born:
|
|
181
|
+
life = self.lives[cid]
|
|
182
|
+
life.n_frames += 1
|
|
183
|
+
life.last_size = cls.size
|
|
184
|
+
life.max_size = max(life.max_size, life.last_size)
|
|
185
|
+
|
|
186
|
+
number = int(frame.universe.coord.frame)
|
|
187
|
+
for event, cid, other, n in rows:
|
|
188
|
+
logger.debug(
|
|
189
|
+
"Frame {}: " + _DESCRIPTIONS[event],
|
|
190
|
+
number,
|
|
191
|
+
cluster=cid,
|
|
192
|
+
other=other,
|
|
193
|
+
n=n,
|
|
194
|
+
)
|
|
195
|
+
other_text = "" if other is None else other
|
|
196
|
+
frame.output.append(
|
|
197
|
+
EVENTS, f"{number},{time!r},{event},{cid},{other_text},{n}\n"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def report_frame(self, frame: Frame) -> dict[str, Any]:
|
|
201
|
+
"""Give how the frame's clusters came from the previous frame's, for the report.
|
|
202
|
+
|
|
203
|
+
Parameters
|
|
204
|
+
----------
|
|
205
|
+
frame : Frame
|
|
206
|
+
The current frame.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
dict[str, Any]
|
|
211
|
+
The frame's `Frame.transition`, sorted: ``flows``, rows of (previous
|
|
212
|
+
id, current id, molecules), id 0 for no cluster; ``born``; ``merged``,
|
|
213
|
+
rows of (absorbed id, id it merged into); and ``dissolved``.
|
|
214
|
+
"""
|
|
215
|
+
transition = frame.transition
|
|
216
|
+
return {
|
|
217
|
+
"flows": [
|
|
218
|
+
[prev, cur, n] for (prev, cur), n in sorted(transition.flows.items())
|
|
219
|
+
],
|
|
220
|
+
"born": sorted(transition.born),
|
|
221
|
+
"merged": [list(pair) for pair in sorted(transition.merged.items())],
|
|
222
|
+
"dissolved": sorted(transition.dissolved),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def _end(
|
|
226
|
+
self, cid: int, time: float, fate: str, merged_into: int | None = None
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Record the end of a cluster.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
cid : int
|
|
233
|
+
The cluster's id.
|
|
234
|
+
time : float
|
|
235
|
+
The time of the first frame without it.
|
|
236
|
+
fate : str
|
|
237
|
+
"merge" or "dissolution".
|
|
238
|
+
merged_into : int | None
|
|
239
|
+
The cluster that absorbed it, for a merge.
|
|
240
|
+
"""
|
|
241
|
+
life = self.lives[cid]
|
|
242
|
+
life.death_time = time
|
|
243
|
+
life.fate = fate
|
|
244
|
+
life.merged_into = merged_into
|
|
245
|
+
|
|
246
|
+
def lifetimes(self) -> pd.DataFrame:
|
|
247
|
+
"""Tabulate every cluster seen so far, as cluster_lifetimes.csv has them.
|
|
248
|
+
|
|
249
|
+
Returns
|
|
250
|
+
-------
|
|
251
|
+
pd.DataFrame
|
|
252
|
+
One row per cluster, by id (see `LIFETIME_COLUMNS`).
|
|
253
|
+
"""
|
|
254
|
+
rows = [
|
|
255
|
+
[
|
|
256
|
+
cid,
|
|
257
|
+
life.birth_time,
|
|
258
|
+
life.death_time,
|
|
259
|
+
(self._time if life.fate == "alive" else life.death_time)
|
|
260
|
+
- life.birth_time,
|
|
261
|
+
life.n_frames,
|
|
262
|
+
life.born_at_start,
|
|
263
|
+
life.fate == "alive",
|
|
264
|
+
life.origin,
|
|
265
|
+
";".join(map(str, life.parents)),
|
|
266
|
+
life.fate,
|
|
267
|
+
life.merged_into,
|
|
268
|
+
life.birth_size,
|
|
269
|
+
life.max_size,
|
|
270
|
+
life.last_size,
|
|
271
|
+
]
|
|
272
|
+
for cid, life in sorted(self.lives.items())
|
|
273
|
+
]
|
|
274
|
+
table = pd.DataFrame(rows, columns=LIFETIME_COLUMNS)
|
|
275
|
+
table["MergedInto"] = table["MergedInto"].astype("Int64")
|
|
276
|
+
return table
|
|
277
|
+
|
|
278
|
+
def finish(self, run: Run) -> None:
|
|
279
|
+
"""Log a summary of the events and lifetimes, and write cluster_lifetimes.csv.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
run : Run
|
|
284
|
+
The run that just ended.
|
|
285
|
+
"""
|
|
286
|
+
table = self.lifetimes()
|
|
287
|
+
table.to_csv(run.output.path(LIFETIMES), index=False)
|
|
288
|
+
|
|
289
|
+
origins = Counter(table["Origin"])
|
|
290
|
+
fates = Counter(table["Fate"])
|
|
291
|
+
message = (
|
|
292
|
+
f"Cluster lineage: {origins['formation']} cluster(s) formed of free "
|
|
293
|
+
f"molecules and {origins['split']} split off others; "
|
|
294
|
+
f"{fates['merge']} merged into another and {fates['dissolution']} "
|
|
295
|
+
"dissolved."
|
|
296
|
+
)
|
|
297
|
+
# lifetimes neither censored at the start nor at the end
|
|
298
|
+
whole = table[~table["BornAtStart"] & ~table["AliveAtEnd"]]
|
|
299
|
+
if len(whole):
|
|
300
|
+
single = int((whole["NFrames"] == 1).sum())
|
|
301
|
+
message += (
|
|
302
|
+
f" The {len(whole)} born and ended within the run lived "
|
|
303
|
+
f"{np.median(whole['Lifetime']):.4g} ps (median); {single} of them "
|
|
304
|
+
"for a single frame."
|
|
305
|
+
)
|
|
306
|
+
logger.info(message)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2024 Emanuel Mancio <emanuelmancio@usp.br>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Provides `Nucleus`, the nuclei inside the clusters (nucleus_data.csv)."""
|
|
6
|
+
|
|
7
|
+
import operator
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from functools import reduce
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import networkx as nx
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ..cluster import Cluster, MolGroup
|
|
17
|
+
from ..output import OutputFile
|
|
18
|
+
from ..report import group_record
|
|
19
|
+
from .base import Frame, FrameAnalysis, Run
|
|
20
|
+
|
|
21
|
+
COLUMNS = [
|
|
22
|
+
"Time",
|
|
23
|
+
"NNuc",
|
|
24
|
+
"Size",
|
|
25
|
+
"Radius",
|
|
26
|
+
"Density",
|
|
27
|
+
"Charge",
|
|
28
|
+
"Dipole",
|
|
29
|
+
"Spher",
|
|
30
|
+
"Shape",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Nucleus(FrameAnalysis):
|
|
35
|
+
"""Finds the nuclei inside the clusters, frame by frame.
|
|
36
|
+
|
|
37
|
+
A nucleus is a connected group, inside a cluster, of molecules with one of the
|
|
38
|
+
nucleus residue names. Each cluster's nuclei also go in the report (see
|
|
39
|
+
`report_cluster`).
|
|
40
|
+
|
|
41
|
+
Attributes
|
|
42
|
+
----------
|
|
43
|
+
resnames : set[str]
|
|
44
|
+
The residue names of the molecules that make up nuclei.
|
|
45
|
+
nuclei : dict[int, list[MolGroup]]
|
|
46
|
+
Cluster id -> the cluster's nuclei in the current frame (an empty list for
|
|
47
|
+
a cluster without nuclei), for the analyses after this one.
|
|
48
|
+
data : np.ndarray
|
|
49
|
+
One row per frame (see `COLUMNS`): time, the average number of nuclei of
|
|
50
|
+
the clusters that have any (0 without nuclei), and the nuclei's average
|
|
51
|
+
size, radius, density, charge, dipole moment, sphericity and shape
|
|
52
|
+
parameter (NaN without nuclei).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
outputs = (OutputFile("nucleus_data.csv"),)
|
|
56
|
+
|
|
57
|
+
def __init__(self, resnames: Iterable[str]) -> None:
|
|
58
|
+
"""Initialize an empty record, sized by `prepare`.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
resnames : Iterable[str]
|
|
63
|
+
The residue names of the molecules that make up nuclei.
|
|
64
|
+
"""
|
|
65
|
+
self.resnames = set(resnames)
|
|
66
|
+
self.nuclei: dict[int, list[MolGroup]] = {}
|
|
67
|
+
self.data = np.full((0, len(COLUMNS)), np.nan)
|
|
68
|
+
|
|
69
|
+
def prepare(self, run: Run) -> None:
|
|
70
|
+
"""Make room for every frame of the run.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
run : Run
|
|
75
|
+
The run about to start.
|
|
76
|
+
"""
|
|
77
|
+
self.data = np.full((run.n_frames, len(COLUMNS)), np.nan)
|
|
78
|
+
|
|
79
|
+
def analyse(self, frame: Frame) -> None:
|
|
80
|
+
"""Find the nuclei of the current frame and record their averages.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
frame : Frame
|
|
85
|
+
The current frame.
|
|
86
|
+
"""
|
|
87
|
+
self.nuclei = {}
|
|
88
|
+
n_nucleus = []
|
|
89
|
+
sizes = []
|
|
90
|
+
radius = []
|
|
91
|
+
dipole = []
|
|
92
|
+
density = []
|
|
93
|
+
sphericity = []
|
|
94
|
+
shape = []
|
|
95
|
+
charge = []
|
|
96
|
+
for cid, cls in frame.clusters.items():
|
|
97
|
+
possible_nucleus = [
|
|
98
|
+
rid
|
|
99
|
+
for rnm, rid in zip(cls.resnames, cls.resids, strict=True)
|
|
100
|
+
if rnm in self.resnames
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
self.nuclei[cid] = []
|
|
104
|
+
subcomps = nx.induced_subgraph(cls.graph, possible_nucleus)
|
|
105
|
+
n_nuc = 0
|
|
106
|
+
for sg in nx.connected_components(subcomps):
|
|
107
|
+
tp = MolGroup(frame.universe, list(sg))
|
|
108
|
+
|
|
109
|
+
n_nuc += 1
|
|
110
|
+
sizes.append(tp.size)
|
|
111
|
+
radius.append(tp.radius)
|
|
112
|
+
dipole.append(tp.dipole_moment)
|
|
113
|
+
density.append(tp.density)
|
|
114
|
+
sphericity.append(tp.sphericity)
|
|
115
|
+
shape.append(tp.shape_parameter)
|
|
116
|
+
charge.append(tp.charge)
|
|
117
|
+
|
|
118
|
+
self.nuclei[cid].append(tp)
|
|
119
|
+
|
|
120
|
+
if n_nuc != 0:
|
|
121
|
+
n_nucleus.append(n_nuc)
|
|
122
|
+
|
|
123
|
+
row = self.data[frame.index]
|
|
124
|
+
row[0] = frame.time
|
|
125
|
+
row[1] = 0 if len(n_nucleus) == 0 else np.average(n_nucleus)
|
|
126
|
+
row[2] = np.nan if len(n_nucleus) == 0 else np.average(sizes)
|
|
127
|
+
row[3] = np.nan if len(n_nucleus) == 0 else np.average(radius)
|
|
128
|
+
row[4] = np.nan if len(n_nucleus) == 0 else np.average(density)
|
|
129
|
+
row[5] = np.nan if len(n_nucleus) == 0 else np.average(charge)
|
|
130
|
+
row[6] = np.nan if len(n_nucleus) == 0 else np.average(dipole)
|
|
131
|
+
row[7] = np.nan if len(n_nucleus) == 0 else np.average(sphericity)
|
|
132
|
+
row[8] = np.nan if len(n_nucleus) == 0 else np.average(shape)
|
|
133
|
+
|
|
134
|
+
def report_cluster(self, frame: Frame, cluster: Cluster) -> dict[str, Any]:
|
|
135
|
+
"""Give the nuclei of a cluster, for the report.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
frame : Frame
|
|
140
|
+
The current frame.
|
|
141
|
+
cluster : Cluster
|
|
142
|
+
The cluster.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
dict[str, Any]
|
|
147
|
+
``nuclei``, one `report.group_record` per nucleus, and
|
|
148
|
+
``combined_dipole_moment``, the dipole moment of all of them together
|
|
149
|
+
(None without nuclei).
|
|
150
|
+
"""
|
|
151
|
+
nuclei = self.nuclei.get(cluster.id, [])
|
|
152
|
+
combined = reduce(operator.add, nuclei).dipole_moment if nuclei else None
|
|
153
|
+
return {
|
|
154
|
+
"nuclei": [group_record(nucleus) for nucleus in nuclei],
|
|
155
|
+
"combined_dipole_moment": combined,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def finish(self, run: Run) -> None:
|
|
159
|
+
"""Write the record to nucleus_data.csv.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
run : Run
|
|
164
|
+
The run that just ended.
|
|
165
|
+
"""
|
|
166
|
+
pd.DataFrame(self.data, columns=COLUMNS).to_csv(
|
|
167
|
+
run.output.path("nucleus_data.csv"), index=False
|
|
168
|
+
)
|