Source code for abtem.inelastic.plasmons

import itertools
from collections import defaultdict
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, List, Tuple, Union

import dask
import dask.array as da
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes

from abtem.core.axes import (
    AxisMetadata,
    OrdinalAxis,
    _iterate_axes_type,
)
from abtem.core.backend import get_array_module
from abtem.core.chunks import chunk_ranges, validate_chunks
from abtem.core.utils import itemset
from abtem.transform import ArrayObjectTransform

if TYPE_CHECKING:
    from abtem.potentials import BasePotential
    from abtem.waves import Waves


nth = {1: "First", 2: "Second", 3: "Third", 4: "Fourth"}
ntuples = {
    0: "Zero loss",
    1: "Single plasmon",
    2: "Double plasmon",
    3: "Triple plasmon",
    4: "Quadruple plasmon",
    5: "Quintuple plasmon",
    6: "Sextuple plasmon",
    7: "Septuple plasmon",
    8: "Octuple plasmon",
    9: "Nonuble plasmon",
}


[docs] def draw_scattering_depths( num_depths: int, num_samples: int, mean_free_path: float, max_depth: float, max_batch: int = 10_000, max_attempts: int = 50_000_000, rng=None, ) -> Tuple[Tuple]: if rng is None: rng = np.random.default_rng() if num_depths == 0: return ((),) * num_samples # noqa max_num_batches = max_attempts // max_batch depths = np.zeros((num_samples, num_depths)) k = 0 for i in range(max_num_batches): new_depths = np.cumsum( -mean_free_path * np.log(rng.random((max_batch, num_depths + 1))), axis=-1 ) new_depths = new_depths[ (new_depths[:, -1] > max_depth) * (new_depths[:, -2] < max_depth) ] new_k = min(num_samples, k + len(new_depths)) depths[k:new_k] = new_depths[: new_k - k, :num_depths] k = new_k if k == num_samples: break if k != num_samples: raise ValueError( f"requested scattering events did not occur in {max_attempts} attempts" ) return tuple(tuple(d) for d in depths)
[docs] def draw_radial_scattering_angle( critical_angle: float, characteristic_angle: float, num_samples, num_depths, rng=None, ) -> Tuple[Tuple[float]]: if rng is None: rng = np.random.default_rng() radial_scattering_angles = [] for _ in range(num_samples): radial_scattering_angles.append( tuple( np.sqrt( characteristic_angle**2 * ( (critical_angle**2 + characteristic_angle**2) / characteristic_angle**2 ) ** rng.random() - characteristic_angle**2 ) for _ in range(num_depths) ) ) return tuple(radial_scattering_angles)
[docs] def draw_azimuthal_angle(num_samples, num_depths, rng=None) -> Tuple[float]: if rng is None: rng = np.random.default_rng() azimuthal_angles = [] for _ in range(num_samples): azimuthal_angles.append( tuple(2 * np.pi * rng.random() for _ in range(num_depths)) ) return tuple(azimuthal_angles)
[docs] def excitations_weights(n: int, thickness: float, mean_free_path: float) -> float: return ( 1 / np.math.factorial(n) * (thickness / mean_free_path) ** n * np.exp(-thickness / mean_free_path) )
[docs] @dataclass(eq=False, repr=False, unsafe_hash=True) class PlasmonAxis(OrdinalAxis): units: str = "" label: str = "Plasmons excitations" _ensemble_mean: bool = False @property def excitations(self): return tuple(value[3] for value in self.values) @property def azimuthal_angles(self): return tuple(value[2] for value in self.values) @property def radial_angles(self): return tuple(value[1] for value in self.values) @property def depths(self): return tuple(value[0] for value in self.values) @property def tilt(self): tilt = () for radial_angles, azimuthal_angles, excitations in zip( self.radial_angles, self.azimuthal_angles, self.excitations ): radial_angle = sum(radial_angles[:excitations]) azimuthal_angle = sum(radial_angles[:excitations]) tilt += ( ( radial_angle * np.cos(azimuthal_angle), radial_angle * np.sin(azimuthal_angle), ), ) return tilt
[docs] def update(self, depth): values = () for excitation_depths, value in zip(self.depths, self.values): for i, excitation_depth in enumerate(excitation_depths): if excitation_depth > depth: break else: i = len(excitation_depths) values += (value[:-1] + (i,),) self.values = values
def _update_plasmon_axes(waves, depth): for axis in _iterate_axes_type(waves, PlasmonAxis): axis.update(depth)
[docs] def reduce_plasmon_axes(measurement): plasmon_axes = [ (i, axes_metadata) for i, axes_metadata in enumerate(measurement.axes_metadata) if isinstance(axes_metadata, PlasmonAxis) ] if len(plasmon_axes) == 0: return measurement plasmon_axis_index, plasmon_axis = plasmon_axes[0] num_excitations = [len(value[0]) for value in plasmon_axis.values] uniques, inverse = np.unique(num_excitations, return_inverse=True) axis_values = [] new_array = [] for i, unique in enumerate(uniques): axis_values.append(f"{ntuples[unique]}") indices = np.where(i == inverse)[0] new_array.append(measurement.array[indices].mean(0, keepdims=True)) array = da.concatenate(new_array, axis=plasmon_axis_index) kwargs = measurement._copy_kwargs(exclude=("array",)) kwargs["ensemble_axes_metadata"][plasmon_axis_index] = OrdinalAxis( label="", values=axis_values ) return measurement.__class__(array, **kwargs)
[docs] class PlasmonScatteringEvents(ArrayObjectTransform): def __init__( self, depths: Tuple[Tuple[float, ...]], radial_angles: Tuple[Tuple[float, ...]], azimuthal_angles: Tuple[Tuple[float, ...]], weights: Tuple[float], ensemble_mean: bool, ): if not ( len(depths) == len(radial_angles) == len(azimuthal_angles) == len(weights) ): raise ValueError() if not all( len(d) == len(r) == len(a) for d, r, a in zip(depths, radial_angles, azimuthal_angles) ): raise ValueError() self._depths = depths self._radial_angles = radial_angles self._azimuthal_angles = azimuthal_angles self._weights = weights self._ensemble_mean = ensemble_mean @property def ensemble_shape(self): return (len(self.depths),) @property def _default_ensemble_chunks(self): return ("auto",) @property def ensemble_mean(self): return self._ensemble_mean @property def depths(self) -> Tuple[Tuple[float, ...]]: return self._depths @property def radial_angles(self) -> Tuple[Tuple[float, ...]]: return self._radial_angles @property def azimuthal_angles(self) -> Tuple[Tuple[float, ...]]: return self._azimuthal_angles @property def weights(self) -> Tuple[float]: return self._weights @property def num_events(self): return len(self._depths) @property def num_excitations(self): return tuple(len(depths_element) for depths_element in self.depths) @property def max_excitations(self): return max(self.num_excitations)
[docs] def show_excitations_histogram(self, ax: Axes = None): bins = range(0, self.max_excitations + 2) if ax is None: ax = plt.subplot() ax.hist(self.num_excitations, bins=bins) ax.set_xticks(np.array(bins) + 0.5) ax.set_xticklabels(bins) ax.set_xlabel("Number of excitations") ax.set_ylabel("Number of events")
[docs] def get_scattering_event_depths(self, num_excitations: int = 1): event_depths = defaultdict(list) for depths in self.depths: n = len(depths) if n >= num_excitations: event_depths[ntuples[n]].append(depths[num_excitations - 1]) return event_depths
[docs] def show_cumulative_scattering_events( self, ax=None, num_excitations: Union[int, List[int]] = 1, **kwargs ): if isinstance(num_excitations, int): num_excitations = [1] if ax is None: fig, axes = plt.subplots(1, len(num_excitations), sharey=True) else: axes = [ax] print(axes) if isinstance(axes, Axes): axes = [axes] if "bins" not in kwargs: kwargs["bins"] = 20 for i, (n, ax) in enumerate(zip(num_excitations, axes)): scattering_depths = self.get_scattering_event_depths(n) ax.hist( scattering_depths.values(), cumulative=True, density=True, histtype="step", label=list(scattering_depths.keys()), **kwargs, ) ax.set_xlabel("Depth [Å]") if i == 0: ax.set_ylabel("Cumulative distribution") ax.set_title(f"{nth[n]} scattering event") ax.legend(loc=2) return ax
[docs] def show_scattering_angle_distribution(self, ax=None, **kwargs): scattering_angles = list(itertools.chain(*self.radial_angles)) if ax is None: fig, ax = plt.subplots(1, 1) ax.hist(scattering_angles, **kwargs) ax.set_ylabel("Frequency") ax.set_xlabel("Scattering angle [mrad]")
[docs] def show_weights(self): uniques, indices = np.unique( [len(depths) for depths in self.depths], return_index=True ) weights = [self.weights[index] for index in indices] x = [ntuples[unique] for unique in uniques] fig, ax = plt.subplots(figsize=(7, 5)) ax.bar(x, weights) ax.set_ylabel("Weight")
@property def ensemble_axes_metadata(self) -> List[AxisMetadata]: return [ PlasmonAxis( values=tuple( (depths, radial_angles, azimuthal_angles, 0) for depths, radial_angles, azimuthal_angles in zip( self.depths, self.radial_angles, self.azimuthal_angles ) ), _ensemble_mean=self.ensemble_mean, ) ] @classmethod def _from_partitioned_args_func(cls, *args, **kwargs): args = args[0] if hasattr(args, "item"): args = args.item() kwargs["depths"] = args["depths"] kwargs["radial_angles"] = args["radial_angles"] kwargs["azimuthal_angles"] = args["azimuthal_angles"] kwargs["weights"] = args["weights"] return cls(**kwargs) def _from_partitioned_args(self): kwargs = self._copy_kwargs( exclude=("depths", "radial_angles", "azimuthal_angles") ) return partial(self._from_partitioned_args_func, **kwargs) @staticmethod def _plasmon_scattering_events(depths, radial_angles, azimuthal_angles, weights): arr = np.zeros((1,), dtype=object) itemset( arr, 0, { "depths": depths, "radial_angles": radial_angles, "azimuthal_angles": azimuthal_angles, "weights": weights, }, ) return arr def _partition_args(self, chunks: int = 1, lazy: bool = True): chunks = validate_chunks(self.ensemble_shape, chunks) array = np.zeros((len(chunks[0]),), dtype=object) for i, (start, stop) in enumerate(chunk_ranges(chunks)[0]): depths = self.depths[start:stop] radial_angles = self.radial_angles[start:stop] azimuthal_angles = self.azimuthal_angles[start:stop] weights = self.weights[start:stop] if lazy: lazy_frozen_phonon = dask.delayed(self._plasmon_scattering_events)( depths=depths, radial_angles=radial_angles, azimuthal_angles=azimuthal_angles, weights=weights, ) itemset( array, i, da.from_delayed(lazy_frozen_phonon, shape=(1,), dtype=object), ) else: itemset( array, i, self._plasmon_scattering_events( depths=depths, radial_angles=radial_angles, azimuthal_angles=azimuthal_angles, weights=weights, ), ) if lazy: array = da.concatenate(list(array)) return (array,)
[docs] def apply(self, waves: "Waves", in_place: bool = False) -> "Waves": xp = get_array_module(waves.device) array = waves.array[(None,) * len(self.ensemble_shape)] if waves.is_lazy: array = da.tile(array, self.ensemble_shape + (1,) * len(waves.shape)) else: array = xp.tile(array, self.ensemble_shape + (1,) * len(waves.shape)) kwargs = waves._copy_kwargs(exclude=("array",)) kwargs["array"] = array kwargs["ensemble_axes_metadata"] = ( self.ensemble_axes_metadata + kwargs["ensemble_axes_metadata"] ) return waves.__class__(**kwargs)
[docs] class MonteCarloPlasmons: def __init__( self, mean_free_path: float, excitation_energy: float, critical_angle: float, num_excitations: Union[int, Tuple[int, ...]] = None, num_samples: int = None, weights: Union[bool] = True, ensemble_mean: bool = False, seed: Union[int, Tuple[int, ...]] = None, ): self._mean_free_path = mean_free_path self._excitation_energy = excitation_energy self._critical_angle = critical_angle self._ensemble_mean = ensemble_mean self._num_samples = num_samples self._seed = seed if isinstance(num_excitations, int): num_excitations = tuple(range(num_excitations + 1)) self._num_excitations = num_excitations @property def ensemble_mean(self) -> bool: return self._ensemble_mean @property def num_samples(self) -> int: return self._num_samples @property def mean_free_path(self) -> float: return self._mean_free_path @property def seed(self) -> int: return self._seed def __len__(self) -> int: return self.num_samples
[docs] def characteristic_angle(self, energy: float) -> float: return self._excitation_energy / (2 * energy) * 1e3
[docs] def draw_events( self, waves: "Waves", potential: "BasePotential" ) -> PlasmonScatteringEvents: depth = potential.thickness energy = waves.energy rng = np.random.default_rng(self.seed) depths = [] radial_angles = [] azimuthal_angles = [] weights = [] for n in self._num_excitations: if n == 0: num_samples = 1 else: num_samples = self.num_samples depths.append( draw_scattering_depths( mean_free_path=self._mean_free_path, num_depths=n, max_depth=depth, num_samples=num_samples, rng=rng, ) ) radial_angles.append( draw_radial_scattering_angle( self._critical_angle, self.characteristic_angle(energy), rng=rng, num_samples=num_samples, num_depths=n, ) ) azimuthal_angles.append( draw_azimuthal_angle(num_samples=num_samples, num_depths=n, rng=rng) ) weights.append( (excitations_weights(n, depth, self._mean_free_path),) * num_samples ) depths = list(itertools.chain(*depths)) radial_angles = list(itertools.chain(*radial_angles)) azimuthal_angles = list(itertools.chain(*azimuthal_angles)) weights = list(itertools.chain(*weights)) return PlasmonScatteringEvents( depths, radial_angles, azimuthal_angles, weights, ensemble_mean=self.ensemble_mean, )