emulsim 0.5.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.
emulsim/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ r"""The `emulsim` package provides classes for describing physical system that consists of
2
+ multiple `elements`, which together describe the state of the system.
3
+
4
+ The dynamical
5
+ rules are encoded in `actors`, which either act on individual elements, encoding their
6
+ autonomous dynamics, or on multiple elements, introducing couplings.
7
+ """
8
+
9
+ # determine the package version
10
+ try:
11
+ # try reading version from the automatically generated module
12
+ from ._version import __version__ # type: ignore
13
+ except ImportError:
14
+ # determine version automatically from CVS information
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ try:
18
+ __version__ = version("emulsim")
19
+ except PackageNotFoundError:
20
+ # package is not installed, so we cannot determine any version
21
+ __version__ = "unknown"
22
+ del PackageNotFoundError, version # clean name space
23
+
24
+ # make key classes from modelrunner available
25
+ from modelrunner import Parameter
26
+
27
+ # import key classes from emulsim package into general namespace
28
+ from .actors import *
29
+ from .elements import *
30
+ from .simulation import Simulation
31
+ from .state import State
32
+ from .trackers import *
emulsim/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,59 @@
1
+ """Provides actors that determine the dynamics of the simulation by modifying the state
2
+ of elements during each time step. Actors are separated into several categories, which
3
+ we describe separately below.
4
+
5
+ **General actors** are classes that provide basic infrastructure to implement custom
6
+ implementations:
7
+
8
+ .. autosummary::
9
+ :nosignatures:
10
+
11
+
12
+ ~base.ActorBase
13
+ ~function.FunctionActor
14
+ ~function.NumbaFunctionActor
15
+
16
+
17
+ **Autonomous actors** only affect a single element and thus describe the autonomous
18
+ dynamics of this element when it is not coupled to other elements.
19
+
20
+ .. autosummary::
21
+ :nosignatures:
22
+
23
+ ~autonomous.active_particles.ActiveParticleActor
24
+ ~autonomous.box.BoxActor
25
+ ~autonomous.brownian_motion.BrownianMotionActor
26
+ ~autonomous.coalescence.CoalescenceDropletActor
27
+ ~autonomous.emitters.EmittersActor
28
+ ~autonomous.fields.LocalReactionsActor
29
+ ~autonomous.fields.ScalarPDEActor
30
+ ~autonomous.fields.DiffusionActor
31
+ ~autonomous.fields.ReactionDiffusionActor
32
+ ~autonomous.fields.CollectionPDEActor
33
+
34
+
35
+ **Coupling actors** affect several elements and thus describe a coupling between these
36
+ elements.
37
+
38
+ .. autosummary::
39
+ :nosignatures:
40
+
41
+ ~coupling.nucleation.DropletNucleationActor
42
+ ~coupling.fields.FieldCouplingActor
43
+ ~coupling.fields.FieldExchangeActor
44
+ ~coupling.fields.FieldBoundaryExchangeActor
45
+ ~coupling.point_droplet.PointDropletActor
46
+ ~coupling.spherical_droplet.SphericalDropletActor
47
+ ~coupling.multicomponent_droplet.MulticomponentDropletActor
48
+
49
+
50
+ Use :func:`~base.find_actors` to discover actors that are compatible with a given list
51
+ of elements.
52
+
53
+ .. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
54
+ """
55
+
56
+ from .autonomous import *
57
+ from .base import ActorBase, find_actors
58
+ from .coupling import *
59
+ from .function import FunctionActor, NumbaFunctionActor
@@ -0,0 +1,31 @@
1
+ """Provides actors that affect single elements in a simulation.
2
+
3
+ .. autosummary::
4
+ :nosignatures:
5
+
6
+ ~active_particles.ActiveParticleActor
7
+ ~box.BoxActor
8
+ ~brownian_motion.BrownianMotionActor
9
+ ~coalescence.CoalescenceDropletActor
10
+ ~emitters.EmittersActor
11
+ ~fields.LocalReactionsActor
12
+ ~fields.ScalarPDEActor
13
+ ~fields.DiffusionActor
14
+ ~fields.ReactionDiffusionActor
15
+ ~fields.CollectionPDEActor
16
+
17
+ .. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
18
+ """
19
+
20
+ from .active_particles import ActiveParticleActor
21
+ from .box import BoxActor
22
+ from .brownian_motion import BrownianMotionActor
23
+ from .coalescence import CoalescenceDropletActor
24
+ from .emitters import EmittersActor
25
+ from .fields import (
26
+ CollectionPDEActor,
27
+ DiffusionActor,
28
+ LocalReactionsActor,
29
+ ReactionDiffusionActor,
30
+ ScalarPDEActor,
31
+ )
@@ -0,0 +1,128 @@
1
+ """
2
+ .. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+
9
+ import numba as nb
10
+ import numpy as np
11
+
12
+ from pde.backends.numba.utils import jit
13
+
14
+ from ... import Parameter
15
+ from ...elements import ArrowsElement
16
+ from ..base import ActorBase, ElementsType
17
+
18
+
19
+ class ActiveParticleActor(ActorBase):
20
+ """Actor moving arrows according to their direction."""
21
+
22
+ parameters_default = [
23
+ Parameter(
24
+ "rotational_diffusion", 0.0, float, "The rotational diffusion strength"
25
+ )
26
+ ]
27
+
28
+ element_classes = (ArrowsElement,)
29
+
30
+ def estimate_dt(self, elements: ElementsType) -> float:
31
+ """Estimate the maximal time step for simulating this actor.
32
+
33
+ Args:
34
+ elements (tuple of :class:`~emulsim.elements.points.ArrowsElement`):
35
+ The element that is affected by the directed motion
36
+
37
+ Returns:
38
+ float: the maximal time step
39
+ """
40
+ return float("inf")
41
+
42
+ def make_evolver_numba( # type: ignore
43
+ self, elements: ElementsType
44
+ ) -> Callable[[tuple[np.ndarray], float, float], None]:
45
+ """Return a function evolve the field state from time `t` to `t + dt`
46
+
47
+ Args:
48
+ elements (tuple of :class:`~emulsim.elements.points.ArrowsElement`):
49
+ The element that is affected by the directed motion
50
+
51
+ Returns:
52
+ callable: A function with signature
53
+ (state_data: :class:`~numpy.ndarray`, t: float, dt: float),
54
+ evolving `state_data`
55
+ """
56
+ dim = int(elements[0].dim) # type: ignore
57
+
58
+ rot_diff = float(self.parameters["rotational_diffusion"])
59
+ if rot_diff > 0 and dim > 2:
60
+ raise NotImplementedError
61
+
62
+ @jit
63
+ def evolver(state_data: tuple[np.ndarray], t: float, dt: float) -> None:
64
+ """Evolve all points explicitly."""
65
+ points = state_data[0]
66
+
67
+ for i in nb.prange(len(state_data[0])):
68
+ # update the position
69
+ for j in range(dim):
70
+ points[i].position[j] += dt * points[i].direction[j]
71
+
72
+ # apply rotational diffusion if requested
73
+ if rot_diff > 0:
74
+ if dim == 1:
75
+ # interpret rot_diff as rate of flipping
76
+ if np.random.rand() < dt * rot_diff:
77
+ points[i].direction[:] *= -1.0
78
+
79
+ elif dim == 2:
80
+ # rotate by angle chosen from normal distribution
81
+ φ = np.random.normal(0, dt * rot_diff)
82
+ cosφ, sinφ = np.cos(φ), np.sin(φ)
83
+ dx, dy = points[i].direction
84
+ points[i].direction[0] = cosφ * dx - sinφ * dy
85
+ points[i].direction[1] = sinφ * dx + cosφ * dy
86
+
87
+ else:
88
+ # higher dimensions are not currently supported
89
+ raise NotImplementedError
90
+
91
+ return evolver # type: ignore
92
+
93
+ def evolve(self, elements: ElementsType, t: float, dt: float) -> None:
94
+ """Evolve the field state from time `t` to `t + dt`
95
+
96
+ Args:
97
+ elements (tuple of :class:`~emulsim.elements.points.ArrowsElement`):
98
+ The element that is affected by the directed motion
99
+ t (float):
100
+ The current time point
101
+ dt (float):
102
+ The time step
103
+ """
104
+ (points,) = elements # extract single element
105
+
106
+ # update the position
107
+ points.positions += dt * points.directions # type: ignore
108
+
109
+ # apply rotational diffusion if requested
110
+ rot_diff = self.parameters["rotational_diffusion"]
111
+ if rot_diff > 0:
112
+ if points.dim == 1:
113
+ # interpret rot_diff as rate of flipping
114
+ flip = np.random.rand(len(points.data)) < dt * rot_diff
115
+ points.directions[flip] *= -1 # type: ignore
116
+
117
+ elif points.dim == 2:
118
+ # rotate by angle chosen from normal distribution
119
+ φ = np.random.normal(0, dt * rot_diff, size=len(points.data))
120
+ rot_mat = np.array([[np.cos(φ), -np.sin(φ)], [np.sin(φ), np.cos(φ)]])
121
+ new_direction = np.einsum("pi,ijp->pj", points.directions, rot_mat) # type: ignore
122
+ points.directions[:] = new_direction # type: ignore
123
+
124
+ else:
125
+ raise NotImplementedError(
126
+ "Rotational diffusion is not implemented for points with "
127
+ f"{points.dim} dimensions."
128
+ )
@@ -0,0 +1,177 @@
1
+ """
2
+ .. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from pde.backends.numba.utils import jit
13
+ from pde.grids.cartesian import CartesianGrid
14
+
15
+ from ... import Parameter
16
+ from ...elements import ArrowsElement, PointsElement, SphericalDropletsElement
17
+ from ..base import ActorBase, ElementsType
18
+
19
+
20
+ class BoxActor(ActorBase):
21
+ """Actor containing particles in a box."""
22
+
23
+ parameters_default = [
24
+ Parameter("bounds", [], np.array, "The bounds of the box"),
25
+ Parameter("periodic", False, np.array, "The bounds of the box"),
26
+ Parameter(
27
+ "point_like",
28
+ True,
29
+ bool,
30
+ "When False, the radius of the object is used in the distance calculation",
31
+ ),
32
+ ]
33
+
34
+ element_classes = ((PointsElement, ArrowsElement, SphericalDropletsElement),)
35
+
36
+ def __init__(self, parameters: dict[str, Any] | None = None):
37
+ """
38
+ Args:
39
+ parameters (dict):
40
+ Parameters affecting the actor. Call
41
+ :meth:`~BoxActor.show_parameters` for details.
42
+ """
43
+ super().__init__(parameters=parameters)
44
+
45
+ # convert periodicity information into useful format
46
+ periodic = self.parameters["periodic"]
47
+ if isinstance(periodic, np.ndarray) and periodic.size == 1:
48
+ periodic = periodic.item()
49
+
50
+ self._grid = CartesianGrid(self.parameters["bounds"], 1, periodic)
51
+
52
+ @classmethod
53
+ def from_grid(cls, grid: CartesianGrid):
54
+ """Create BoxActor from a Cartesian grid.
55
+
56
+ Args:
57
+ grid (:class:`pde.grids.cartesian.CartesianGrid`):
58
+ The Cartesian grid that defines the box
59
+ """
60
+ return cls({"bounds": grid.axes_bounds, "periodic": grid.periodic})
61
+
62
+ def estimate_dt(self, elements: ElementsType) -> float:
63
+ """Estimate the maximal time step for simulating this actor.
64
+
65
+ Args:
66
+ elements (tuple of :class:`~emulsim.elements.points.PointsElement`):
67
+ The element that is affected by the directed motion
68
+
69
+ Returns:
70
+ float: the maximal time step
71
+ """
72
+ return float("inf")
73
+
74
+ def make_evolver_numba( # type: ignore
75
+ self, elements: ElementsType
76
+ ) -> Callable[[tuple[np.ndarray], float, float], None]:
77
+ """Return a function evolve the field state from time `t` to `t + dt`
78
+
79
+ Args:
80
+ elements (tuple of :class:`~emulsim.elements.points.PointsElement`):
81
+ The element that is affected by this actor
82
+
83
+ Returns:
84
+ callable: A function with signature
85
+ (state_data: :class:`~numpy.ndarray`, t: float, dt: float),
86
+ evolving `state_data`
87
+ """
88
+ (points_element,) = elements # extract single element
89
+
90
+ num_points = len(points_element.data)
91
+ num_axes = self._grid.num_axes
92
+ periodic = np.array(self._grid.periodic) # using a tuple led to a numba error
93
+ bounds = np.array(self._grid.axes_bounds)
94
+ midpoint = self._grid.cuboid.centroid
95
+ xmin = bounds[:, 0]
96
+ xmax = bounds[:, 1]
97
+ size = bounds[:, 1] - bounds[:, 0]
98
+
99
+ # figure out which axes need to be considered for flipping direction
100
+ if "direction" in points_element.data.dtype.fields:
101
+ flip_ax: np.ndarray = np.flatnonzero(np.logical_not(self._grid.periodic))
102
+ else:
103
+ flip_ax = np.empty((0,))
104
+ test_for_flipping = flip_ax.size > 0
105
+
106
+ point_like = self.parameters["point_like"]
107
+
108
+ @jit
109
+ def evolver(state_data: tuple[np.ndarray], t: float, dt: float) -> None:
110
+ """Evolve all points explicitly."""
111
+ points = state_data[0] # data of the points
112
+ for i in range(num_points):
113
+ pos = points[i].position
114
+
115
+ if point_like:
116
+ radius = 0
117
+ else:
118
+ radius = points[i].radius
119
+
120
+ # flip direction if out of bound
121
+ if test_for_flipping:
122
+ for ax in flip_ax:
123
+ dist_norm = (pos[ax] - midpoint[ax]) / (size[ax] - 2 * radius)
124
+ if (dist_norm - 0.5) % 2 - 1 < 0:
125
+ points[i].direction[ax] *= -1
126
+ # TODO: this function's performance could be improved by calculating
127
+ # the distance only once
128
+
129
+ # move the points to inside the box
130
+ for ax in range(num_axes):
131
+ if periodic[ax]:
132
+ pos[ax] = (pos[ax] - xmin[ax]) % size[ax] + xmin[ax]
133
+ else:
134
+ dist_left = pos[ax] - (xmax[ax] - radius)
135
+ size_red = size[ax] - 2 * radius
136
+ arg = (dist_left) % (2 * size_red) - size_red
137
+ pos[ax] = xmin[ax] + radius + abs(arg)
138
+
139
+ return evolver # type: ignore
140
+
141
+ def evolve(self, elements: ElementsType, t: float, dt: float) -> None:
142
+ """Evolve the field state from time `t` to `t + dt`
143
+
144
+ Args:
145
+ elements (tuple of :class:`~emulsim.elements.points.PointsElement`):
146
+ The element that is affected by this actor
147
+ t (float):
148
+ The current time point
149
+ dt (float):
150
+ The time step
151
+ """
152
+ if not self.parameters["point_like"]:
153
+ raise NotImplementedError("numpy backend can only deal with point-objects")
154
+
155
+ (points,) = elements # extract single element
156
+
157
+ if "direction" in points.data.dtype.fields:
158
+ # flip direction if out of bound
159
+ midpoint = self._grid.cuboid.centroid
160
+ size = np.array(self._grid.cuboid.size)
161
+ if not self.parameters["point_like"]:
162
+ size = size[:, np.newaxis] - 2 * points.radius[np.newaxis, :] # type: ignore
163
+
164
+ for ax in range(points.dim): # type: ignore
165
+ if self._grid.periodic[ax]:
166
+ continue # do nothing for periodic axes
167
+ dist_norm = (points.positions[..., ax] - midpoint[ax]) / size[ax] # type: ignore
168
+ factor = np.sign((dist_norm - 0.5) % 2 - 1)
169
+ factor[factor == 0] = 1 # don't flip corner cases
170
+ points.directions[..., ax] *= factor # type: ignore
171
+
172
+ # move the points to inside the box
173
+ # TODO: support extended objects, where `point_like` is False
174
+ points.positions[...] = self._grid.normalize_point( # type: ignore
175
+ points.positions, # type: ignore
176
+ reflect=True,
177
+ )
@@ -0,0 +1,135 @@
1
+ """
2
+ .. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+
9
+ import numpy as np
10
+
11
+ from pde.backends.numba.utils import jit
12
+ from pde.tools.expressions import ScalarExpression
13
+
14
+ from ... import Parameter
15
+ from ...elements import ArrowsElement, PointsElement, SphericalDropletsElement
16
+ from ..base import ActorBase, ElementsType
17
+
18
+
19
+ class BrownianMotionActor(ActorBase):
20
+ """Actor moving objects according to Brownian motion."""
21
+
22
+ parameters_default = [
23
+ Parameter(
24
+ "diffusivity",
25
+ "1",
26
+ str,
27
+ "Expression that determines the strength of the Brownian motion of "
28
+ "droplets. The expression may depend on time and potentially on a radius "
29
+ "if this is defined for the element on which the actor acts",
30
+ ),
31
+ ]
32
+
33
+ element_classes = ((ArrowsElement, PointsElement, SphericalDropletsElement),)
34
+
35
+ def estimate_dt(self, elements: ElementsType) -> float:
36
+ """Estimate the maximal time step for simulating this actor.
37
+
38
+ Args:
39
+ elements (tuple of :class:`~emulsim.elements.droplets.SphericalDropletsElement`):
40
+ The element that is affected by the Brownian motion
41
+
42
+ Returns:
43
+ float: the maximal time step
44
+ """
45
+ return float("inf")
46
+
47
+ def _update_cache(self, elements: ElementsType) -> None:
48
+ """Prepare the simulation doing pre-calculations.
49
+
50
+ Args:
51
+ elements (tuple):
52
+ The state of all the droplets and of the field
53
+ """
54
+ fields = elements[0].data.dtype.fields
55
+ if "position" not in fields:
56
+ raise ValueError("Could not find field `positions` in element data")
57
+ self._cache["has_radius"] = "radius" in fields
58
+ if self._cache["has_radius"]:
59
+ self._cache["diffusivity"] = ScalarExpression(
60
+ self.parameters["diffusivity"], [["radius", "R"], ["time", "t"]]
61
+ )
62
+ else:
63
+ self._cache["diffusivity"] = ScalarExpression(
64
+ self.parameters["diffusivity"], [["time", "t"]]
65
+ )
66
+
67
+ def make_evolver_numba( # type: ignore
68
+ self, elements: ElementsType
69
+ ) -> Callable[[tuple[np.ndarray], float, float], None]:
70
+ """Return a function evolve the field state from time `t` to `t + dt`
71
+
72
+ Args:
73
+ elements (tuple of :class:`~emulsim.elements.droplets.SphericalDropletsElement`):
74
+ The field element that is affected by the Brownian motion
75
+
76
+ Returns:
77
+ callable: A function with signature
78
+ (state_data: :class:`~numpy.ndarray`, t: float, dt: float),
79
+ evolving `state_data`
80
+ """
81
+ self._check_cache(elements)
82
+ diffusivity = self._cache["diffusivity"].get_function(backend="numba")
83
+ dim = int(elements[0].dim) # type: ignore
84
+
85
+ if self._cache["has_radius"]:
86
+
87
+ @jit
88
+ def evolver(state_data: tuple[np.ndarray], t: float, dt: float):
89
+ """Evolve all points explicitly."""
90
+ (droplets_data,) = state_data
91
+ for droplet_data in droplets_data:
92
+ if droplet_data.radius > 0:
93
+ scale = np.sqrt(dt * diffusivity(droplet_data.radius, t))
94
+ for i in range(dim):
95
+ droplet_data.position[i] += scale * np.random.randn()
96
+
97
+ else:
98
+
99
+ @jit
100
+ def evolver(state_data: tuple[np.ndarray], t: float, dt: float):
101
+ """Evolve all points explicitly."""
102
+ (droplets_data,) = state_data
103
+ scale = np.sqrt(dt * diffusivity(t))
104
+ for droplet_data in droplets_data:
105
+ for i in range(dim):
106
+ droplet_data.position[i] += scale * np.random.randn()
107
+
108
+ return evolver # type: ignore
109
+
110
+ def evolve(self, elements: ElementsType, t: float, dt: float) -> None:
111
+ """Evolve the state from time `t` to `t + dt`
112
+
113
+ Args:
114
+ elements (tuple of :class:`~emulsim.elements.droplets.SphericalDropletsElement`):
115
+ The element that is affected by the Brownian motion
116
+ t (float):
117
+ The current time point
118
+ dt (float):
119
+ The time step
120
+ """
121
+ self._check_cache(elements)
122
+ (objs,) = elements # extract single element
123
+ diffusivity = self._cache["diffusivity"]
124
+ dim = objs.dim
125
+ if dim is None:
126
+ raise ValueError("`dim` must not be None")
127
+
128
+ if self._cache["has_radius"]:
129
+ for droplet in objs.droplets: # type: ignore
130
+ if droplet.radius > 0:
131
+ scale = np.sqrt(dt * diffusivity(droplet.radius, t))
132
+ droplet.position += scale * np.random.randn(dim)
133
+ else:
134
+ scale = np.sqrt(dt * diffusivity(t))
135
+ objs.positions[...] += scale * np.random.randn(len(objs), objs.dim) # type: ignore