Source code for emulsim.elements.multicomponent_droplets

"""Provides a simulation element representing multicomponent droplets.

.. autosummary::
   :nosignatures:

   ~MulticomponentDroplet
   ~MulticomponentDropletsElement

.. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
"""

from __future__ import annotations

from collections.abc import Callable

import numpy as np
from numba.extending import register_jitable

from droplets import SphericalDroplet
from droplets.tools.spherical import volume_from_radius
from pde.grids.base import GridBase

from .spherical_droplets import SphericalDropletsElement


[docs] class MulticomponentDroplet(SphericalDroplet): """Represents a single droplet comprised of many species.""" __slots__ = ["data"] def __init__( self, position: np.ndarray, radius: float, amounts: np.ndarray | None = None, ): """ Args: position (:class:`~numpy.ndarray`): Position of the droplet center radius (float): Radius of the droplet amounts (:class:`~numpy.ndarray`): The amounts of material of each component. If omitted, a single component with vanishing amounts is assumed. """ self._init_data(position=position, amounts=amounts) if amounts is None: self.data["amounts"] = np.zeros(1) else: self.data["amounts"] = amounts super().__init__(position=position, radius=radius)
[docs] @classmethod def from_composition( cls, position: np.ndarray, radius: float, phis: np.ndarray, ) -> MulticomponentDroplet: """ Args: position (:class:`~numpy.ndarray`): Position of the droplet center radius (float): Radius of the droplet phis (:class:`~numpy.ndarray`): The composition of the droplet """ # TODO: Add optional field that is used to subtract background dim = len(position) amounts = np.asarray(phis) * volume_from_radius(radius, dim) return cls(position, radius, amounts)
[docs] @classmethod def get_dtype(cls, **kwargs): """Determine the dtype representing this droplet class. Args: position (:class:`~numpy.ndarray`): The position vector of the droplet. This is used to determine the space dimension. amounts (:class:`~numpy.ndarray`): The amounts of material of each component Returns: :class:`numpy.dtype`: the (structured) dtype associated with this class """ # extract data amounts = kwargs.pop("amounts") if amounts is None: num_comps = 1 else: num_comps = len(amounts) if num_comps < 1: raise ValueError(f"Need at least one component, got {num_comps}") # create dtype dtype = super().get_dtype(**kwargs) return dtype + [("amounts", float, (num_comps,))]
@property def num_comps(self) -> int: """int: number of components inside the droplet""" shape = self.data.dtype.fields["amounts"][0].shape # type: ignore return int(shape[0]) if shape else 1 @property def data_bounds(self) -> tuple[np.ndarray, np.ndarray]: """tuple: lower and upper bounds on the parameters""" l, h = super().data_bounds n = self.dim + 2 l[n : n + self.num_comps] = 0 return l, h
[docs] def check_data(self): """Method that checks the validity and consistency of self.data.""" super().check_data() if np.any(self.amounts < 0): raise ValueError(f"Amounts must be positive ({self.amounts})")
@property def amounts(self) -> np.ndarray: """:class:`~numpy.ndarray`: the composition.""" return self.data["amounts"] @amounts.setter def amounts(self, value: np.ndarray): self.data["amounts"] = np.broadcast_to(value, (self.num_comps,)) self.check_data() @property def phis(self) -> np.ndarray: """:class:`~numpy.ndarray`: total amounts in the droplet.""" return self.amounts / self.volume @property def phi_solvent(self) -> float: """float: solvent fraction""" return float(1 - self.phis.sum()) @classmethod def _make_merge_data(cls) -> Callable[[np.ndarray, np.ndarray, np.ndarray], None]: """Factory for a function that merges the data of two droplets.""" parent_merge = super()._make_merge_data() @register_jitable def merge_data(drop1: np.ndarray, drop2: np.ndarray, out: np.ndarray) -> None: """Merge the data of two droplets.""" parent_merge(drop1, drop2, out) out.amounts[...] = drop1.amounts + drop2.amounts # type: ignore return merge_data # type: ignore def _get_phase_field(self, grid: GridBase, dtype=np.double) -> np.ndarray: """Creates a normalized image of the droplet on the `grid` Args: grid (:class:`~pde.grids.base.GridBase`): The grid used for discretizing the droplet phase field Returns: :class:`~numpy.ndarray`: An array with data values representing the droplet phase fields at support points of the `grid`. """ phase_field = super()._get_phase_field(grid, dtype=float) return np.outer(self.phis, phase_field).astype(dtype) def _get_mpl_patch(self, dim=None, *, brightness=0.5, **kwargs): """Return the patch representing the droplet for plotting. The color of the droplets is determined automatically using at most the first three concentrations inside the droplet. Args: dim (int, optional): The dimension in which the data is plotted. If omitted, the actual physical dimension is assumed. brightness (float): Factor that determines how bright the colors of the droplets are **kwargs: Additional keyword arguments are passed to :class:`matplotlib.patches.Circle`, which creates the patch that represents the droplet. For instance, to only draw the outlines of the droplets, you may need to supply `fill=False`. Returns: :class:`~matplotlib.patches.Circle`: The patch representing the droplet """ if kwargs.get("color") is None: color = np.ones(3) num_channels = min(3, self.num_comps) rgb = self.phis[:num_channels] * brightness * self.num_comps color[:num_channels] = np.clip(rgb, 0, 1) kwargs["color"] = color return super()._get_mpl_patch(dim=dim, **kwargs)
[docs] class MulticomponentDropletsElement(SphericalDropletsElement): """Element representing many multicomponent droplets.""" droplet_class = MulticomponentDroplet @property def num_comps(self) -> int: """int: the number of components inside each droplet""" shape = self.data.dtype.fields["amounts"][0].shape # type: ignore return int(shape[0]) if shape else 1 @property def phis(self) -> np.ndarray: """:class:`~numpy.ndarray`: fractions of all components in all droplets.""" return np.array([d.phis for d in self.droplets if d.radius > 0]) @property def amounts(self) -> np.ndarray: """:class:`~numpy.ndarray`: the amounts in all droplets.""" return sum(droplet.amounts for droplet in self.droplets if droplet.radius > 0) # type: ignore @property def total_amount(self) -> float: """float: total amount of all fields in all droplets""" return sum(self.amounts) # type: ignore
[docs] def plot(self, ax=None, *args, **kwargs): """Plot all droplets of this element. Args: {PLOT_ARGS} **kwargs: All additional arguments are forwarded to :meth:`droplets.emulsions.Emulsion.plot`. """ plot_args = self.parameters["plot_args"].copy() plot_args.update(kwargs) emulsion = self.droplets if "grid" in kwargs: emulsion = emulsion.copy() emulsion.grid = kwargs.pop("grid") emulsion.plot(*args, ax=ax, **plot_args)