"""Module providing common utilities to deal with phases and their composition.
.. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
"""
from __future__ import annotations
import typing
import numpy as np
from scipy import cluster, spatial
[docs]
class Phases:
"""Contains information about compositions and relative sizes of many phases."""
def __init__(self, volumes: np.ndarray, fractions: np.ndarray):
r"""
Args:
volumes:
1D array with shape :math:`N_\mathrm{P}`, containing the volume
:math:`J_p` of each phase.
fractions:
2D array with shape :math:`N_\mathrm{P} \times N_\mathrm{C}`,
containing the volume fractions of the components in each phase
:math:`\phi_{p,i}`. The first dimension must be the same as
:paramref:`volumes`.
"""
volumes = np.asarray(volumes)
fractions = np.asarray(fractions)
if volumes.ndim != 1:
raise ValueError("volumes must be a 1d array")
if fractions.ndim != 2:
raise ValueError("fractions must be a 2d array")
if volumes.shape[0] != fractions.shape[0]:
raise ValueError(
"volumes and fractions must have consistent first dimension"
)
self.volumes = volumes
self.fractions = fractions
[docs]
def _copy(self, volumes: np.ndarray, fractions: np.ndarray) -> Phases:
"""create copy with changed volume and fraction
This method helps with subclassing methods that should keep other information
intact.
"""
return self.__class__(volumes, fractions)
def __str__(self) -> str:
return f"{self.__class__.__name__}(volumes={self.volumes}, fractions={self.fractions})"
@property
def num_phases(self) -> int:
r"""Number of phases :math:`N_\mathrm{P}`."""
return len(self.volumes)
@property
def num_components(self) -> int:
r"""Number of components :math:`N_\mathrm{C}`."""
return self.fractions.shape[1]
@property
def total_volume(self) -> float:
"""Total volume of entire system"""
return self.volumes.sum()
@property
def mean_fractions(self) -> np.ndarray:
r"""Mean fraction averaged over phases :math:`\bar{\phi}_i`"""
return self.volumes @ self.fractions / self.total_volume
[docs]
def normalize(self) -> Phases:
"""normalize the phases so that their volumes adds to one"""
return self._copy(self.volumes / self.total_volume, self.fractions)
[docs]
def sort(self) -> Phases:
"""Sort the phases according to the index of most concentrated components.
Returns:
: The sorted phases.
"""
enrich_indexes = np.argsort(self.fractions)
sorting_index = np.lexsort(np.transpose(enrich_indexes))
return self._copy(self.volumes[sorting_index], self.fractions[sorting_index])
[docs]
def get_clusters(self, dist: float = 1e-2) -> Phases:
r"""Find clusters of compositions.
Find unique phases from compartments by clustering. The returning results are
sorted according to the index of most concentrated components.
Args:
dist (float):
Cut-off distance for cluster analysis.
Returns:
: The clustered and sorted phases.
"""
if self.num_phases < 2:
return self # nothing to do here
# calculate distances between compositions
dists = spatial.distance.pdist(self.fractions)
# obtain hierarchy structure
links = cluster.hierarchy.linkage(dists, method="centroid")
# flatten the hierarchy by clustering
clusters = cluster.hierarchy.fcluster(links, dist, criterion="distance")
# collect data for each cluster
cluster_fractions = []
cluster_volumes = []
for n in np.unique(clusters):
current_fractions = self.fractions[clusters == n, :]
current_volumes = self.volumes[clusters == n]
mean_fractions = np.average(
current_fractions, weights=current_volumes, axis=0
)
cluster_fractions.append(mean_fractions)
cluster_volumes.append(current_volumes.sum())
return self._copy(cluster_volumes, cluster_fractions)
[docs]
class PhasesResult(Phases):
"""Contains compositions and relative sizes of many phases along with extra information."""
def __init__(
self, volumes: np.ndarray, fractions: np.ndarray, *, info: dict | None = None
):
r"""
Args:
volumes:
1D array with shape :math:`N_\mathrm{P}`, containing the volume
:math:`J_p` of each phase.
fractions:
2D array with shape :math:`N_\mathrm{P} \times N_\mathrm{C}`,
containing the volume fractions of the components in each phase
:math:`\phi_{p,i}`. The first dimension must be the same as
:paramref:`volumes`.
info:
Additional information about how the phases were obtained.
"""
super().__init__(volumes, fractions)
self._info = {} if info is None else info
[docs]
@classmethod
def from_phases(cls, phases: Phases, *, info: dict | None = None) -> PhasesResult:
r"""create phase result from :class:`Phases`
Args:
phases:
The :class:`Phases` containing volumes and fractions.
info:
Additional information about how the phases were obtained
"""
return cls(phases.volumes, phases.fractions, info=info)
@property
def info(self) -> dict:
r"""Information for the current collection of phases."""
return self._info
[docs]
def _copy(self, volumes: np.ndarray, fractions: np.ndarray) -> PhasesResult:
"""create copy with changed volume and fraction
This method helps with subclassing methods that should keep other information
intact.
"""
return self.__class__(volumes, fractions, info=self.info.copy())