from __future__ import annotations
import contextlib
import itertools
import os
from abc import ABCMeta, abstractmethod
from bisect import bisect_left
from typing import TYPE_CHECKING
import numpy as np
from ase import Atom, Atoms, units
from ase.data import chemical_symbols
from numba import jit
from scipy.interpolate import interp1d
from scipy.special import spherical_jn
try:
# sph_harm_y is the non-deprecated replacement for sph_harm, available
# since scipy 1.15; sph_harm itself is removed in scipy 1.17.
from scipy.special import sph_harm_y
except ImportError:
from scipy.special import sph_harm
def sph_harm_y(n, m, theta, phi):
return sph_harm(m, n, phi, theta)
from abtem.array import ArrayObject
from abtem.core.axes import AxisMetadata, OrdinalAxis
from abtem.core.backend import copy_to_device, get_array_module
from abtem.core.chunks import validate_chunks
from abtem.core.complex import abs2, complex_exponential
from abtem.core.electron_configurations import electron_configurations
from abtem.core.energy import (
Accelerator,
HasAcceleratorMixin,
energy2sigma,
energy2wavelength,
relativistic_mass_correction,
)
from abtem.core.fft import fft2, fft2_convolve, fft_shift_kernel, ifft2
from abtem.core.grid import Grid, HasGrid2DMixin, polar_spatial_frequencies
from abtem.core.utils import CopyMixin
from abtem.measurements import Images, RealSpaceLineProfiles, _polar_detector_bins
if TYPE_CHECKING:
from abtem.prism.s_matrix import SMatrix
from abtem.waves import Waves
azimuthal_number = {"s": 0, "p": 1, "d": 2, "f": 3, "g": 4, "h": 5, "i": 6}
azimuthal_letter = {value: key for key, value in azimuthal_number.items()}
[docs]
def config_str_to_config_tuples(config_str):
"""Parse an electron configuration string (e.g. "1s2 2s2 2p6") into a list of
(n, l, occupancy) tuples."""
config_tuples = []
for subshell_string in config_str.split(" "):
config_tuples.append(
(
int(subshell_string[0]),
azimuthal_number[subshell_string[1]],
int(subshell_string[2]),
)
)
return config_tuples
[docs]
def config_tuples_to_config_str(config_tuples):
"""Convert a list of (n, l, occupancy) tuples back to an electron configuration
string (e.g. "1s2 2s2 2p6")."""
config_str = []
for n, ell, occ in config_tuples:
config_str.append(str(n) + azimuthal_letter[ell] + str(occ))
return " ".join(config_str)
[docs]
def remove_electron_from_config_str(config_str, n, ell):
"""Remove one electron from the (n, l) subshell in the given configuration string
and return the updated configuration string."""
config_tuples = []
for shell in config_str_to_config_tuples(config_str):
if shell[:2] == (n, ell):
config_tuples.append(shell[:2] + (shell[2] - 1,))
else:
config_tuples.append(shell)
return config_tuples_to_config_str(config_tuples)
[docs]
def check_valid_quantum_number(Z, n, ell):
"""Validate that the quantum numbers (n, l) correspond to an occupied subshell
for element with atomic number Z. Raises RuntimeError if invalid."""
symbol = chemical_symbols[Z]
config_tuple = config_str_to_config_tuples(electron_configurations[symbol])
if not any([shell[:2] == (n, ell) for shell in config_tuple]):
raise RuntimeError(
f"Quantum numbers (n, ell) = ({n}, {ell}) not valid for element {symbol}"
)
def _validate_transition_potentials(transition_potentials):
if hasattr(transition_potentials, "scatter"):
transition_potentials = [transition_potentials]
return transition_potentials
[docs]
class RadialWavefunction:
def __init__(
self,
n: int | None,
l: int,
energy: float,
radial_grid: np.ndarray,
radial_values: np.ndarray,
):
self._n = n
self._l = l
self._energy = energy
if energy >= 0.0:
if n is not None:
raise ValueError()
else:
if n is None:
raise ValueError()
self._radial_grid = radial_grid
self._radial_values = radial_values
def __call__(self, r):
f = interp1d(
self._radial_grid,
self._radial_values,
kind=2,
fill_value="extrapolate",
)
return f(r)
@property
def bound(self):
return self.n > 0
@property
def energy(self):
return self._energy
@property
def radial_grid(self):
return self._radial_grid
@property
def n(self):
return self._n
@property
def l(self):
return self._l
[docs]
def to_lineprofiles(self, sampling=0.01):
r = np.arange(0, self._radial_grid[-1], sampling)
return RealSpaceLineProfiles(self(r), sampling=sampling)
[docs]
def show(self, **kwargs):
return self.to_lineprofiles().show(**kwargs)
[docs]
class AtomicWaveFunction:
def __init__(self, radial_wavefunction, ml):
self._radial_wavefunction = radial_wavefunction
self._ml = ml
def __call__(self, r):
return self._radial_wavefunction(r)
@property
def bound(self):
return self._radial_wavefunction.bound
@property
def energy(self):
return self._radial_wavefunction.energy
@property
def radial_grid(self):
return self._radial_wavefunction.radial_grid
@property
def n(self):
return self._radial_wavefunction.n
@property
def l(self):
return self._radial_wavefunction.l
@property
def ml(self):
return self._ml
@property
def quantum_numbers(self):
return self.n, self.l, self.ml
[docs]
@jit(nopython=True)
def numerov(f, x0, dx, dh):
"""Given precomputed function f(x), solves for x(t), which satisfies:
x''(t) = f(t) x(t)
"""
# f.copy() rather than np.zeros(len(f)): some numba/numpy pairings
# (observed: numba 0.64.0 + numpy 2.4.3) fail to type numba's internal
# np.zeros -> np.empty lowering inside @njit, while ndarray.copy() is
# unaffected. Every element of x is overwritten below before being
# read, so the borrowed initial values from f are never used.
x = f.copy()
x[0] = x0
x[1] = x0 + dh * dx
h2 = dh**2
h12 = h2 / 12.0
w0 = x0 * (1 - h12 * f[0])
w1 = x[1] * (1 - h12 * f[1])
xi = x[1]
fi = f[1]
for i in range(2, f.size):
w2 = 2 * w1 - w0 + h2 * fi * xi # here fi=f1
fi = f[i] # fi=f2
xi = w2 / (1 - h12 * fi)
x[i] = xi
w0 = w1
w1 = w2
return x
[docs]
def calculate_bound_radial_wavefunction(Z, n, l, xc="PBE"):
from gpaw.atom.all_electron import AllElectron
check_valid_quantum_number(Z, n, l)
config_tuples = config_str_to_config_tuples(
electron_configurations[chemical_symbols[Z]]
)
subshell_index = [shell[:2] for shell in config_tuples].index((n, l))
with open(os.devnull, "w") as f, contextlib.redirect_stdout(f):
ae = AllElectron(chemical_symbols[Z], xcname=xc)
ae.run()
energy = ae.e_j[subshell_index] * units.Hartree
return RadialWavefunction(
n=n,
l=l,
energy=energy,
radial_grid=ae.r,
radial_values=ae.u_j[subshell_index],
)
[docs]
def radial_schroedinger_equation(ef, l, r, vr):
return (l * (l + 1) / r**2 - vr(r) / r) * 1.02 - ef
[docs]
def calculate_continuum_radial_wavefunction(Z, n, l, lprime, epsilon, xc="PBE"):
# from gpaw.atom.all_electron import AllElectron
from gpaw.atom.aeatom import AllElectronAtom
def f(self, *args, **kwargs):
pass
AllElectronAtom.log = f
check_valid_quantum_number(Z, n, l)
# config_tuples = config_str_to_config_tuples(
# electron_configurations[chemical_symbols[Z]]
# )
# subshell_index = [shell[:2] for shell in config_tuples].index((n, l))
ae = AllElectronAtom(chemical_symbols[Z], xc=xc)
# ae.f_j[subshell_index] -= 0.0
ae.run()
ae.scalar_relativistic = True
ae.refine()
vr = interp1d(
ae.rgd.r_g, -2 * ae.vr_sg[0], fill_value="extrapolate", bounds_error=False
)
ef = epsilon / units.Rydberg
r = np.linspace(1e-12, 20, 1000000)
f = radial_schroedinger_equation(ef, lprime, r, vr)
ur = numerov(f, 0.0, 1e-12, r[1] - r[0])
ur = ur / ur.max() / (np.sqrt(np.pi) * ef ** (1 / 4))
return RadialWavefunction(
n=None,
l=lprime,
energy=epsilon,
radial_grid=r,
radial_values=ur,
)
[docs]
class BaseTransitionCollection:
def __init__(self, Z):
self._Z = Z
@property
def Z(self):
return self._Z
[docs]
@abstractmethod
def get_transition_potential(self):
pass
[docs]
class SubshellTransitions(BaseTransitionCollection):
def __init__(
self,
Z: int,
n: int,
l: int,
order: int = 1,
min_contrast: float = 1.0,
epsilon: float = 1.0,
xc: str = "PBE",
):
check_valid_quantum_number(Z, n, l)
self._n = n
self._l = l
self._order = order
self._min_contrast = min_contrast
self._epsilon = epsilon
self._xc = xc
super().__init__(Z)
def __len__(self):
return len(self.get_transition_quantum_numbers())
@property
def bound_configuration(self):
return electron_configurations[chemical_symbols[self.Z]]
@property
def excited_configuration(self):
return remove_electron_from_config_str(
electron_configurations[chemical_symbols[self.Z]], self.n, self.l
)
@property
def order(self):
return self._order
@property
def min_contrast(self):
return self._min_contrast
@property
def epsilon(self):
return self._epsilon
@property
def xc(self):
return self._xc
@property
def n(self):
return self._n
@property
def l(self):
return self._l
@property
def lprimes(self):
min_new_l = max(self.l - self.order, 0)
return range(min_new_l, self.l + self.order + 1)
[docs]
def get_bound_wave_function(self):
wave_functions = calculate_bound_radial_wavefunction(
Z=self.Z, n=self.n, l=self.l, xc=self.xc
)
return wave_functions
[docs]
def get_excited_wave_functions(self):
wave_functions = [
calculate_continuum_radial_wavefunction(
Z=self.Z, n=self.n, l=self.l, lprime=lprime, epsilon=self.epsilon
)
for lprime in self.lprimes
]
return wave_functions
[docs]
def get_transition_quantum_numbers(self):
bound_states = [(self.n, self.l, ml) for ml in range(-self.l, self.l + 1)]
excited_states = []
for lprime in self.lprimes:
for mlprime in range(-lprime, lprime + 1):
excited_states.append((None, lprime, mlprime))
transitions = []
for bound_state, excited_state in itertools.product(
bound_states, excited_states
):
transitions.append((bound_state, excited_state))
return transitions
[docs]
def get_transitions(self):
bound_state = self.get_bound_wave_function()
bound_states = [
AtomicWaveFunction(bound_state, ml)
for ml in range(-bound_state.l, bound_state.l + 1)
]
excited_states = self.get_excited_wave_functions()
excited_states = [
AtomicWaveFunction(radial, ml)
for radial in excited_states
for ml in range(-radial.l, radial.l + 1)
]
transitions = []
for bound_state, excited_state in itertools.product(
bound_states, excited_states
):
transitions.append((bound_state, excited_state))
return transitions
[docs]
def get_transition_potentials(
self,
extent: float | tuple[float, float] = None,
gpts: float | tuple[int, int] = None,
sampling: float | tuple[float, float] = None,
energy: float = None,
double_channel: bool = True,
):
transitions = self.get_transitions()
return TransitionPotential(
self.Z,
transitions,
extent=extent,
gpts=gpts,
sampling=sampling,
energy=energy,
double_channel=double_channel,
)
[docs]
class BaseTransitionPotential(
HasAcceleratorMixin, HasGrid2DMixin, CopyMixin, metaclass=ABCMeta
):
def __init__(
self,
Z: int,
extent: float | tuple[float, float],
gpts: int | tuple[int, int],
sampling: float | tuple[float, float],
energy: float,
double_channel: bool = True,
**kwargs,
):
self._Z = Z
self._grid = Grid(extent=extent, gpts=gpts, sampling=sampling)
self._accelerator = Accelerator(energy=energy)
self._double_channel = double_channel
super().__init__(**kwargs)
@property
def double_channel(self) -> bool:
return self._double_channel
@property
def Z(self) -> int:
return self._Z
@property
@abstractmethod
def metadata(self) -> dict:
pass
[docs]
class TransitionPotential(BaseTransitionPotential):
def __init__(
self,
Z: int,
transitions,
orbital_filling_factor: bool = True,
extent: float | tuple[float, float] = None,
gpts: int | tuple[int, int] = None,
sampling: float | tuple[float, float] = None,
energy: float = None,
double_channel: bool = True,
):
self._Z = Z
self._orbital_filling_factor = orbital_filling_factor
self._transitions = transitions
super().__init__(Z, extent, gpts, sampling, energy, double_channel)
def __len__(self) -> int:
return len(self._transitions)
@property
def orbital_filling_factor(self) -> bool:
return self._orbital_filling_factor
@property
def double_channel(self) -> bool:
return self._double_channel
@property
def Z(self) -> int:
return self._Z
@property
def ensemble_shape(self) -> tuple[int]:
return (len(self._transitions),)
@property
def ensemble_axes_metadata(self) -> list[AxisMetadata]:
values = [
f"{bound[1:]} → {excited[1:]}"
for (bound, excited) in self.transition_quantum_numbers
]
return [
OrdinalAxis(
values=values,
label="(l,ml)→(l',ml')",
tex_label=r"$(\ell, m_l) → (\ell', m_l')$",
)
]
@property
def metadata(self) -> dict:
bound = self.transition_quantum_numbers[0][0]
return {"Z": self.Z, "n": bound[0], "l": bound[1]}
@property
def transitions(self):
return self._transitions
@property
def transition_quantum_numbers(self):
return [
(bound.quantum_numbers, excited.quantum_numbers)
for (bound, excited) in self._transitions
]
def _calculate_overlap_integral(self, lprimeprime, bound, excited, k):
radial_grid = np.arange(0, np.max(k) * 1.05, 1 / max(self.extent))
integration_grid = np.linspace(0, bound.radial_grid[-1], 20000)
values = (
bound(integration_grid)
* spherical_jn(
lprimeprime,
2 * np.pi * units.Bohr * radial_grid[:, None] * integration_grid[None],
)
* excited(integration_grid)
)
integral = np.trapezoid(values, integration_grid, axis=1) / (
units.Bohr * np.sqrt(units.Rydberg)
)
return interp1d(radial_grid, integral)(k)
def _calculate_form_factor(self, bound, excited, k, phi, theta):
try:
from sympy.physics.wigner import wigner_3j
except ImportError as e:
raise ImportError(
"Calculating core-loss EELS form factors requires sympy. "
"Install it with `pip install abtem[gpaw]` or "
"`pip install sympy`."
) from e
Hn0 = np.zeros_like(k, dtype=complex)
l = bound.l
lprime = excited.l
ml = bound.ml
mlprime = excited.ml
mask = k <= np.max(k) * 2 / 3
for lprimeprime in range(abs(l - lprime), abs(l + lprime) + 1):
jq = self._calculate_overlap_integral(lprimeprime, bound, excited, k)
for mlprimeprime in range(-lprimeprime, lprimeprime + 1):
if ml - mlprime - mlprimeprime != 0:
continue
lprime = int(lprime)
lprimeprime = int(lprimeprime)
l = int(l)
mlprime = int(mlprime)
mlprimeprime = int(mlprimeprime)
ml = int(ml)
prefactor = (
np.sqrt(4 * np.pi)
* ((-1j) ** lprimeprime)
* np.sqrt((2 * lprime + 1) * (2 * lprimeprime + 1) * (2 * l + 1))
* (-1.0) ** (mlprime + mlprimeprime)
* float(wigner_3j(lprime, lprimeprime, l, 0, 0, 0))
* float(
wigner_3j(lprime, lprimeprime, l, -mlprime, -mlprimeprime, ml)
)
)
if np.abs(prefactor) < 1e-12:
continue
Ylm = sph_harm_y(lprimeprime, mlprimeprime, theta, phi)
Hn0[mask] += prefactor * (jq * Ylm)[mask]
return Hn0
[docs]
def integrated_intensities(self):
intensities = self.build().to_images().intensity()
return intensities.array.sum((-2, -1)) * np.prod(self.sampling)
[docs]
def filter_by_intensity(self, threshold: float) -> TransitionPotential:
integrated_intensities = self.integrated_intensities()
order = np.argsort(-integrated_intensities)
integrated_intensities = integrated_intensities[order]
cumulative = np.cumsum(integrated_intensities / integrated_intensities.sum())
n = np.searchsorted(cumulative, threshold) + 1
transitions = self.transitions[:n]
if not len(transitions) > 0:
raise RuntimeError()
kwargs = self._copy_kwargs(exclude=("transitions",))
kwargs["transitions"] = transitions
return self.__class__(**kwargs)
[docs]
def build(self) -> TransitionPotentialArray:
self.grid.check_is_defined()
self.accelerator.check_is_defined()
array = np.zeros((len(self._transitions),) + self.gpts, dtype=np.complex64)
k0 = 1 / energy2wavelength(self.energy)
for i, (bound, excited) in enumerate(self._transitions):
energy_loss = bound.energy - excited.energy
kn = 1 / energy2wavelength(self.energy + energy_loss)
kz = k0 - kn
kxy, phi = polar_spatial_frequencies(self.gpts, self.sampling)
k = np.sqrt(kxy**2 + kz**2)
theta = np.pi - np.arctan(kxy / kz)
array[i] = self._calculate_form_factor(bound, excited, k, phi, theta)
if self._orbital_filling_factor:
array[i] *= np.sqrt(4 * bound.l + 2)
array[i] *= relativistic_mass_correction(self.energy) / (
2 * np.pi**2 * kn * k**2 * energy2sigma(self.energy)
)
array = array / np.prod(self.sampling)
# array = array.astype(xp.complex64)
return TransitionPotentialArray(
self.Z,
array,
energy=self.energy,
extent=self.extent,
sampling=self.sampling,
ensemble_axes_metadata=self.ensemble_axes_metadata,
metadata=self.metadata,
)
[docs]
def scatter(self, waves: Waves, sites: Atoms | Atom | np.ndarray) -> Waves:
self.grid.match(waves)
self.accelerator.match(waves)
return self.build().scatter(waves, sites)
[docs]
def show(self, **kwargs):
return self.build().to_images().show(**kwargs)
[docs]
def fast_roll(array, shifts):
"""Batched 2D circular roll: ``out[i] == xp.roll(array, shifts[i], axis=(0, 1))``.
On CPU the per-site quadrant-copy loop is already very fast — each slice is
a memmove — and beats both ``xp.roll`` in a loop and a full advanced-indexing
gather. On GPU the advanced-indexing form wins because the per-site loop
serialises kernel launches; we dispatch on the backend.
Shifts are first reduced modulo ``H`` / ``W`` so negative and out-of-range
values are handled correctly (the previous version raised RuntimeError on
negative shifts).
"""
xp = get_array_module(array)
H, W = array.shape[-2:]
shifts = shifts.copy()
shifts[:, 0] %= H
shifts[:, 1] %= W
if xp is not np:
# GPU path: batched gather. CuPy's advanced indexing launches one
# kernel for the whole batch instead of one per site.
rows = (xp.arange(H)[None, :] - shifts[:, 0:1]) % H
cols = (xp.arange(W)[None, :] - shifts[:, 1:2]) % W
return array[rows[:, :, None], cols[:, None, :]]
# CPU path: per-site quadrant copy. Memmove inside each branch is faster
# than any vectorised numpy alternative we benchmarked.
output = xp.empty((len(shifts),) + array.shape, dtype=array.dtype)
for i in range(len(shifts)):
s0, s1 = int(shifts[i, 0]), int(shifts[i, 1])
if s0 > 0 and s1 > 0:
output[i, :s0, :s1] = array[-s0:, -s1:]
output[i, :s0, s1:] = array[-s0:, :-s1]
output[i, s0:, :s1] = array[:-s0, -s1:]
output[i, s0:, s1:] = array[:-s0, :-s1]
elif s1 > 0:
output[i, :, :s1] = array[:, -s1:]
output[i, :, s1:] = array[:, :-s1]
elif s0 > 0:
output[i, :s0, :] = array[-s0:, :]
output[i, s0:, :] = array[:-s0, :]
else:
output[i] = array
return output
[docs]
class TransitionPotentialArray(ArrayObject, BaseTransitionPotential):
_base_dims = 2
def __init__(
self,
Z: int,
array: np.ndarray,
energy: float = None,
extent: float | tuple[float, float] = None,
sampling: float | tuple[float, float] = None,
ensemble_axes_metadata: list[AxisMetadata] = None,
metadata: dict = None,
):
super().__init__(
Z=Z,
extent=extent,
gpts=array.shape[-2:],
sampling=sampling,
energy=energy,
array=array,
ensemble_axes_metadata=ensemble_axes_metadata,
metadata=metadata,
)
self._local_potential = self.local_potential(space="real").sum(0)
self._threshold = None
[docs]
def set_threshold(self, wave, threshold):
local_potentials = self.local_potential(space="real")
local_potential = local_potentials.sum(0)
c = np.fft.irfft2(np.fft.rfft2(local_potential) * np.fft.rfft2(wave.array))
c = np.sort(c.ravel())[::-1]
[docs]
def local_potential(self, max_angle=None, space="reciprocal"):
"""
Parameters
----------
max_angle : float
Maximum angle (in degrees) for the local potential calculation.
space : str, optional
Specifies the coordinate space in which the potential is calculated.
Default is "reciprocal". Possible values are "reciprocal" and "real".
Returns
-------
array : ndarray
The calculated local potential.
"""
self.accelerator.check_is_defined()
fourier_space_sampling = self.reciprocal_space_sampling
angular_sampling = (
fourier_space_sampling[0] * self.wavelength * 1e3,
fourier_space_sampling[1] * self.wavelength * 1e3,
)
array = self.array
if max_angle is not None:
region = _polar_detector_bins(
gpts=self.gpts,
sampling=angular_sampling,
inner=0.0,
outer=max_angle,
nbins_radial=1,
nbins_azimuthal=1,
fftshift=False,
rotation=0.0,
# offset=self.offset,
return_indices=False,
)
region = region >= 0.0
array = array * region
if space == "reciprocal":
array = abs2(array)
elif space == "real":
array = abs2(ifft2(array))
else:
raise ValueError(
"The 'space' parameter is invalid. Accepted values are 'reciprocal' or"
" 'real'."
)
return array
[docs]
def integrated_intensities(self, max_angle: float, space: str = "reciprocal"):
array = self.local_potential(max_angle, space)
intensity = array.sum((-2, -1)) * np.prod(self.sampling)
return intensity
[docs]
def filter_by_intensity(
self, threshold: float, max_angle: float
) -> TransitionPotential:
intensities = self.integrated_intensities(max_angle)
order = np.argsort(-intensities)
intensities = intensities[order]
cumulative = np.cumsum(intensities / intensities.sum())
n = np.searchsorted(cumulative, threshold) + 1
included = order[:n]
return self[included]
[docs]
def absolute_threshold(self, waves: Waves, threshold: float = 1.0):
if threshold >= 1.0:
return 0.0
if hasattr(waves, "build"):
waves = waves.build(lazy=False)
local_potential = self.local_potential(space="real").sum(0)
array = abs2(waves.array)
local_potential = copy_to_device(local_potential, array)
overlap = fft2_convolve(
local_potential[(None,) * (len(array.shape) - 2)].astype(np.complex64),
fft2(array.astype(np.complex64)),
).real
overlap = copy_to_device(overlap, "cpu")
overlap = np.sort(overlap.ravel())[::-1]
cumulative = np.cumsum(overlap) / overlap.sum()
return overlap[np.searchsorted(cumulative, threshold, side="left") - 1]
[docs]
def validate_sites(self, sites: Atoms | Atom) -> np.ndarray:
if isinstance(sites, Atoms):
sites = sites[sites.numbers == self.Z].positions[:, :2]
elif isinstance(sites, Atom):
if sites.number == self.Z:
sites = sites.position[:2]
else:
sites = np.zeros((0, 2), dtype=np.float32)
else:
sites = np.array(sites)
if len(sites.shape) == 1:
sites = sites[None]
sites = np.array(sites, dtype=np.float32)
return sites
[docs]
def filter_sites(self, waves, sites, threshold):
if hasattr(waves, "build"):
waves = waves.build(lazy=False)
validated_sites = self.validate_sites(sites)
if threshold is not None and threshold > 0.0:
xp = get_array_module(waves.array)
validated_sites = copy_to_device(validated_sites, waves.array)
rounded_sites = xp.round(
(validated_sites / xp.array(self.sampling))
).astype(int)
local_potential = copy_to_device(self._local_potential, waves.array)
# Stream the overlap reduction over sites in chunks. The full
# (n_sites, *waves_shape, H, W) tensor that the naive computation
# would build can dwarf available memory for big scans / many sites;
# by reducing each chunk to a per-site sum before moving on, peak
# transient is O(chunk_size) instead of O(n_sites).
#
# The chunk size targets the same byte budget as the rest of abTEM
# (dask.chunk-size / dask.chunk-size-gpu) via validate_chunks, so
# users who already tuned that knob for a memory-constrained
# workstation get the tighter behaviour here automatically.
abs2_waves = abs2(waves.array)
n_sites = len(validated_sites)
reduce_axes_offset = len(waves.shape) - 2 # broadcast dims per site
chunks = validate_chunks(
shape=(n_sites,) + waves.shape,
chunks=("auto",) + (-1,) * len(waves.shape),
max_elements="auto",
dtype=waves.dtype,
device=self.device,
)[0]
mask = xp.zeros(n_sites, dtype=bool)
start = 0
for chunk_size in chunks:
end = start + chunk_size
shifted = fast_roll(local_potential, rounded_sites[start:end])
shifted = shifted.reshape(
(end - start,)
+ (1,) * reduce_axes_offset
+ shifted.shape[-2:]
)
overlaps = (shifted * abs2_waves[None]).sum(axis=(-2, -1))
chunk_mask = overlaps > threshold
if chunk_mask.ndim > 1:
chunk_mask = chunk_mask.any(
tuple(range(1, chunk_mask.ndim))
)
mask[start:end] = chunk_mask
start = end
mask = copy_to_device(mask, "cpu")
# if np.any(mask):
# print(shifted_local_potential.shape, waves.shape)
#
# plt.imshow(
# shifted_local_potential[0, 0, 0]
# / shifted_local_potential[0, 0, 0].max()
# + abs2(waves.array).sum((0,1)) / abs2(waves.array[0, 0]).max()
# )
# plt.title("include")
# # plt.show()
# # plt.imshow(abs2(waves.array[0, 0]))
# plt.show()
# else:
# plt.imshow(
# shifted_local_potential[0, 0, 0]
# / shifted_local_potential[0, 0, 0].max()
# + abs2(waves.array).sum((0,1)) / abs2(waves.array[0, 0]).max()
# )
# plt.title("skip")
# # plt.show()
# # plt.imshow(abs2(waves.array[0, 0]))
# plt.show()
# print(type(mask), type(sites))
sites = sites[mask]
return sites
[docs]
def scatter(
self, waves: Waves, sites: Atoms | Atom | np.ndarray, threshold: float = None
) -> Waves:
self.grid.match(waves)
self.accelerator.match(waves)
self.grid.check_is_defined()
self.accelerator.check_is_defined()
xp = get_array_module(waves.array)
sites = self.validate_sites(sites)
sites = self.filter_sites(waves, sites, threshold=threshold)
if len(sites) == 0:
array = waves.array[None][[False]]
else:
self._array = copy_to_device(self.array, waves.array)
sites = copy_to_device(sites, waves.array)
sites = sites / xp.array(self.sampling, dtype=xp.float32)
array = ifft2(
self.array[None]
* fft_shift_kernel(sites, self.gpts)[:, None]
* energy2sigma(self.energy)
)
array = array.reshape(
(
len(sites),
len(self),
)
+ (1,) * (len(waves.shape) - 2)
+ array.shape[-2:]
)
array = array * waves.array[None, None]
array = array.reshape((-1,) + array.shape[2:])
d = waves._copy_kwargs(exclude=("array",))
d["array"] = array
ensemble_axes_metadata = [AxisMetadata(label="sites")]
d["ensemble_axes_metadata"] = (
ensemble_axes_metadata + d["ensemble_axes_metadata"]
)
return waves.__class__(**d)
[docs]
def generate_scattered_waves(
self,
waves: Waves,
sites: Atoms | Atom | np.ndarray,
max_batch: int = "auto",
threshold=None,
):
sites = self.validate_sites(sites)
if isinstance(max_batch, int):
limit = int(max_batch * np.prod(waves.shape) * len(self))
else:
limit = max_batch
chunks = validate_chunks(
shape=(len(sites),) + waves.shape,
chunks=(max_batch,) + (-1,) * len(waves.shape),
max_elements=limit,
dtype=waves.dtype,
device=self.device,
)[0]
start = 0
for chunk in chunks:
end = start + chunk
if end - start == 0:
break
sites_chunk = sites[start:end]
start = end
scattered_waves = self.scatter(waves, sites_chunk, threshold=threshold)
yield sites_chunk, scattered_waves
[docs]
def to_images(self):
array = np.fft.fftshift(ifft2(self.array), axes=(-2, -1))
return Images(
array,
sampling=self.sampling,
ensemble_axes_metadata=self.ensemble_axes_metadata,
)
[docs]
def show(self, **kwargs):
return self.to_images().show(**kwargs)
def _extract_scattering_sites(potential, sites):
"""Extract scattering sites from a potential, or validate provided sites.
Handles ``Potential`` (via ``get_sliced_atoms``), ``FrozenPhonons``-wrapped
potentials (via ``atoms``), and ``CrystalPotential`` (via
``potential_unit.get_transformed_atoms`` tiled by ``repetitions``).
"""
from abtem.slicing import SliceIndexedAtoms
if sites is None and hasattr(potential, "get_sliced_atoms"):
sites = potential.get_sliced_atoms()
elif sites is None and hasattr(potential, "atoms"):
sites = potential.atoms
elif sites is None and hasattr(potential, "potential_unit"):
if hasattr(potential.potential_unit, "get_transformed_atoms"):
unit_atoms = potential.potential_unit.get_transformed_atoms()
sites = unit_atoms * potential.repetitions
if isinstance(sites, Atoms):
sites = SliceIndexedAtoms(sites, slice_thickness=potential.slice_thickness)
elif not isinstance(sites, SliceIndexedAtoms):
raise ValueError(
"Could not derive scattering sites from the potential "
f"({type(potential).__name__}). Pass ``sites=`` explicitly as an "
"ase.Atoms or SliceIndexedAtoms covering the full simulation cell."
)
return sites
def _prism_eels_common_setup(s_matrix, transition_potentials, scan, detectors, sites):
"""Shared setup for the real-space and beam-basis PRISM-EELS drivers."""
import types as _types
from abtem.antialias import AntialiasAperture
from abtem.core.utils import get_dtype
from abtem.detectors import FlexibleAnnularDetector, validate_detectors
from abtem.multislice import FresnelPropagator, conventional_multislice_step
from abtem.prism.utils import plane_waves
from abtem.scan import validate_scan
from abtem.transfer import CTF
from abtem.waves import Waves
if isinstance(transition_potentials, (list, tuple)):
if len(transition_potentials) != 1:
raise NotImplementedError(
"PRISM-EELS supports a single transition potential."
)
transition_potential = transition_potentials[0]
else:
transition_potential = transition_potentials
if isinstance(transition_potential, TransitionPotential):
transition_potential = transition_potential.build()
potential = s_matrix.potential
energy = s_matrix.energy
extent = s_matrix.extent
gpts = s_matrix.gpts
xp = get_array_module(s_matrix.device)
complex_dtype = get_dtype(complex=True)
scan = validate_scan(scan)
if detectors is None:
detectors = [FlexibleAnnularDetector()]
detectors = validate_detectors(detectors)
wave_vectors = s_matrix.wave_vectors
wave_vectors_np = np.array(
wave_vectors.get() if hasattr(wave_vectors, "get") else wave_vectors
)
n_k = len(wave_vectors_np)
s_array = plane_waves(
xp.asarray(wave_vectors_np, dtype=np.float32), extent, gpts
)
s_array = s_array * (
np.prod(s_matrix.interpolation) / np.prod(s_array.shape[-2:])
)
s_waves = Waves(
s_array,
energy=energy,
extent=extent,
ensemble_axes_metadata=[OrdinalAxis(values=tuple(range(n_k)))],
)
antialias_aperture = AntialiasAperture()
propagator = FresnelPropagator()
transmissions = [
antialias_aperture.bandlimit(
s.transmission_function(energy=energy), in_place=False
)
for s in potential.generate_slices()
]
transition_potential.grid.match(s_waves)
transition_potential.accelerator.match(s_waves)
transition_potential = transition_potential.copy_to_device(s_matrix.device)
Z = transition_potential.Z
sites = _extract_scattering_sites(potential, sites)
positions_np = np.asarray(scan.get_positions()).reshape((-1, 2))
positions = xp.asarray(positions_np, dtype=np.float32)
n_positions = positions.shape[0]
wave_vectors_xp = xp.asarray(wave_vectors_np, dtype=np.float32)
position_coefficients = complex_exponential(
-2.0 * np.float32(np.pi)
* positions[:, 0:1]
* wave_vectors_xp[None, :, 0]
) * complex_exponential(
-2.0 * np.float32(np.pi)
* positions[:, 1:2]
* wave_vectors_xp[None, :, 1]
)
ctf = CTF(semiangle_cutoff=s_matrix.semiangle_cutoff, energy=energy)
ctf.grid.match(s_matrix.dummy_probes())
alpha = (
xp.sqrt(wave_vectors_xp[:, 0] ** 2 + wave_vectors_xp[:, 1] ** 2)
* np.float32(ctf.wavelength)
)
phi = xp.arctan2(wave_vectors_xp[:, 1], wave_vectors_xp[:, 0])
ctf_array = ctf._evaluate_from_angular_grid(alpha, phi)
ctf_array = ctf_array / xp.sqrt(
(ctf_array**2).sum(axis=-1, keepdims=True)
)
coefficients = (position_coefficients * ctf_array[None, :]).astype(
complex_dtype
)
full_sampling = (extent[0] / gpts[0], extent[1] / gpts[1])
full_sampling_arr = np.array(full_sampling, dtype=np.float32)
return _types.SimpleNamespace(
transition_potential=transition_potential,
Z=Z,
potential=potential,
energy=energy,
extent=extent,
gpts=gpts,
xp=xp,
complex_dtype=complex_dtype,
scan=scan,
detectors=detectors,
wave_vectors_np=wave_vectors_np,
n_k=n_k,
s_waves=s_waves,
antialias_aperture=antialias_aperture,
propagator=propagator,
transmissions=transmissions,
sites=sites,
positions=positions,
n_positions=n_positions,
coefficients=coefficients,
full_sampling=full_sampling,
full_sampling_arr=full_sampling_arr,
)
[docs]
def prism_transition_potential_scan(
s_matrix: "SMatrix",
transition_potentials,
scan,
detectors=None,
sites=None,
double_channel: bool = False,
inelastic_crop: float | tuple[float, float] | None = None,
):
"""PRISM-EELS driver following Brown et al. (Phys. Rev. Research 1,
033186, 2019).
Supports any ``interpolation`` factor, ``downsample`` setting, and both
single- and double-channel modes. The scatter and (optionally)
double-channel propagation operate on a cropped grid at full resolution
centered at each scattering site (Sec. IV B); when ``downsample`` is
enabled the scattered result is Fourier-cropped to the downsampled
resolution before per-position reduction.
At ``interpolation=(1,1)`` with ``downsample=False`` the output is
bit-equivalent (to float32 noise) to ``Probe.transition_potential_scan``
at the matching ``double_channel`` setting.
``double_channel=True`` propagates the scattered state through the
remaining potential slices to the exit before reducing per-position;
``double_channel=False`` (default) detects immediately at the scatter
slice — Brown's single-channel approximation.
Frozen-phonon ensemble averaging is handled at the ``SMatrix`` level
(see ``SMatrix.transition_potential_scan``); Dask lazy evaluation is
supported via the ``lazy`` parameter on that method.
Parameters
----------
s_matrix : SMatrix
S-matrix specification (any ``interpolation``).
transition_potentials : BaseTransitionPotential
Atomic transition potential.
scan : BaseScan or tuple
Scan positions.
detectors : BaseDetector or list, optional
Detectors. Defaults to ``FlexibleAnnularDetector()``.
sites : Atoms or SliceIndexedAtoms, optional
Scattering sites. Auto-extracted from the potential if not given,
following the same logic as
``transition_potential_multislice_and_detect``.
inelastic_crop : float or tuple of float, optional
Real-space side length [Å] of the window on which the transition
potential ``H_n0`` and the scattered wave are evaluated, following
Brown et al. Sec. IV B (their independent ``inelastic_crop`` factor).
Smaller windows speed up the scatter and — most significantly — the
double-channel inner propagation, at the cost of truncating the
``H_n0`` tails (cf. their Fig. 4 / Table II). If ``None`` (default)
the full PRISM cell ``extent / interpolation`` is used (current
behaviour). The window is clamped to the PRISM cell: values larger
than ``extent / interpolation`` are not supported by this real-space
reduction (they would admit aliased probe copies) and are clamped
with a warning — exceeding the cell requires the beam-basis reduction
(see the PRISM-EELS follow-up note).
Returns
-------
BaseMeasurements or list of BaseMeasurements
One measurement per detector.
"""
import warnings
from abtem.core.fft import fft_interpolate
from abtem.core.utils import get_dtype, safe_ceiling_int
from abtem.multislice import (
FresnelPropagator,
_potential_ensemble_shape_and_metadata,
allocate_multislice_measurements,
conventional_multislice_step,
)
from abtem.prism.utils import (
batch_crop_2d,
minimum_crop,
wrapped_crop_2d,
)
from abtem.waves import Waves, reduce_ensemble
ctx = _prism_eels_common_setup(
s_matrix, transition_potentials, scan, detectors, sites
)
transition_potential = ctx.transition_potential
Z = ctx.Z
potential = ctx.potential
energy = ctx.energy
extent = ctx.extent
gpts = ctx.gpts
xp = ctx.xp
complex_dtype = ctx.complex_dtype
real_dtype = get_dtype(complex=False)
scan = ctx.scan
detectors = ctx.detectors
n_k = ctx.n_k
s_waves = ctx.s_waves
transmissions = ctx.transmissions
n_slices = len(transmissions)
sites = ctx.sites
positions = ctx.positions
n_positions = ctx.n_positions
coefficients = ctx.coefficients
full_sampling = ctx.full_sampling
full_sampling_arr = ctx.full_sampling_arr
def _step(waves, transmission):
return conventional_multislice_step(
waves,
potential_slice=transmission,
propagator=ctx.propagator,
antialias_aperture=ctx.antialias_aperture,
)
# --- Window properties ---
interpolation = s_matrix.interpolation
ds_gpts = s_matrix.downsampled_gpts
full_sampling = (extent[0] / gpts[0], extent[1] / gpts[1])
ds_sampling = (extent[0] / ds_gpts[0], extent[1] / ds_gpts[1])
needs_downsample = ds_gpts != gpts
scatter_window_gpts = (
safe_ceiling_int(gpts[0] / interpolation[0]),
safe_ceiling_int(gpts[1] / interpolation[1]),
)
output_window_gpts = (
safe_ceiling_int(ds_gpts[0] / interpolation[0]),
safe_ceiling_int(ds_gpts[1] / interpolation[1]),
)
scatter_window_extent = (
scatter_window_gpts[0] * full_sampling[0],
scatter_window_gpts[1] * full_sampling[1],
)
output_window_extent = (
output_window_gpts[0] * ds_sampling[0],
output_window_gpts[1] * ds_sampling[1],
)
# --- Inelastic crop window (Brown et al. Sec. IV B, independent of the
# interpolation factor) ---
# The scatter and double-channel propagation run on this window; the
# scattered result is then embedded (centered, zero-padded) back into
# scatter_window_gpts before the per-position reduction so that the
# detection grid — and hence the validated normalisation — is unchanged.
# The window is clamped to the PRISM cell (scatter_window_gpts): a larger
# window would admit aliased probe copies in this real-space reduction
# and requires the beam-basis path instead.
if inelastic_crop is None:
inelastic_window_gpts = scatter_window_gpts
else:
if np.isscalar(inelastic_crop):
inelastic_crop = (inelastic_crop, inelastic_crop)
requested = (
safe_ceiling_int(inelastic_crop[0] / full_sampling[0]),
safe_ceiling_int(inelastic_crop[1] / full_sampling[1]),
)
inelastic_window_gpts = (
min(requested[0], scatter_window_gpts[0]),
min(requested[1], scatter_window_gpts[1]),
)
if (
requested[0] > scatter_window_gpts[0]
or requested[1] > scatter_window_gpts[1]
):
warnings.warn(
"inelastic_crop exceeds the PRISM cell "
f"(extent / interpolation = {scatter_window_extent[0]:.2f} x "
f"{scatter_window_extent[1]:.2f} A); clamping to the cell. "
"Larger inelastic windows require the beam-basis reduction.",
stacklevel=2,
)
inelastic_window_extent = (
inelastic_window_gpts[0] * full_sampling[0],
inelastic_window_gpts[1] * full_sampling[1],
)
def _embed_in_scatter_window(arr):
# Place an inelastic_window_gpts-sized array (centered on the site)
# into a scatter_window_gpts-sized zero array (also centered). When
# the two match (inelastic_crop is None) this is a no-op.
src = tuple(arr.shape[-2:])
if src == tuple(scatter_window_gpts):
return arr
out = xp.zeros(
arr.shape[:-2] + tuple(scatter_window_gpts), dtype=arr.dtype
)
o0 = (scatter_window_gpts[0] - src[0]) // 2
o1 = (scatter_window_gpts[1] - src[1]) // 2
out[..., o0 : o0 + src[0], o1 : o1 + src[1]] = arr
return out
# --- Pre-compute windowed TP (Brown et al. Sec. IV B) ---
# Scatter and double-channel propagation operate on an
# inelastic_window_gpts-sized grid centered at each site.
_tp_real_origin = ifft2(
transition_potential.array * energy2sigma(energy)
)
_tp_crop_corner = (
-inelastic_window_gpts[0] // 2,
-inelastic_window_gpts[1] // 2,
)
_tp_window_real = wrapped_crop_2d(
_tp_real_origin, _tp_crop_corner, inelastic_window_gpts
)
_tp_window_k = fft2(_tp_window_real)
_window_propagator = FresnelPropagator()
_dummy_window_waves = Waves(
xp.zeros((1,) + tuple(inelastic_window_gpts), dtype=complex_dtype),
energy=energy,
extent=inelastic_window_extent,
ensemble_axes_metadata=[OrdinalAxis(values=(0,))],
)
full_sampling_arr = np.array(full_sampling, dtype=np.float32)
# Reduction helpers operate in the downsampled grid.
pixel_positions = positions / xp.asarray(ds_sampling, dtype=np.float32)
reduce_crop_corner, reduce_size, reduce_corners = minimum_crop(
pixel_positions, output_window_gpts
)
# --- Exit planes ---
exit_planes = potential.exit_planes
n_exit = len(exit_planes)
(
extra_ensemble_axes_shape,
extra_ensemble_axes_metadata,
) = _potential_ensemble_shape_and_metadata(potential)
# --- Allocate measurements with the scan shape ---
scan_axes_metadata = scan.ensemble_axes_metadata
scan_shape = scan.shape
dummy_scan_waves = Waves(
xp.zeros(scan_shape + output_window_gpts, dtype=complex_dtype),
energy=energy,
extent=output_window_extent,
ensemble_axes_metadata=scan_axes_metadata,
)
measurements = allocate_multislice_measurements(
dummy_scan_waves,
detectors,
extra_ensemble_axes_shape,
extra_ensemble_axes_metadata,
)
# --- Reduce, detect, accumulate helper ---
def _reduce_and_record(scattered_window, site_xy, exit_idx):
ds_sampling_arr = np.array(ds_sampling, dtype=np.float32)
site_pixel_ds = site_xy / ds_sampling_arr
site_pixel_int_ds = np.rint(site_pixel_ds).astype(int)
site_crop_corner_ds = (
int(site_pixel_int_ds[0]) - output_window_gpts[0] // 2,
int(site_pixel_int_ds[1]) - output_window_gpts[1] // 2,
)
site_in_bbox = (
site_crop_corner_ds[0] - reduce_crop_corner[0],
site_crop_corner_ds[1] - reduce_crop_corner[1],
)
bbox_scattered = xp.zeros(
scattered_window.shape[:-2] + tuple(reduce_size),
dtype=complex_dtype,
)
for _n0 in range(-1, 2):
for _n1 in range(-1, 2):
_r0 = site_in_bbox[0] + _n0 * ds_gpts[0]
_r1 = site_in_bbox[1] + _n1 * ds_gpts[1]
_s0 = max(0, -_r0)
_s1 = max(0, -_r1)
_d0 = max(0, _r0)
_d1 = max(0, _r1)
_e0 = min(reduce_size[0], _r0 + output_window_gpts[0])
_e1 = min(reduce_size[1], _r1 + output_window_gpts[1])
if _d0 >= _e0 or _d1 >= _e1:
continue
bbox_scattered[
..., _d0:_e0, _d1:_e1
] = scattered_window[
...,
_s0 : _s0 + (_e0 - _d0),
_s1 : _s1 + (_e1 - _d1),
]
reduced = xp.tensordot(
coefficients, bbox_scattered, axes=[-1, -3]
)
reduced = xp.moveaxis(reduced, 1, 0)
waves_at_positions = batch_crop_2d(
reduced, reduce_corners, output_window_gpts
)
position_waves_shape = (
waves_at_positions.shape[:-3]
+ scan_shape
+ waves_at_positions.shape[-2:]
)
waves_at_positions = waves_at_positions.reshape(
position_waves_shape
)
n_T = waves_at_positions.shape[0]
position_waves = Waves(
waves_at_positions,
energy=energy,
extent=output_window_extent,
ensemble_axes_metadata=[
OrdinalAxis(values=tuple(range(n_T)))
]
+ list(scan_axes_metadata),
)
for det_idx, detector in enumerate(detectors):
m = detector.detect(position_waves)
m = m.sum((0,))
if isinstance(exit_idx, int):
idx = () if n_exit == 1 else (exit_idx,)
measurements[det_idx].array[idx] += m.array
else:
measurements[det_idx].array[exit_idx] += (
m.array[(None,) * len(exit_idx)]
)
def _scatter_at_site(atom):
site_xy = np.array(
[atom.position[0], atom.position[1]], dtype=np.float32
)
site_pixel = site_xy / full_sampling_arr
site_pixel_int = np.rint(site_pixel).astype(int)
sub_pixel = xp.asarray(
(site_pixel - site_pixel_int).reshape(1, 2), dtype=np.float32,
)
site_crop_corner = (
int(site_pixel_int[0]) - inelastic_window_gpts[0] // 2,
int(site_pixel_int[1]) - inelastic_window_gpts[1] // 2,
)
s_cropped = wrapped_crop_2d(
s_waves.array, site_crop_corner, inelastic_window_gpts
)
shift_k = fft_shift_kernel(sub_pixel, inelastic_window_gpts)
tp_shifted = ifft2(_tp_window_k * shift_k)
sw = tp_shifted[:, None] * s_cropped[None, :]
return sw, site_xy, site_crop_corner
# --- Main loop ---
for slice_index, transmission in enumerate(transmissions):
s_waves = _step(s_waves, transmission)
sites_this_slice = sites.get_atoms_in_slices(
slice_index, atomic_number=Z
)
if len(sites_this_slice) == 0:
continue
if not double_channel:
ep_start = bisect_left(exit_planes, slice_index)
exit_idx = () if n_exit == 1 else (
slice(ep_start, n_exit),
)
for atom in sites_this_slice:
sw, site_xy, _ = _scatter_at_site(atom)
sw = _embed_in_scatter_window(sw)
if needs_downsample:
sw = fft_interpolate(
sw, output_window_gpts,
normalization="intensity",
)
_reduce_and_record(sw, site_xy, exit_idx)
continue
site_xys = []
site_crop_corners = []
scattered_windows = []
for atom in sites_this_slice:
sw, site_xy, site_crop_corner = _scatter_at_site(atom)
site_xys.append(site_xy)
site_crop_corners.append(site_crop_corner)
scattered_windows.append(sw)
n_T_val = scattered_windows[0].shape[0]
n_sites_slice = len(scattered_windows)
# Double-channel: batch inner propagation across all sites in
# this slice. Shape: (n_sites, n_T * n_k, wh, ww) on the inelastic
# window; embedded back into scatter_window_gpts at each exit plane.
batched = xp.stack([
sw.reshape((-1,) + tuple(inelastic_window_gpts))
for sw in scattered_windows
])
if slice_index in exit_planes:
ep_idx = exit_planes.index(slice_index)
for s_idx in range(n_sites_slice):
sw_out = _embed_in_scatter_window(
batched[s_idx].reshape(
(n_T_val, n_k) + tuple(inelastic_window_gpts)
)
)
if needs_downsample:
sw_out = fft_interpolate(
sw_out, output_window_gpts,
normalization="intensity",
)
_reduce_and_record(sw_out, site_xys[s_idx], ep_idx)
for inner_idx, inner_transmission in enumerate(
transmissions[slice_index + 1:]
):
# Crop transmission for each site: (n_sites, 1, wh, ww).
# Transmissions may carry a leading singleton ensemble dim
# (shape (1, H, W)); squeeze to 2D before stacking.
t_arr = inner_transmission.array
if t_arr.ndim > 2:
t_arr = t_arr[0]
cropped_t = xp.stack([
wrapped_crop_2d(t_arr, sc, inelastic_window_gpts)
for sc in site_crop_corners
])[:, None]
batched *= cropped_t
kernel = _window_propagator.get_array(
_dummy_window_waves,
thickness=inner_transmission.slice_thickness[0],
)
batched = fft2_convolve(batched, kernel, overwrite_x=True)
abs_inner = slice_index + 1 + inner_idx
if abs_inner in exit_planes:
ep_idx = exit_planes.index(abs_inner)
for s_idx in range(n_sites_slice):
sw_out = _embed_in_scatter_window(
batched[s_idx].reshape(
(n_T_val, n_k) + tuple(inelastic_window_gpts)
)
)
if needs_downsample:
sw_out = fft_interpolate(
sw_out, output_window_gpts,
normalization="intensity",
)
_reduce_and_record(
sw_out, site_xys[s_idx], ep_idx
)
# Squeeze out single-point-scan axes the same way the multislice path
# does (via reduce_ensemble inside Waves.transition_potential_multislice
# — see waves.py:1075). This is what makes ``scan=(0, 0)`` return a bare
# detector-shaped measurement instead of a ``(1, *detector_shape)``
# array with a singleton scan axis.
measurements = [reduce_ensemble(m) for m in measurements]
if len(measurements) == 1:
return measurements[0]
return measurements
[docs]
def prism_transition_potential_scan_beam_basis(
s_matrix: "SMatrix",
transition_potentials,
scan,
detectors=None,
sites=None,
double_channel: bool = True,
inelastic_crop: float | tuple[float, float] | None = None,
):
"""PRISM-EELS beam-basis reduction (Brown et al. Sec. IV B / Eq. dropped
in supplementary; ``PRISM_double_channeling_nanoparticle.m``) — the
accuracy-oriented alternative to :func:`prism_transition_potential_scan`.
Implements Brown's beam-basis contraction (un-reduced S-matrix columns
against a transition-potential window, *before* applying the periodic
position phase ramps). This was originally pursued (GitHub issue
abTEM/abTEM#293) to let the transition-potential window *exceed* the
real-space driver's PRISM-cell cap, in the hope of fixing the
delocalized-edge truncation error at ``interpolation > 1``. **That goal
turned out to be unfounded:** the interpolation-decimated PRISM probe is
exactly periodic with the PRISM cell (``extent / interpolation``), so a
window larger than the cell multiplies the transition-potential tail
against an exact *copy* of the probe peak — adding spurious signal rather
than recovering accuracy. A direct experiment (issue #293, Update 4)
confirms the shape error is flat-to-worse as the window grows past the
cell, and Brown's own published run uses a window *smaller* than the cell.
``inelastic_crop`` is therefore clamped to the cell, exactly like the
real-space driver. This function is kept as a **validated, independent
re-derivation** of Brown's reduction (bit-exact at ``interpolation=1``);
it does not — and now appears it cannot — beat the real-space path on
delocalized-edge accuracy. The lever for delocalized edges is a larger
cell (lower ``interpolation`` or a bigger supercell), not a larger window.
Normalisation derivation (validated bit-exact against
``Probe.transition_potential_scan`` at ``interpolation=(1, 1)`` for both
single- and double-channel; see ``project_prism_eels_beam_basis_convention``
memory note): for an abtem ``fft2``/``ifft2`` pair (unnormalised forward,
``1/N`` inverse),
.. code-block::
fft2(forward_propagate(psi))[q] = N * sum_r conj(S2[q, r]) * psi[r]
where ``N = prod(gpts)`` and ``S2[q]`` is built by reverse-propagating
``ifft2(delta_q)`` through the remaining slices with
``conventional_multislice_step(..., conjugate=True, transpose=True)``.
The full contraction is
.. code-block::
SHn0[q, k] = N * sum_{r in window} conj(S2[q, r]) * H(r) * S1[k, r]
recip[pos, q] = sum_k coeff[pos, k] * SHn0[q, k]
**Limitations** (this is a validated reference implementation, not an
optimised production path — see GitHub issue abTEM/abTEM#293):
- ``inelastic_crop`` exceeding the PRISM cell is clamped (with a warning):
it is not a useful regime — see the docstring intro.
- ``S2`` (double-channel) is built over the *full* native reciprocal grid
(``prod(gpts)`` beams). Memory and compute scale as ``O(prod(gpts)^2)``
per scattering site per slice — only practical for small grids.
- Single exit plane only (``len(potential.exit_planes) == 1``).
- No frozen-phonon ensemble (``potential.ensemble_shape == ()``).
- No ``downsample`` support (``s_matrix.downsampled_gpts == s_matrix.gpts``).
- Eager only; no Dask laziness.
Parameters
----------
s_matrix : SMatrix
S-matrix specification (any ``interpolation``).
transition_potentials : BaseTransitionPotential
Atomic transition potential.
scan : BaseScan or tuple
Scan positions.
detectors : BaseDetector or list, optional
Detectors. Defaults to ``FlexibleAnnularDetector()``.
sites : Atoms or SliceIndexedAtoms, optional
Scattering sites. Auto-extracted from the potential if not given.
double_channel : bool, optional
If ``True`` (default), propagate the scattered state to the exit via
a reverse-multislice ``S2`` before reducing. If ``False``, detect
immediately at the scatter slice (single-channel): ``S2`` is then
trivial — the contraction reduces to an FFT of the windowed
scattered field directly, no reverse multislice needed.
inelastic_crop : float or tuple of float, optional
Real-space side length [Å] of the window on which ``H_n0`` and the
scattered wave are evaluated. Clamped to the PRISM cell
(``extent / interpolation``) with a warning if larger — see
Limitations above. If ``None`` (default), the PRISM cell is used,
matching the real-space driver's default window.
Returns
-------
BaseMeasurements or list of BaseMeasurements
One measurement per detector.
"""
import warnings
from abtem.core.utils import safe_ceiling_int
from abtem.multislice import (
allocate_multislice_measurements,
conventional_multislice_step,
)
from abtem.prism.utils import wrapped_crop_2d
from abtem.waves import Waves
ctx = _prism_eels_common_setup(
s_matrix, transition_potentials, scan, detectors, sites
)
transition_potential = ctx.transition_potential
Z = ctx.Z
potential = ctx.potential
energy = ctx.energy
extent = ctx.extent
gpts = ctx.gpts
xp = ctx.xp
complex_dtype = ctx.complex_dtype
scan = ctx.scan
detectors = ctx.detectors
n_k = ctx.n_k
s_waves = ctx.s_waves
transmissions = ctx.transmissions
sites = ctx.sites
positions = ctx.positions
n_positions = ctx.n_positions
coefficients = ctx.coefficients
full_sampling = ctx.full_sampling
full_sampling_arr = ctx.full_sampling_arr
if s_matrix.downsampled_gpts != gpts:
raise NotImplementedError(
"PRISM-EELS beam-basis does not yet support downsample "
"(s_matrix.downsampled_gpts != s_matrix.gpts)."
)
if potential.ensemble_shape:
raise NotImplementedError(
"PRISM-EELS beam-basis does not yet support frozen-phonon "
"ensembles (potential.ensemble_shape is non-empty)."
)
exit_planes = potential.exit_planes
if len(exit_planes) != 1:
raise NotImplementedError(
"PRISM-EELS beam-basis only supports a single exit plane "
f"(got {len(exit_planes)})."
)
def _step(waves, transmission, **kwargs):
return conventional_multislice_step(
waves,
potential_slice=transmission,
propagator=ctx.propagator,
antialias_aperture=ctx.antialias_aperture,
**kwargs,
)
tp_k = transition_potential.array * energy2sigma(energy)
n_T = tp_k.shape[0]
n_pix = gpts[0] * gpts[1]
interpolation = s_matrix.interpolation
cell_gpts = (
safe_ceiling_int(gpts[0] / interpolation[0]),
safe_ceiling_int(gpts[1] / interpolation[1]),
)
cell_extent = (
cell_gpts[0] * full_sampling[0],
cell_gpts[1] * full_sampling[1],
)
prism_region = (cell_extent[0] / 2, cell_extent[1] / 2)
if inelastic_crop is None:
window_gpts = cell_gpts
else:
if np.isscalar(inelastic_crop):
inelastic_crop = (inelastic_crop, inelastic_crop)
window_gpts = (
min(gpts[0], safe_ceiling_int(inelastic_crop[0] / full_sampling[0])),
min(gpts[1], safe_ceiling_int(inelastic_crop[1] / full_sampling[1])),
)
if window_gpts[0] > cell_gpts[0] or window_gpts[1] > cell_gpts[1]:
warnings.warn(
"PRISM-EELS beam-basis: inelastic_crop exceeding the PRISM cell "
"(extent / interpolation) does not improve accuracy and is "
"clamped to the cell. The interpolation-decimated PRISM probe is "
"exactly cell-periodic, so a larger window multiplies the "
"transition-potential tail against a copy of the probe peak "
"(spurious signal, not recovered accuracy) — see GitHub issue "
"abTEM/abTEM#293, Update 4. The lever for delocalized edges is a "
"larger cell (lower interpolation / bigger supercell).",
stacklevel=2,
)
window_gpts = (
min(window_gpts[0], cell_gpts[0]),
min(window_gpts[1], cell_gpts[1]),
)
# --- Allocate measurements (full scan shape; identical pattern to the
# real-space driver, single exit plane). ---
# Detection resolution: double-channel's q-basis is intrinsically the
# full native reciprocal grid (S2 is built over all ``gpts`` pixels), so
# detect at ``gpts``/``extent``. Single-channel's q is whatever size we
# FFT (no reverse multislice) — detecting on a *zero-padded* gpts-sized
# array would inflate the Parseval-summed intensity by
# ``prod(gpts) / prod(window_gpts)`` relative to the real-space driver's
# convention (detector.detect() does its own internal FFT at whatever
# array size it is given, and the unnormalised-forward/``1/N``-inverse
# FFT pair used throughout abtem is not size-invariant for the *total*
# intensity). So single-channel must FFT and detect directly at
# ``window_gpts``/``window_extent``, matching the real-space driver's
# ``output_window_gpts``-sized detection grid when ``window_gpts``
# equals the cell.
detect_gpts = gpts if double_channel else window_gpts
detect_extent = (
detect_gpts[0] * full_sampling[0],
detect_gpts[1] * full_sampling[1],
)
scan_axes_metadata = scan.ensemble_axes_metadata
scan_shape = scan.shape
dummy_scan_waves = Waves(
xp.zeros(scan_shape + tuple(detect_gpts), dtype=complex_dtype),
energy=energy,
extent=detect_extent,
ensemble_axes_metadata=scan_axes_metadata,
)
measurements = allocate_multislice_measurements(
dummy_scan_waves, detectors, (), []
)
def _detect_and_accumulate(recip_full, mask):
# recip_full: (n_T, n_masked, *detect_gpts) reciprocal-space.
real_full = ifft2(recip_full)
wave = Waves(
real_full,
energy=energy,
extent=detect_extent,
ensemble_axes_metadata=[
OrdinalAxis(values=tuple(range(n_T))),
OrdinalAxis(values=tuple(range(int(mask.sum())))),
],
)
for det_idx, detector in enumerate(detectors):
m = detector.detect(wave)
m = m.sum((0,))
full_partial = xp.zeros(
(n_positions,) + m.array.shape[1:], dtype=m.array.dtype
)
full_partial[mask] = m.array
full_partial = copy_to_device(full_partial, measurements[det_idx].array)
measurements[det_idx].array += full_partial.reshape(
scan_shape + m.array.shape[1:]
)
# --- Main loop ---
for slice_index, transmission in enumerate(transmissions):
s_waves = _step(s_waves, transmission)
sites_this_slice = sites.get_atoms_in_slices(slice_index, atomic_number=Z)
if len(sites_this_slice) == 0:
continue
s2_full = None
if double_channel:
# Build S2 over the FULL native reciprocal grid: reverse
# multislice (conjugate transmission, transposed propagate
# order) of every reciprocal-pixel delta function, batched.
delta_k = xp.eye(n_pix, dtype=complex_dtype).reshape((n_pix,) + tuple(gpts))
s2_array = ifft2(delta_k)
s2_waves = Waves(
s2_array,
energy=energy,
extent=extent,
ensemble_axes_metadata=[OrdinalAxis(values=tuple(range(n_pix)))],
)
for t in reversed(transmissions[slice_index + 1 :]):
s2_waves = _step(s2_waves, t, conjugate=True, transpose=True)
s2_full = s2_waves.array # (n_pix, *gpts)
for atom in sites_this_slice:
site_xy = np.array(
[atom.position[0], atom.position[1]], dtype=np.float32
)
site_pixel = site_xy / full_sampling_arr
site_pixel_int = np.rint(site_pixel).astype(int)
crop_corner = (
int(site_pixel_int[0]) - window_gpts[0] // 2,
int(site_pixel_int[1]) - window_gpts[1] // 2,
)
shift_k = fft_shift_kernel(
xp.asarray(site_pixel.reshape(1, 2), dtype=np.float32), gpts
)[0]
H_full = ifft2(tp_k * shift_k) # (n_T, *gpts), shifted to true site position
H_crop = wrapped_crop_2d(H_full, crop_corner, window_gpts)
s1_crop = wrapped_crop_2d(s_waves.array, crop_corner, window_gpts)
HS1 = H_crop[:, None] * s1_crop[None, :] # (n_T, n_k, wh, ww)
mask = xp.ones(n_positions, dtype=bool)
if interpolation[0] > 1:
mask &= (
xp.abs(positions[:, 0] - site_xy[0])
% (extent[0] - prism_region[0])
) <= prism_region[0]
if interpolation[1] > 1:
mask &= (
xp.abs(positions[:, 1] - site_xy[1])
% (extent[1] - prism_region[1])
) <= prism_region[1]
coeff_masked = coefficients[mask] # (n_masked, n_k)
if double_channel:
S2_crop = wrapped_crop_2d(s2_full, crop_corner, window_gpts)
S2_flat = S2_crop.conj().reshape(n_pix, -1)
recip_full = xp.stack(
[
(
n_pix
* (S2_flat @ HS1[t].reshape(n_k, -1).T) # (n_pix, n_k)
)
@ coeff_masked.T # (n_pix, n_masked)
for t in range(n_T)
]
) # (n_T, n_pix, n_masked)
recip_full = xp.moveaxis(recip_full, -1, 1).reshape(
(n_T, -1) + tuple(gpts)
) # (n_T, n_masked, *gpts)
else:
# Single channel: S2 is trivial -- FFT the windowed
# scattered field directly (no reverse multislice, and no
# zero-padding to the full grid: detect at window_gpts
# resolution to match the real-space driver's convention,
# see the ``detect_gpts`` note above).
SHn0 = fft2(HS1) # (n_T, n_k, *window_gpts)
recip_full = xp.tensordot(
coeff_masked, SHn0, axes=[1, 1]
) # (n_masked, n_T, *window_gpts)
recip_full = xp.moveaxis(recip_full, 0, 1) # (n_T, n_masked, *window_gpts)
_detect_and_accumulate(recip_full, mask)
if len(measurements) == 1:
return measurements[0]
return measurements