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,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
+ )