smoldynutils 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rgrosseholz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: smoldynutils
3
+ Version: 0.1.0
4
+ Summary: A small collection of utility tools to process the output of Smoldyn simulations.
5
+ License: MIT
6
+ License-File: LICENSE.md
7
+ Keywords: smoldyn,simulation,modeling
8
+ Author: Fabian Ormersbach
9
+ Author-email: fabian.ormersbach@maastrichtuniversity.nl
10
+ Requires-Python: >=3.12
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Dist: matplotlib (>=3.10.8,<4.0.0)
14
+ Requires-Dist: numpy (>=2.4.2,<3.0.0)
15
+ Requires-Dist: pandas (>=3.0.0,<4.0.0)
16
+ Requires-Dist: scipy (>=1.17.0,<2.0.0)
17
+ Requires-Dist: seaborn (>=0.13.2,<0.14.0)
18
+ Project-URL: Homepage, https://github.com/rgrosseholz/smoldynutils
19
+ Project-URL: Issues, https://github.com/rgrosseholz/smoldynutils/issues
20
+ Project-URL: Repository, https://github.com/rgrosseholz/smoldynutils
21
+ Description-Content-Type: text/markdown
22
+
23
+ # smoldynutils
24
+ Utilities for parsing, analyzing, and visualizing Smoldyn simulation outputs.
25
+
26
+ ## Installation
27
+ From GitHub with poetry:
28
+ ```bash
29
+ git clone https://github.com/rgrosseholz/smoldynutils.git
30
+ cd smoldynutils
31
+ poetry install
32
+ poetry shell
33
+ ```
34
+
35
+ Or via pip:
36
+ ```bash
37
+ pip install smoldynutils
38
+ ```
39
+
40
+ ## Quickstart
41
+ ```python
42
+ from smoldynutils.parser import SmoldynParser
43
+
44
+ parser = SmoldynParser(delimiter=",")
45
+ trajectories = parser.parse_fixed_grid("molpos_output.txt")
46
+
47
+ for traj in trajectories:
48
+ print(traj.positions)
49
+ ```
50
+
51
+ ## Authors
52
+ Fabian Ormersbach, Maastricht Centre for Systems Biology and Bioinformatics, Maastricht University
53
+ Ruth Grosseholz, Maastricht Centre for Systems Biology and Bioinformatics, Maastricht University
54
+
55
+ ## License
56
+ This project is licensed under the MIT License. See `LICENSE.md` for details.
57
+
@@ -0,0 +1,34 @@
1
+ # smoldynutils
2
+ Utilities for parsing, analyzing, and visualizing Smoldyn simulation outputs.
3
+
4
+ ## Installation
5
+ From GitHub with poetry:
6
+ ```bash
7
+ git clone https://github.com/rgrosseholz/smoldynutils.git
8
+ cd smoldynutils
9
+ poetry install
10
+ poetry shell
11
+ ```
12
+
13
+ Or via pip:
14
+ ```bash
15
+ pip install smoldynutils
16
+ ```
17
+
18
+ ## Quickstart
19
+ ```python
20
+ from smoldynutils.parser import SmoldynParser
21
+
22
+ parser = SmoldynParser(delimiter=",")
23
+ trajectories = parser.parse_fixed_grid("molpos_output.txt")
24
+
25
+ for traj in trajectories:
26
+ print(traj.positions)
27
+ ```
28
+
29
+ ## Authors
30
+ Fabian Ormersbach, Maastricht Centre for Systems Biology and Bioinformatics, Maastricht University
31
+ Ruth Grosseholz, Maastricht Centre for Systems Biology and Bioinformatics, Maastricht University
32
+
33
+ ## License
34
+ This project is licensed under the MIT License. See `LICENSE.md` for details.
@@ -0,0 +1,73 @@
1
+ [project]
2
+ name = "smoldynutils"
3
+ version = "0.1.0"
4
+ description = "A small collection of utility tools to process the output of Smoldyn simulations."
5
+ authors = [
6
+ {name = "Fabian Ormersbach",email = "fabian.ormersbach@maastrichtuniversity.nl"},
7
+ {name = "Ruth Grosseholz",email = "ruth.grosseholz@maastrichtuniversity.nl"},
8
+ ]
9
+ license = {text = "MIT"}
10
+ readme = "README.md"
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "numpy (>=2.4.2,<3.0.0)",
14
+ "pandas (>=3.0.0,<4.0.0)",
15
+ "matplotlib (>=3.10.8,<4.0.0)",
16
+ "scipy (>=1.17.0,<2.0.0)",
17
+ "seaborn (>=0.13.2,<0.14.0)"
18
+ ]
19
+ keywords = ["smoldyn", "simulation", "modeling"]
20
+ classifiers = [
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ ]
24
+ packages = [{ include = "smoldynutils", from = "src" }]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/rgrosseholz/smoldynutils"
28
+ Repository = "https://github.com/rgrosseholz/smoldynutils"
29
+ Issues = "https://github.com/rgrosseholz/smoldynutils/issues"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "flake8 (>=7.3.0,<8.0.0)",
34
+ "pytest (>=9.0.2,<10.0.0)",
35
+ "pytest-cov (>=7.0.0,<8.0.0)",
36
+ "black (>=26.1.0,<27.0.0)",
37
+ "pre-commit (>=4.5.1,<5.0.0)",
38
+ "ruff (>=0.15.0,<0.16.0)",
39
+ "isort (>=7.0.0,<8.0.0)",
40
+ "mypy (>=1.19.1,<2.0.0)"
41
+ ]
42
+
43
+ [tool.poetry]
44
+ packages = [{include = "smoldynutils", from = "src"}]
45
+
46
+ [tool.black]
47
+ line-length = 100
48
+ target-version = ["py312"]
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ lint.select = ["E", "F", "W", "C90", "N"]
53
+ lint.ignore = ["E501"]
54
+ extend-exclude = ["tests"]
55
+
56
+ [tool.ruff.lint.pep8-naming]
57
+ ignore-names = ["D", "MSD"]
58
+
59
+ [tool.isort]
60
+ profile = "black"
61
+ src_paths = ["src", "tests"]
62
+
63
+ [tool.mypy]
64
+ python_version = "3.12"
65
+ strict = true
66
+ ignore_missing_imports = true
67
+ mypy_path = ["src"]
68
+ explicit_package_bases = true
69
+ exclude = ["^tests/"]
70
+
71
+ [build-system]
72
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
73
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("smoldynutils")
5
+ except PackageNotFoundError:
6
+ # package not installed (e.g. local dev)
7
+ __version__ = "0.0.0"
@@ -0,0 +1,190 @@
1
+ import warnings
2
+ from dataclasses import dataclass
3
+ from typing import Iterator, Optional, Sequence, Type, Union, overload
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Trajectory:
10
+ """Immutable container for trajectory."""
11
+
12
+ serialnumber: int
13
+ t: np.ndarray
14
+ x: np.ndarray
15
+ y: np.ndarray
16
+ species: np.ndarray
17
+
18
+ def __post_init__(self) -> None:
19
+ """Performs sensibility checks.
20
+
21
+ Raises:
22
+ ValueError: Differing lenghts of t, x, y, or species
23
+ ValueError: >1D for t, x, y, or species
24
+ TypeError: Species is not integer
25
+ """
26
+ n = len(self.t)
27
+ if not (len(self.x) == len(self.y) == len(self.species) == n):
28
+ raise ValueError("t, x, y, and species must have the same length")
29
+ if self.t.ndim != 1 or self.x.ndim != 1 or self.y.ndim != 1 or self.species.ndim != 1:
30
+ raise ValueError("t, x, y, species must be 1D arrays")
31
+ if not np.issubdtype(self.species.dtype, np.integer):
32
+ raise TypeError("Species must be integer-coded")
33
+ self._check_jumps(self.x)
34
+ self._check_jumps(self.y)
35
+
36
+ def _check_jumps(self, positions: np.ndarray) -> None:
37
+ jump_sensitivity = 0.5
38
+ max_pos = np.max(np.abs(positions))
39
+ forward_diff = np.diff(positions)
40
+ upper_jumps = forward_diff < jump_sensitivity * max_pos * -1
41
+ lower_jumps = forward_diff > jump_sensitivity * max_pos
42
+
43
+ def user_format_warning(
44
+ message: Warning | str,
45
+ category: Type[Warning],
46
+ filename: str,
47
+ lineno: int,
48
+ line: Optional[str] = None,
49
+ ) -> str:
50
+ return f"Warning: {message}\n"
51
+
52
+ if (upper_jumps + lower_jumps).sum() != 0:
53
+ warnings.formatwarning = user_format_warning
54
+ warnings.warn(f"Large jumps in trajectory {self.serialnumber} detected.", UserWarning)
55
+
56
+ def __len__(self) -> int:
57
+ """Returns number of points in trajectory
58
+
59
+ Returns:
60
+ int: Number of timepoints in trajectory
61
+ """
62
+ return len(self.t)
63
+
64
+ def __eq__(self, other: object) -> bool:
65
+ """Checks for equality.
66
+
67
+ Args:
68
+ other (object): Can be Trajectory or dict containing data.
69
+
70
+ Returns:
71
+ bool: True if t, x, y, and species match. False otherwise. NotImplemented if other is not dict or Trajectory.
72
+ """
73
+ if isinstance(other, Trajectory):
74
+ serial_bool = self.serialnumber == other.serialnumber
75
+ t_bool = np.allclose(self.t, other.t)
76
+ x_bool = np.allclose(self.x, other.x)
77
+ y_bool = np.allclose(self.y, other.y)
78
+ species_bool = np.allclose(self.species, other.species)
79
+ return serial_bool and t_bool and x_bool and y_bool and species_bool
80
+ if isinstance(other, dict):
81
+ if len(other["t"]) != len(self):
82
+ return False
83
+ serial_bool = self.serialnumber == other["serialnum"]
84
+ t_bool = np.allclose(self.t, other["t"])
85
+ x_bool = np.allclose(self.x, other["x"])
86
+ y_bool = np.allclose(self.y, other["y"])
87
+ species_bool = np.allclose(self.species, other["species"])
88
+ return serial_bool and t_bool and x_bool and y_bool and species_bool
89
+
90
+ return NotImplemented
91
+
92
+ def __getitem__(self, i: int) -> tuple[int, float, float, float, int]:
93
+ return (
94
+ self.serialnumber,
95
+ self.t[i],
96
+ self.x[i],
97
+ self.y[i],
98
+ self.species[i],
99
+ )
100
+
101
+ @staticmethod
102
+ def adjust_for_periodic_boundaries(
103
+ position: np.ndarray, min_pos: float, max_pos: float
104
+ ) -> np.ndarray:
105
+ size = max_pos - min_pos
106
+ half_delta = 0.5 * (size)
107
+ forward_diff = np.diff(position, prepend=position[0])
108
+ upper_jumps = forward_diff < -1 * half_delta
109
+ lower_jumps = forward_diff > half_delta
110
+ if (upper_jumps + lower_jumps).sum() == 0:
111
+ return position
112
+ upper_jumps_cumsum = upper_jumps.cumsum() * size
113
+ lower_jumps_cumsum = lower_jumps.cumsum() * size * -1
114
+ position_mask = upper_jumps_cumsum + lower_jumps_cumsum
115
+ return position + position_mask
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class TrajectorySet:
120
+ """Immutable container for set of trajectories."""
121
+
122
+ trajectories: tuple[Trajectory, ...]
123
+
124
+ @classmethod
125
+ def from_list(cls, trajectories: Sequence[Trajectory]) -> "TrajectorySet":
126
+ """Create TrajectorySet from sequence of trajectories
127
+
128
+ Args:
129
+ trajectories (Sequence[Trajectory]): Sequence of `Trajectory` objects.
130
+
131
+ Returns:
132
+ TrajectorySet: Contains provided Trajectories
133
+ """
134
+ return cls(tuple(trajectories))
135
+
136
+ def __len__(self) -> int:
137
+ """Returns the number of trajectories in the set
138
+
139
+ Returns:
140
+ int: Number of stored trajectories
141
+ """
142
+ return len(self.trajectories)
143
+
144
+ def __getitem__(self, key: int) -> Trajectory:
145
+ """Return trajectory by index.
146
+
147
+ Args:
148
+ key (int): Index of trajectory to retrieve
149
+
150
+ Returns:
151
+ Trajectory: Trajectory at given index
152
+ """
153
+ return self.trajectories[key]
154
+
155
+ @overload
156
+ def __add__(self, other: "TrajectorySet") -> "TrajectorySet": ...
157
+ @overload
158
+ def __add__(self, other: Trajectory) -> "TrajectorySet": ...
159
+
160
+ def __add__(self, other: Union["TrajectorySet", Trajectory]) -> "TrajectorySet":
161
+ """Combines given trajectories
162
+
163
+ Args:
164
+ other (Union[TrajectorySet, Trajectory]): TrajectorySet or Trajectory to combine with current
165
+
166
+ Returns:
167
+ TrajectorySet: New TrajectorySet containing the combined trajectories.
168
+ """
169
+ if isinstance(other, TrajectorySet):
170
+ return TrajectorySet(self.trajectories + other.trajectories)
171
+ if isinstance(other, Trajectory):
172
+ return TrajectorySet(self.trajectories + (other,))
173
+ return NotImplemented
174
+
175
+ def __iter__(self) -> Iterator[Trajectory]:
176
+ """Iterate over trajectories
177
+
178
+ Yields:
179
+ Trajectory: Trajectorie object
180
+ """
181
+ return iter(self.trajectories)
182
+
183
+ @property
184
+ def serialnums(self) -> np.ndarray:
185
+ serialnums = np.zeros(len(self))
186
+ for index, traj in enumerate(self):
187
+ serialnums[index] = traj.serialnumber
188
+ return serialnums
189
+
190
+ # TODO: Methods .t, .x, ... that return array of values of all trajectories
@@ -0,0 +1,147 @@
1
+ import warnings
2
+ from typing import cast
3
+
4
+ import numpy as np
5
+ from scipy.optimize import curve_fit
6
+
7
+ from smoldynutils.data_objects import Trajectory
8
+ from smoldynutils.utils import theoretical_msd, theoretical_msd_residue
9
+
10
+ FloatArray = np.typing.NDArray[np.floating]
11
+
12
+
13
+ def calc_displacements(traj_values: FloatArray, lag: int = 1) -> FloatArray:
14
+ """Calculates the displacement depending on time lag.
15
+
16
+ Eq: x(t+lag) - x(t)
17
+
18
+ Args:
19
+ traj_values (np.ndarray): x or y values
20
+ lag (int, optional): Controls the shift of the window. Defaults to 1.
21
+
22
+ Raises:
23
+ ValueError: Chosen timelag is bigger than the length of x/y
24
+
25
+ Returns:
26
+ np.ndarray: Timelag displacement values
27
+ """
28
+ if lag > len(traj_values) - 1:
29
+ raise ValueError("Timelag is bigger than length of trajectory.")
30
+ displacement = traj_values[lag:] - traj_values[:-lag]
31
+ return displacement
32
+
33
+
34
+ def calc_xy_displacement(traj: Trajectory, lag: int = 1) -> tuple[np.ndarray, np.ndarray]:
35
+ """Feeds x and y of Trajectory into calc_displacements
36
+
37
+ Args:
38
+ traj (Trajectory): Trajectory object for which x and y displacements should be calculated.
39
+ lag (int, optional): Controls the shift of the window. Defaults to 1.
40
+
41
+ Raises:
42
+ ValueError: Chosen Timelag is bigger than the trajectory is long.
43
+
44
+ Returns:
45
+ tuple[np.ndarray, np.ndarray]: Timelag displacements in x and y direction
46
+ """
47
+ if lag > len(traj.x) - 1 or lag > len(traj.y) - 1:
48
+ raise ValueError("Timelag is bigger than number of datapoints in x or y")
49
+ x_displacement = calc_displacements(traj.x, lag)
50
+ y_displacement = calc_displacements(traj.y, lag)
51
+
52
+ return (x_displacement, y_displacement)
53
+
54
+
55
+ def calc_msd(displacment: np.ndarray) -> np.ndarray:
56
+ """Calculates mean squeared displacement.
57
+
58
+ Equation: mean(dx**2)
59
+
60
+ Args:
61
+ displacment (np.ndarray): Displacement values.
62
+
63
+ Returns:
64
+ np.ndarray: Mean squared displacement values.
65
+ """
66
+ squared_displacement = displacment**2
67
+ mean_squared_displacement = np.array(np.mean(squared_displacement))
68
+ return mean_squared_displacement
69
+
70
+
71
+ def calc_xy_msd(displacements: tuple[np.ndarray, np.ndarray]) -> tuple[np.ndarray, np.ndarray]:
72
+ """Feeds x and y into calc_msd
73
+
74
+ Args:
75
+ displacements (tuple[np.ndarray, np.ndarray]): x displacement followed by y displacement
76
+
77
+ Returns:
78
+ tuple[np.ndarray, np.ndarray]: MSD of x and y
79
+ """
80
+ x_msd = calc_msd(displacements[0])
81
+ y_msd = calc_msd(displacements[1])
82
+ return (x_msd, y_msd)
83
+
84
+
85
+ def calc_sq_displacement_from_zero(traj_values: FloatArray) -> FloatArray:
86
+ """Calculates displacement relative to start position.
87
+
88
+ Args:
89
+ traj_values (np.ndarray): Position value of Trajectory
90
+
91
+ Returns:
92
+ np.ndarray: Displacement from start position.
93
+ """
94
+ x0 = float(traj_values[0])
95
+ return (traj_values - x0) ** 2
96
+
97
+
98
+ def calc_combined_msd(msds: tuple[np.ndarray, np.ndarray]) -> np.ndarray:
99
+ return np.array(msds[0] + msds[1])
100
+
101
+
102
+ def estimate_diffcoff_fullinfo(
103
+ msds: np.ndarray, timepoints: np.ndarray, add_epsilon: bool = False
104
+ ) -> tuple[FloatArray, FloatArray]:
105
+ """Estimates diffusion coefficient from MSD.
106
+
107
+ Fitted equation is MSD = 4*D*t
108
+
109
+ Args:
110
+ msds (np.ndarray): Array of MSD values
111
+ timepoints (np.ndarray): Array of timelag or time values
112
+ add_epsilon (bool, optional): Use equation MSD = 4*D*t + epsilon for fitting. Defaults to False.
113
+ return_full (bool, optional): Return full information about curve fitting. Defaults to False.
114
+
115
+ Returns:
116
+ np.ndarray: _description_
117
+ """
118
+
119
+ if len(timepoints) < 2 and add_epsilon is True:
120
+ warnings.warn(
121
+ "Cannot fit with epsilon if only one timelag given. Setting add_epsilon to False.",
122
+ UserWarning,
123
+ )
124
+ add_epsilon = False
125
+ if add_epsilon is True:
126
+ line_fit = curve_fit(theoretical_msd_residue, timepoints, msds)
127
+ else:
128
+ line_fit = curve_fit(theoretical_msd, timepoints, msds)
129
+ return cast(tuple[FloatArray, FloatArray], line_fit)
130
+
131
+
132
+ def estimate_diffcoff(msds: np.ndarray, timepoints: np.ndarray, add_epsilon: bool = False) -> float:
133
+ """Estimates diffusion coefficient from MSD.
134
+
135
+ Fitted equation is MSD = 4*D*t
136
+
137
+ Args:
138
+ msds (np.ndarray): Array of MSD values
139
+ timepoints (np.ndarray): Array of timelag or time values
140
+ add_epsilon (bool, optional): Use equation MSD = 4*D*t + epsilon for fitting. Defaults to False.
141
+ return_full (bool, optional): Return full information about curve fitting. Defaults to False.
142
+
143
+ Returns:
144
+ np.ndarray: _description_
145
+ """
146
+ popt, _ = estimate_diffcoff_fullinfo(msds, timepoints, add_epsilon)
147
+ return float(popt[0])
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+
9
+ from smoldynutils.data_objects import Trajectory, TrajectorySet
10
+
11
+
12
+ @dataclass
13
+ class SmoldynParser:
14
+ path: str
15
+ delimiter: str = ","
16
+ dt: float = 0.5
17
+ min_val: Optional[float] = None
18
+ max_val: Optional[float] = None
19
+
20
+ def parse_fixed_grid(
21
+ self,
22
+ dtype_xy: npt.DTypeLike = np.float64,
23
+ dtype_t: npt.DTypeLike = np.float32,
24
+ dtype_species: npt.DTypeLike = np.uint16,
25
+ dtype_serialnum: npt.DTypeLike = np.uint32,
26
+ ) -> TrajectorySet:
27
+ """Parser based on numpy loadtxt assuming equal size of all trajectories.
28
+
29
+ Sorts based on time and serialnumber. Then generates Trajectories based on expected size.
30
+
31
+ Args:
32
+ path (str): Path to smoldyn data (assuming listmols2 command)
33
+ delimiter (str, optional): Column delimiter. Defaults to ",".
34
+ dtype_xy (np.float32, optional): xy data type. Defaults to np.float32.
35
+ dtype_t (np.float32, optional): t data type. Defaults to np.float32.
36
+ dtype_species (np.uint16, optional): Species data type. Defaults to np.uint16.
37
+
38
+ Returns:
39
+ TrajectorySet: Set of read trajectories.
40
+ """
41
+ file_content = np.loadtxt(self.path, delimiter=self.delimiter, dtype=np.float32)
42
+ if file_content.size == 0:
43
+ raise ValueError("Data file appears to be empty.")
44
+ t = file_content[:, 0].astype(dtype_t, copy=False)
45
+ serial_number = file_content[:, 5].astype(dtype_serialnum, copy=False)
46
+ order = np.lexsort((t, serial_number))
47
+
48
+ t = t[order]
49
+ serial_number = serial_number[order]
50
+ species = file_content[:, 1].astype(dtype_species, copy=False)[order]
51
+ x = file_content[:, 3].astype(dtype_xy, copy=False)[order]
52
+ y = file_content[:, 4].astype(dtype_xy, copy=False)[order]
53
+ serial_number = file_content[:, 5].astype(dtype_serialnum, copy=False)[order]
54
+
55
+ serial_ids, serial_start, serial_counts = np.unique(
56
+ serial_number, return_index=True, return_counts=True
57
+ )
58
+
59
+ expected = int(serial_counts[0])
60
+ if not np.all(serial_counts == expected):
61
+ raise NotImplementedError(
62
+ "Not a fixed grid. Serials have different number of timepoints."
63
+ )
64
+
65
+ trajs: list[Trajectory] = []
66
+ for sid, start in zip(serial_ids, serial_start):
67
+ end = start + expected
68
+ if self.min_val is not None and self.max_val is not None:
69
+ trajs.append(
70
+ Trajectory(
71
+ int(sid),
72
+ t=t[start:end],
73
+ x=Trajectory.adjust_for_periodic_boundaries(
74
+ x[start:end], self.min_val, self.max_val
75
+ ),
76
+ y=Trajectory.adjust_for_periodic_boundaries(
77
+ y[start:end], self.min_val, self.max_val
78
+ ),
79
+ species=species[start:end],
80
+ )
81
+ )
82
+ else:
83
+ trajs.append(
84
+ Trajectory(
85
+ int(sid),
86
+ t=t[start:end],
87
+ x=x[start:end],
88
+ y=y[start:end],
89
+ species=species[start:end],
90
+ )
91
+ )
92
+
93
+ return TrajectorySet(tuple(trajs))
@@ -0,0 +1,220 @@
1
+ from typing import Optional, Sequence, Union, Dict
2
+
3
+ import numpy as np
4
+ from matplotlib.axes import Axes
5
+
6
+ from smoldynutils.data_objects import Trajectory, TrajectorySet
7
+
8
+ import seaborn as sns
9
+
10
+ FloatArray = np.typing.NDArray[np.floating]
11
+
12
+
13
+ def plot_gauss_comparison(
14
+ displacement: np.ndarray,
15
+ gauss_vals: np.ndarray,
16
+ ax: Axes,
17
+ bins: Union[str, Sequence[float]] = "fd",
18
+ title: str = "Title",
19
+ ) -> Axes:
20
+ """Plots histogram of measured displacement and theoretical displacement.
21
+
22
+ Args:
23
+ displacement (np.ndarray): Measured displacement
24
+ gauss_vals (np.ndarray): Theoretical expectation
25
+ ax (Axes, optional): Axis to plot onto. Defaults to None.
26
+ bins (str, optional): Algorithm to determine bins or bins. Defaults to "fd".
27
+ title (str, optional): Title for the plot. Defaults to "Title".
28
+
29
+ Raises:
30
+ ValueError: No axis to plot onto provided.
31
+
32
+ Returns:
33
+ Axes: Axis that contains the histogram.
34
+ """
35
+ ax.hist(displacement, bins=bins, density=True)
36
+ ax.hist(gauss_vals, bins=bins, density=True)
37
+ ax.set_xlabel("Δx")
38
+ ax.set_ylabel("density")
39
+ ax.set_title(title)
40
+ return ax
41
+
42
+
43
+ def plot_trajectorie(traj: Trajectory, ax: Axes, title: str = "Title") -> Axes:
44
+ """Simple xy plot of a single trajectory.
45
+
46
+ Args:
47
+ traj (Trajectory): Trajectory to plot
48
+ ax (Axes): Axis onto which the trajectory will be plotted
49
+ title (str, optional): Figure title. Defaults to "Title".
50
+
51
+ Raises:
52
+ ValueError: No axis to plot onto provided
53
+
54
+ Returns:
55
+ Axes: Axis that contains the xy plot
56
+ """
57
+
58
+ ax.plot(traj.x, traj.y, color="black")
59
+ ax.scatter(traj.x, traj.y, c=traj.t)
60
+ ax.set_xlabel("x")
61
+ ax.set_ylabel("y")
62
+ ax.set_title(title)
63
+ return ax
64
+
65
+
66
+ def plot_trajectories(trajs: TrajectorySet, ax: Axes, title: str = "Title") -> Axes:
67
+ """Creates xy plot for multiple trajectories.
68
+
69
+ Args:
70
+ trajs (TrajectorySet): Set of trajectories
71
+ ax (Axes): Axis onto which the trajectory will be plotted
72
+ title (str, optional): Figure title. Defaults to "Title".
73
+
74
+ Raises:
75
+ ValueError: No axis to plot onto provided
76
+
77
+ Returns:
78
+ Axes: Axis that contains the xy plot
79
+ """
80
+
81
+ for traj in trajs:
82
+ ax = plot_trajectorie(traj, ax, title)
83
+ return ax
84
+
85
+
86
+ def plot_msd(
87
+ msd: np.ndarray,
88
+ ax: Axes,
89
+ time: Optional[np.ndarray] = None,
90
+ title: str = "Title",
91
+ color: Optional[str] = None,
92
+ ) -> Axes:
93
+ if time is None:
94
+ time = np.arange(len(msd))
95
+ if color is None:
96
+ color = "blue"
97
+ if len(msd.shape) > 2:
98
+ raise ValueError("Input MSD array is > 2D")
99
+ if not len(msd) == len(time):
100
+ msd = msd.T
101
+ if not len(msd) == len(time):
102
+ raise ValueError("Input MSD array and time array have no shape in common.")
103
+ ax.plot(time, msd, color=color)
104
+ ax.set_xlabel("time")
105
+ ax.set_ylabel("msd")
106
+ ax.set_title(title)
107
+ return ax
108
+
109
+
110
+ def plot_msd_comparison(
111
+ msd: np.ndarray,
112
+ theoretical_msd: np.ndarray,
113
+ ax: Axes,
114
+ time: Optional[np.ndarray] = None,
115
+ title: str = "Title",
116
+ ) -> Axes:
117
+ """Lineplot showing the calculated MSD values vs the theoretical expectation.
118
+
119
+ Args:
120
+ msd (np.ndarray): Calculated MSD values
121
+ theoretical_msd (np.ndarray): Theoretically expected MSD values
122
+ ax (Axes): Axis onto which will be plotted
123
+ title (str, optional): Figure title. Defaults to "Title".
124
+
125
+ Returns:
126
+ Axes: Axis that contains the msd comparison.
127
+ """
128
+ if len(msd) != len(theoretical_msd):
129
+ raise ValueError(
130
+ f"Mismatch in MSD arrays: input array length={len(msd)}, theoretical array length={len(theoretical_msd)}"
131
+ )
132
+ if time is None:
133
+ time = np.arange(len(msd))
134
+ ax = plot_msd(msd, ax, time=time)
135
+ ax = plot_msd(theoretical_msd, ax, time=time, color="red")
136
+ ax.set_xlabel("time")
137
+ ax.set_ylabel("msd")
138
+ ax.set_title(title)
139
+ return ax
140
+
141
+
142
+ def plot_diffconst_hist(
143
+ diffcoffs: np.ndarray, reference_diffcoff: float, ax: Axes, title: str = "Title"
144
+ ) -> Axes:
145
+ """Plots histogram of diffusion coefficients
146
+
147
+ Args:
148
+ diffcoffs (np.ndarray): Array of diffusion coefficients
149
+ reference_diffcoff (float): Expected diffusion coefficient
150
+ ax (Axes): Axes onto which will be plotted
151
+ title (str, optional): Plot title. Defaults to "Title".
152
+
153
+ Returns:
154
+ Axes: Axes with histogram
155
+ """
156
+
157
+ lower_bound = min(diffcoffs)
158
+ upper_bound = max(diffcoffs)
159
+ bins = list(np.linspace(lower_bound, upper_bound, 20))
160
+ ax.hist(diffcoffs, bins=bins)
161
+ ax.set_xscale("log")
162
+
163
+ ax.axvline(float(np.mean(diffcoffs)))
164
+
165
+ ax.axvline(reference_diffcoff)
166
+
167
+ ax.set_xlabel("Diffusion coefficient")
168
+ ax.set_ylabel("Count")
169
+ ax.set_title(title)
170
+ return ax
171
+
172
+
173
+ def plot_violin_with_mean(
174
+ diffcoff: Dict[float, FloatArray],
175
+ reference_diffcoffs: Sequence[float],
176
+ permeability: Sequence[float],
177
+ ax: Axes,
178
+ title: str = "Title",
179
+ ) -> Axes:
180
+ """Generates a violinplot of diffcoff vs permeability.
181
+
182
+ Args:
183
+ diffcoff (Dict[float, FloatArray]): Permeability vs diffusion coefficients
184
+ reference_diffcoffs (Sequence[float]): Expected diffusion coefficients
185
+ permeability (Sequence[float]): Permeabilities for x axis
186
+ ax (Axes): Axes onto which will be plotted
187
+ title (str, optional): Title of plot. Defaults to "Title".
188
+
189
+ Raises:
190
+ ValueError: Number of entries in diffcoff does not match number of permeabilities
191
+
192
+ Returns:
193
+ Axes: Axis that contains violin plots
194
+ """
195
+ if not len(diffcoff.keys()) == len(permeability):
196
+ raise ValueError(
197
+ "Number of entries in diffcoff dict does not match number of permeabilites."
198
+ )
199
+ sns.violinplot(diffcoff, ax=ax, order=list(diffcoff.keys()), color="skyblue", inner=None)
200
+ mean_ds = [np.mean(vals) for vals in diffcoff.values()]
201
+ indices = np.arange(0, len(diffcoff.keys()))
202
+ ax.scatter(indices, mean_ds, color="black", marker="_", zorder=10, alpha=1, s=100)
203
+ ax.axhline(
204
+ reference_diffcoffs[0],
205
+ color="red",
206
+ linestyle="--",
207
+ linewidth=1,
208
+ label=f"WT D={reference_diffcoffs[0]}",
209
+ )
210
+ ax.axhline(
211
+ reference_diffcoffs[1],
212
+ color="blue",
213
+ linestyle=":",
214
+ linewidth=1,
215
+ label=f"PHSD D={reference_diffcoffs[1]}",
216
+ )
217
+ ax.set_xlabel("Permeability")
218
+ ax.set_ylabel("Diffusion coefficient")
219
+ ax.set_title(title)
220
+ return ax
@@ -0,0 +1,27 @@
1
+ import numpy as np
2
+
3
+
4
+ def gauss_probability_density(x: float, mu: float, sigma: float) -> float:
5
+ if sigma <= 0:
6
+ raise ValueError("sigma must be > 0")
7
+
8
+ value = (1 / (np.sqrt(2 * np.pi) * sigma)) * np.exp(-np.square(x - mu) / (2 * sigma**2))
9
+ return float(value)
10
+
11
+
12
+ def theoretical_brownian_motion_pdf(x: float, D: float, t: float) -> float:
13
+ if D <= 0:
14
+ raise ValueError("D must be > 0")
15
+ if t < 0:
16
+ raise ValueError("t must be >= 0")
17
+ sigma = np.sqrt(2 * D * t)
18
+ mu = 0
19
+ return gauss_probability_density(x, mu, sigma)
20
+
21
+
22
+ def theoretical_msd(t: float, D: float) -> float:
23
+ return 4 * D * t
24
+
25
+
26
+ def theoretical_msd_residue(t: float, D: float, epsilon: float) -> float:
27
+ return 4 * D * t + epsilon
@@ -0,0 +1,97 @@
1
+ from typing import Dict, Sequence
2
+
3
+ import numpy as np
4
+
5
+ from smoldynutils.data_objects import Trajectory, TrajectorySet
6
+ from smoldynutils.metrics import (
7
+ calc_combined_msd,
8
+ calc_sq_displacement_from_zero,
9
+ calc_xy_displacement,
10
+ calc_xy_msd,
11
+ estimate_diffcoff,
12
+ )
13
+
14
+
15
+ def estimate_timelag_msd_from_traj(traj: Trajectory, timelags: Sequence[int]) -> Dict[int, float]:
16
+ """Calculates MSD(timelag) for trajectory.
17
+
18
+ Args:
19
+ traj (Trajectory): Trajectory for which MSD will be calculated
20
+ timelags (Sequence[int]): Sequence of timelags that will be used
21
+
22
+ Returns:
23
+ Dict[int, float]: Keys are timelags, values the corresponding MSD
24
+ """
25
+ msd_dict = {}
26
+ for timelag in timelags:
27
+ xy_displacement = calc_xy_displacement(traj, timelag)
28
+ xy_msd = calc_xy_msd(xy_displacement)
29
+ msd = calc_combined_msd(xy_msd)
30
+ msd_dict[timelag] = float(msd)
31
+ return msd_dict
32
+
33
+
34
+ def estimate_timelag_diffcoff_from_trajset(
35
+ trajs: TrajectorySet, timelags: Sequence[int] = (1, 2, 3, 4), add_epsilon: bool = False
36
+ ) -> Dict[int, float]:
37
+ """Calculates observed diffusion coefficient based on MSD(timelag) for set of trajectories
38
+
39
+ Args:
40
+ trajs (TrajectorySet): Set of trajectories for which diff coff will be calculated
41
+ timelags (Sequence[int], optional): Sequence of timelags for MSD calculation. Defaults to (1, 2, 3, 4).
42
+
43
+ Returns:
44
+ Dict[int, float]: Keys are trajectory serialnums or index, values the corresponding diff coff.
45
+ """
46
+ diffcoffs = {}
47
+ use_index_for_dict = False
48
+ timelag_array = np.array(timelags)
49
+ if len(np.unique(trajs.serialnums)) < len(trajs):
50
+ use_index_for_dict = True
51
+ for index, traj in enumerate(trajs):
52
+ msd_dict = estimate_timelag_msd_from_traj(traj, timelags)
53
+ msds = np.array(list(msd_dict.values()))
54
+ if use_index_for_dict is True:
55
+ diffcoffs[index] = estimate_diffcoff(msds, timelag_array, add_epsilon=add_epsilon)
56
+ else:
57
+ diffcoffs[traj.serialnumber] = estimate_diffcoff(
58
+ msds, timelag_array, add_epsilon=add_epsilon
59
+ )
60
+ return diffcoffs
61
+
62
+
63
+ def estimate_time_msd_from_traj(traj: Trajectory) -> np.ndarray:
64
+ """Calculates MSD(time) for trajectory.
65
+
66
+ Args:
67
+ traj (Trajectory): Trajectory for which MSD will be calculated
68
+
69
+ Returns:
70
+ np.ndarray: Calculated MSDs.
71
+ """
72
+ x_sqdisplacement = calc_sq_displacement_from_zero(traj.x)
73
+ y_sqdisplacement = calc_sq_displacement_from_zero(traj.y)
74
+ msd = calc_combined_msd((x_sqdisplacement, y_sqdisplacement))
75
+ return msd
76
+
77
+
78
+ def estimate_time_diffcoff_from_trajset(trajs: TrajectorySet) -> Dict[int, float]:
79
+ """Estimates diffusion coefficient of set of Trajectories based on MSD(time)
80
+
81
+ Args:
82
+ trajs (TrajectorySet): Set of trajectories for which diffusion coefficient will be estimated.
83
+
84
+ Returns:
85
+ Dict[int, float]: Keys are serialnums or index, values are diffcoffs
86
+ """
87
+ diffcoffs = {}
88
+ use_index_for_dict = False
89
+ if len(np.unique(trajs.serialnums)) < len(trajs):
90
+ use_index_for_dict = True
91
+ for index, traj in enumerate(trajs):
92
+ msd = estimate_time_msd_from_traj(traj)
93
+ if use_index_for_dict is True:
94
+ diffcoffs[index] = estimate_diffcoff(msd, traj.t)
95
+ else:
96
+ diffcoffs[traj.serialnumber] = estimate_diffcoff(msd, traj.t)
97
+ return diffcoffs