Source code for abtem.prism.s_matrix

"""Module describing the scattering matrix used in the PRISM algorithm."""

from __future__ import annotations

import copy
import inspect
import operator
import warnings
from abc import abstractmethod
from functools import partial, reduce

import dask.array as da
import numpy as np
from ase import Atoms
from dask.graph_manipulation import wait_on

from abtem.array import ArrayObject, ComputableList, stack, validate_lazy
from abtem.core import config
from abtem.core.axes import (
    AxisMetadata,
    EnergyAxis,
    OrdinalAxis,
    ScanAxis,
    UnknownAxis,
    WaveVectorAxis,
)
from abtem.core.backend import copy_to_device, cp, get_array_module, validate_device
from abtem.core.chunks import Chunks, chunk_ranges, equal_sized_chunks, validate_chunks
from abtem.core.complex import complex_exponential
from abtem.core.diagnostics import TqdmWrapper
from abtem.core.energy import Accelerator
from abtem.core.ensemble import Ensemble, _wrap_with_array
from abtem.core.fft import fft2, ifft2
from abtem.core.grid import Grid, GridUndefinedError, spatial_frequencies
from abtem.core.utils import (
    CopyMixin,
    EqualityMixin,
    ensure_list,
    expand_dims_to_broadcast,
    get_dtype,
    itemset,
    safe_ceiling_int,
    tuple_range,
)
from abtem.detectors import (
    AnnularDetector,
    BaseDetector,
    FlexibleAnnularDetector,
    PixelatedDetector,
    SegmentedDetector,
    WavesDetector,
    validate_detectors,
)
from abtem.measurements import BaseMeasurements
from abtem.multislice import allocate_multislice_measurements, multislice_and_detect
from abtem.potentials.iam import BasePotential, validate_potential
from abtem.prism.utils import batch_crop_2d, minimum_crop, plane_waves, wrapped_crop_2d
from abtem.scan import BaseScan, GridScan, validate_scan
from abtem.transfer import CTF
from abtem.waves import BaseWaves, Probe, Waves, _antialias_cutoff_gpts


def _extract_measurement(array, index):
    if array.size == 0:
        return array

    array = array.item()[index].array
    return array


def _wrap_measurements(measurements):
    return measurements[0] if len(measurements) == 1 else ComputableList(measurements)


def _finalize_lazy_measurements(
    arrays, waves, detectors, extra_ensemble_axes_metadata=None, chunks=None
):
    if extra_ensemble_axes_metadata is None:
        extra_ensemble_axes_metadata = []

    measurements = []
    for i, detector in enumerate(detectors):
        base_shape = detector._out_base_shape(waves)[0]

        if isinstance(detector, AnnularDetector):
            # TODO
            base_shape = ()

        meta = detector._out_meta(waves)[0]

        new_axis = tuple(range(len(arrays.shape), len(arrays.shape) + len(base_shape)))

        if chunks is None:
            chunks = arrays.chunks

        array = arrays.map_blocks(
            _extract_measurement,
            i,
            chunks=chunks + tuple((n,) for n in base_shape),
            new_axis=new_axis,
            meta=meta,
        )

        ensemble_axes_metadata = detector._out_ensemble_axes_metadata(waves)[0]

        base_axes_metadata = detector._out_base_axes_metadata(waves)[0]

        axes_metadata = ensemble_axes_metadata + base_axes_metadata

        metadata = detector._out_metadata(waves)[0]

        cls = detector._out_type(waves)[0]

        axes_metadata = extra_ensemble_axes_metadata + axes_metadata

        measurement = cls.from_array_and_metadata(
            array, axes_metadata=axes_metadata, metadata=metadata
        )

        if hasattr(measurement, "reduce_ensemble"):
            measurement = measurement.reduce_ensemble()

        measurements.append(measurement)

    return measurements


def _round_gpts_to_multiple_of_interpolation(
    gpts: tuple[int, int], interpolation: tuple[int, int]
) -> tuple[int, int]:
    return tuple(n + (-n) % f for f, n in zip(interpolation, gpts))  # noqa


[docs] class BaseSMatrix(BaseWaves): """Base class for scattering matrices.""" _device: str ensemble_axes_metadata: list[AxisMetadata] ensemble_shape: tuple[int, ...] _base_dims = 3 @property def device(self): """The device where the S-Matrix is created and reduced.""" return self._device @property def _xp(self): """The array module (numpy or cupy) for this S-matrix's device.""" return get_array_module(self._device) @property def _complex_dtype(self): """The complex dtype to use, honouring ``config['precision']``.""" return get_dtype(complex=True) # element budget (independent of dtype) for the plane-wave expansion and # compression row/pixel batching in CompressedSMatrixArray and SMatrix; # a fixed ceiling rather than a device-VRAM-aware limit (unlike # CompressedSMatrixArray._reduce_memory_budget, which is `inf` on the # host and would disable batching there entirely). _EXPANSION_BATCH_ELEMENTS = 256**3 # byte budget for the fixed intermediate-block ceilings that scale with # dtype size (see :meth:`_row_batch_size`), independently tuned from # `_EXPANSION_BATCH_ELEMENTS` above. _EXPANSION_BATCH_BYTES = 2**30 def _row_batch_size(self, elements_per_row: int, dtype, budget_bytes: int) -> int: """Rows of *elements_per_row* elements each (of *dtype*) that fit in *budget_bytes*.""" bytes_per_row = max(elements_per_row, 1) * np.dtype(dtype).itemsize return max(1, int(budget_bytes // bytes_per_row)) def _expansion_batch_size(self, elements_per_row: int) -> int: """Rows of *elements_per_row* elements each that fit in :attr:`_EXPANSION_BATCH_ELEMENTS`, independent of dtype.""" return max(1, int(self._EXPANSION_BATCH_ELEMENTS / max(elements_per_row, 1))) @property @abstractmethod def interpolation(self): """Interpolation factor in the `x` and `y` directions""" pass @property @abstractmethod def wave_vectors(self) -> np.ndarray: """The wave vectors corresponding to each plane wave.""" pass @property @abstractmethod def semiangle_cutoff(self) -> float: """The radial cutoff of the plane-wave expansion [mrad].""" pass @property @abstractmethod def window_extent(self): """The cropping window extent of the waves.""" pass @property @abstractmethod def window_gpts(self): """The number of grid points describing the cropping window of the wave functions.""" pass def __len__(self) -> int: return len(self.wave_vectors) @property def base_axes_metadata(self) -> list[AxisMetadata]: wave_axes_metadata = super().base_axes_metadata return [ WaveVectorAxis( label="q", values=tuple(tuple(value) for value in self.wave_vectors), ), wave_axes_metadata[0], wave_axes_metadata[1], ]
[docs] def dummy_probes( self, scan: BaseScan = None, ctf: CTF = None, plane: str = "entrance", downsample: bool = True, **kwargs, ) -> Probe: """ A probe or an ensemble of probes equivalent reducing the SMatrix at a single position. Parameters ---------- scan : BaseScan ctf : CTF plane : str Returns ------- dummy_probes : Probes """ if ctf is None: ctf = CTF(energy=self.energy, semiangle_cutoff=self.semiangle_cutoff) elif isinstance(ctf, dict): ctf = CTF(energy=self.energy, semiangle_cutoff=self.semiangle_cutoff, **ctf) elif isinstance(ctf, CTF): ctf = ctf.copy() else: raise ValueError() if plane == "exit": defocus = 0.0 if hasattr(self, "potential"): if self.potential is not None: defocus = self.potential.thickness elif "accumulated_defocus" in self.metadata: defocus = self.metadata["accumulated_defocus"] ctf.defocus = ctf.defocus - defocus if ctf.semiangle_cutoff is None or ctf.semiangle_cutoff == np.inf: ctf.semiangle_cutoff = self.semiangle_cutoff default_kwargs = {"device": self.device, "metadata": {**self.metadata}} kwargs = {**default_kwargs, **kwargs} if downsample: window_gpts = self.window_gpts else: window_gpts = ( safe_ceiling_int(self.gpts[0] / self.interpolation[0]), safe_ceiling_int(self.gpts[1] / self.interpolation[1]), ) probes = Probe._from_ctf( extent=self.window_extent, gpts=window_gpts, ctf=ctf, energy=self.energy, **kwargs, ) if scan is not None: probes.scan_positions = scan return probes
def _validate_interpolation(interpolation: int | tuple[int, int]): if isinstance(interpolation, int): interpolation = (interpolation,) * 2 elif not len(interpolation) == 2: raise ValueError("Interpolation factor must be an integer.") return tuple(interpolation) def _common_kwargs(a, b): a_kwargs = inspect.signature(a).parameters.keys() b_kwargs = inspect.signature(b).parameters.keys() return set(a_kwargs).intersection(b_kwargs) def _pack_wave_vectors(wave_vectors): return tuple( (float(wave_vector[0]), float(wave_vector[1])) for wave_vector in wave_vectors ) def _chunked_axis(s_matrix_array): window_margin = s_matrix_array._window_margin argsort = np.argsort( ( -s_matrix_array.gpts[0] // window_margin[0], -s_matrix_array.gpts[1] // window_margin[1], ) ) return int(argsort[0]), int(argsort[1]) def _chunks_for_multiple_rechunk_reduce(partitions): chunks_1 = () chunk_indices_1 = () for i in range(1, len(partitions) - 1, 3): chunks_1 += (sum(partitions[i - 1 : i + 2]),) chunk_indices_1 += (i - 1,) chunks_1 = chunks_1 + (sum(partitions[i + 2 :]),) assert sum(chunks_1) == sum(partitions) chunks_2 = (sum(partitions[:1]),) chunk_indices_2 = () for i in range(2, len(partitions) - 1, 3): chunks_2 += (sum(partitions[i - 1 : i + 2]),) chunk_indices_2 += (i - 1,) chunks_2 = chunks_2 + (sum(partitions[i + 2 :]),) assert sum(chunks_2) == sum(partitions) chunks_3 = (sum(partitions[:2]),) chunk_indices_3 = () for i in range(3, len(partitions) - 1, 3): chunks_3 += (sum(partitions[i - 1 : i + 2]),) chunk_indices_3 += (i - 1,) chunks_3 = chunks_3 + (sum(partitions[i + 2 :]),) assert sum(chunks_3) == sum(partitions) assert len(chunk_indices_1 + chunk_indices_2 + chunk_indices_3) == ( len(partitions) - 2 ) chunks = (chunks_1, chunks_2, chunks_3) chunk_indices = (chunk_indices_1, chunk_indices_2, chunk_indices_3) return chunks, chunk_indices def _lazy_reduce( array: np.ndarray, waves_partial: partial, ensemble_axes_metadata: list[AxisMetadata], from_waves_kwargs: dict, scan: BaseScan, ctf: CTF, detectors: list[BaseDetector], max_batch_reduction: int, pbar: bool = False, ) -> np.ndarray: args = (array, ensemble_axes_metadata) waves = waves_partial(args).item() s_matrix = SMatrixArray._from_waves(waves, **from_waves_kwargs) measurements = s_matrix._batch_reduce_to_measurements( scan, ctf, detectors, max_batch_reduction, pbar ) n = len(array.shape) - 3 + len(scan.shape) + len(ctf.ensemble_shape) arr = np.zeros((1,) * n, dtype=object) itemset(arr, 0, measurements) return arr def _map_blocks(array, scans, block_indices, window_offset=(0, 0), **kwargs): ctf_chunks = tuple((n,) for n in kwargs["ctf"].ensemble_shape) blocks = () for i, scan in zip(block_indices, scans): block = array.blocks[(slice(None),) * (len(array.shape) - 2) + i] new_chunks = array.chunks[:-3] + ctf_chunks + scan.shape kwargs["from_waves_kwargs"]["window_offset"] = ( window_offset[0] + sum(array.chunks[-2][: i[0]]), window_offset[1] + sum(array.chunks[-1][: i[1]]), ) if len(scan.shape) == 1: drop_axis = (len(array.shape) - 3, len(array.shape) - 1) elif len(scan.shape) == 2: drop_axis = (len(array.shape) - 3,) else: raise NotImplementedError drop_axis = (len(array.shape) - 3, len(array.shape) - 2, len(array.shape) - 1) new_axis = tuple( i for i in range( len(array.shape) - 3, len(array.shape) - 3 + len(scan.shape) + len(ctf_chunks), ) ) block = da.map_blocks( _lazy_reduce, block, scan=scan, drop_axis=drop_axis, new_axis=new_axis, chunks=new_chunks, **kwargs, meta=np.array((), dtype=np.complex64), ) if len(scan) == 0: block = da.zeros( (0,) * len(block.shape), dtype=np.complex64, ) blocks += (block,) return blocks def _tuple_from_index_value_pairs(*args): temp_list = [None] * (len(args) // 2) for arg1, arg2 in zip(args[::2], args[1::2]): temp_list[arg1] = arg2 return tuple(temp_list) def _multiple_rechunk_reduce( s_matrix_array: SMatrixArray, scan: BaseScan, detectors: list[BaseDetector], ctf: CTF, max_batch_reduction: int, pbar: bool = False, ): assert np.all(s_matrix_array.periodic) window_margin = s_matrix_array._window_margin chunked_axis, nochunks_axis = _chunked_axis(s_matrix_array) pad_amounts = _tuple_from_index_value_pairs( chunked_axis, (window_margin[chunked_axis],) * 2, nochunks_axis, (0, 0) ) s_matrix_array = s_matrix_array._pad(pad_amounts) chunk_size = window_margin[chunked_axis] size = s_matrix_array.shape[-2:][chunked_axis] - window_margin[chunked_axis] * 2 num_chunks = -(size // -chunk_size) partitions = _tuple_from_index_value_pairs( chunked_axis, (chunk_size,) * num_chunks, nochunks_axis, (s_matrix_array.shape[-2:][nochunks_axis],), ) chunk_extents = tuple( tuple(((cc[0]) * d, (cc[1]) * d) for cc in c) for c, d in zip(chunk_ranges(partitions), s_matrix_array.sampling) ) scan, scan_chunks = scan._sort_into_extents(chunk_extents) scans = [ (indices, scan.item()) for indices, _, scan in scan.generate_blocks(scan_chunks) ] partitions = (pad_amounts[chunked_axis][0],) + partitions[chunked_axis] partitions = partitions + ( s_matrix_array.shape[len(s_matrix_array.shape) - 2 + chunked_axis] - sum(partitions), ) ( (chunks_1, chunks_2, chunks_3), ( scan_indices_1, scan_indices_2, scan_indices_3, ), ) = _chunks_for_multiple_rechunk_reduce(partitions) chunks_1 = ( s_matrix_array.array.chunks[:-3] + (-1,) + _tuple_from_index_value_pairs(chunked_axis, chunks_1, nochunks_axis, -1) ) chunks_2 = ( s_matrix_array.array.chunks[:-3] + (-1,) + _tuple_from_index_value_pairs(chunked_axis, chunks_2, nochunks_axis, -1) ) chunks_3 = ( s_matrix_array.array.chunks[:-3] + (-1,) + _tuple_from_index_value_pairs(chunked_axis, chunks_3, nochunks_axis, -1) ) shape = tuple(len(c) for c in scan_chunks) blocks = np.zeros(shape, dtype=object) kwargs = { "waves_partial": s_matrix_array.waves._from_partitioned_args(), "ensemble_axes_metadata": s_matrix_array.waves.ensemble_axes_metadata, "from_waves_kwargs": s_matrix_array._copy_kwargs(exclude=("array", "extent")), "ctf": ctf, "detectors": detectors, "max_batch_reduction": max_batch_reduction, "pbar": pbar, } array = s_matrix_array.array.rechunk(chunks_1) window_offset = s_matrix_array.window_offset block_indices = [ _tuple_from_index_value_pairs(chunked_axis, i, nochunks_axis, 0) for i in range(len(scan_indices_1)) ] new_blocks = _map_blocks( array, [scans[i][1] for i in scan_indices_1], block_indices, window_offset=window_offset, **kwargs, ) for i, block in zip(scan_indices_1, new_blocks): itemset(blocks, scans[i][0], block) if s_matrix_array.ensemble_shape: fp_arrays = [] for i in np.ndindex(s_matrix_array.ensemble_shape): try: fp_new_blocks = tuple(block[i] for block in new_blocks) fp_array = wait_on(array[i], *fp_new_blocks)[0] fp_arrays.append(fp_array) except IndexError: fp_arrays.append(array[i]) array = da.stack(fp_arrays, axis=0) array = array.rechunk(chunks_2) block_indices = [ _tuple_from_index_value_pairs(chunked_axis, i, nochunks_axis, 0) for i in range(1, len(scan_indices_2) + 1) ] new_blocks = _map_blocks( array, [scans[i][1] for i in scan_indices_2], block_indices, window_offset=window_offset, **kwargs, ) for i, block in zip(scan_indices_2, new_blocks): itemset(blocks, scans[i][0], block) if s_matrix_array.ensemble_shape: fp_arrays = [] for i in np.ndindex(s_matrix_array.ensemble_shape): try: fp_new_blocks = tuple(block[i] for block in new_blocks) fp_array = wait_on(array[i], *fp_new_blocks)[0] fp_arrays.append(fp_array) except IndexError: fp_arrays.append(array[i]) array = da.stack(fp_arrays, axis=0) array = array.rechunk(chunks_3) block_indices = [ _tuple_from_index_value_pairs(chunked_axis, i, nochunks_axis, 0) for i in range(1, len(scan_indices_3) + 1) ] new_blocks = _map_blocks( array, [scans[i][1] for i in scan_indices_3], block_indices, window_offset=window_offset, **kwargs, ) for i, block in zip(scan_indices_3, new_blocks): itemset(blocks, scans[i][0], block) array = da.block(blocks.tolist()) dummy_probes = s_matrix_array.dummy_probes(scan=scan, ctf=ctf) measurements = _finalize_lazy_measurements( array, waves=dummy_probes, detectors=detectors, extra_ensemble_axes_metadata=s_matrix_array.ensemble_axes_metadata, ) return measurements def _single_rechunk_reduce( s_matrix_array: "SMatrixArray", scan: BaseScan, detectors: list[BaseDetector], ctf: CTF, max_batch_reduction: int, ): chunked_axis, nochunks_axis = _chunked_axis(s_matrix_array) num_chunks = ( s_matrix_array.gpts[chunked_axis] // s_matrix_array._window_margin[chunked_axis] ) chunks = equal_sized_chunks( s_matrix_array.shape[-2:][chunked_axis], num_chunks=num_chunks ) assert np.all(np.array(chunks) > s_matrix_array._window_margin[chunked_axis]) chunks = ( s_matrix_array.array.chunks[:-3] + (-1,) + _tuple_from_index_value_pairs(chunked_axis, chunks, nochunks_axis, -1) ) array = s_matrix_array._array.rechunk(chunks) assert all(s_matrix_array.periodic) # chunk_extents = tuple( # tuple(((cc[0]) * d, (cc[1]) * d) for cc in c) # for c, d in zip(chunk_ranges(array.chunks[-2:]), s_matrix_array.sampling) # ) chunk_extents_x = tuple( ((cc[0]) * s_matrix_array.sampling[0], (cc[1]) * s_matrix_array.sampling[0]) for cc in array.chunks[-2] ) chunk_extents_y = tuple( ((cc[0]) * s_matrix_array.sampling[1], (cc[1]) * s_matrix_array.sampling[1]) for cc in array.chunks[-1] ) chunk_extents = (chunk_extents_x, chunk_extents_y) scan, scan_chunks = scan._sort_into_extents(chunk_extents) ctf_chunks = tuple((n,) for n in ctf.ensemble_shape) chunks = array.chunks[:-3] + ctf_chunks shape = tuple(len(c) for c, p in zip(scan_chunks, s_matrix_array.periodic)) blocks = np.zeros((1,) * len(array.shape[:-3]) + shape, dtype=object) kwargs = { "waves_partial": s_matrix_array.waves._from_partitioned_args(), "ensemble_axes_metadata": s_matrix_array.waves.ensemble_axes_metadata, "from_waves_kwargs": s_matrix_array._copy_kwargs(exclude=("array", "extent")), "ctf": ctf, "detectors": detectors, "max_batch_reduction": max_batch_reduction, } for indices, _, sub_scan in scan.generate_blocks(scan_chunks): sub_scan = sub_scan.item() if len(sub_scan) == 0: itemset( blocks, (0,) * len(array.shape[:-3]) + indices, da.zeros( (0,) * len(blocks.shape), dtype=np.complex64, ), ) continue slics = (slice(None),) * (len(array.shape) - 2) window_offset = () for i, k in enumerate(indices): if len(array.chunks[-2:][i]) > 1: slics += ([k - 1, k, (k + 1) % len(array.chunks[-2:][i])],) window_offset += ( sum(array.chunks[-2:][i][:k]) - array.chunks[-2:][i][k - 1], ) else: slics += (slice(None),) window_offset += (0,) new_block = array.blocks[slics] new_block = new_block.rechunk(array.chunks[:-2] + (-1, -1)) new_chunks = chunks + sub_scan.shape kwargs["from_waves_kwargs"]["window_offset"] = tuple(window_offset) if len(scan.shape) == 1: drop_axis = (len(array.shape) - 3, len(array.shape) - 1) elif len(scan.shape) == 2: drop_axis = (len(array.shape) - 3,) else: raise NotImplementedError new_block = da.map_blocks( _lazy_reduce, new_block, scan=sub_scan, drop_axis=drop_axis, chunks=new_chunks, **kwargs, meta=np.array((), dtype=np.complex64), ) itemset(blocks, (0,) * len(array.shape[:-3]) + indices, new_block) array = da.block(blocks.tolist()) dummy_probes = s_matrix_array.dummy_probes(scan=scan, ctf=ctf) measurements = _finalize_lazy_measurements( array, waves=dummy_probes, detectors=detectors, extra_ensemble_axes_metadata=s_matrix_array.ensemble_axes_metadata, ) return measurements def _no_chunks_reduce( s_matrix_array: "SMatrixArray", scan: BaseScan, detectors: list[BaseDetector], ctf: CTF, max_batch_reduction: int = 1, pbar: bool = False, ): kwargs = { "waves_partial": s_matrix_array.waves._from_partitioned_args(), "ensemble_axes_metadata": s_matrix_array.waves.ensemble_axes_metadata, "from_waves_kwargs": s_matrix_array._copy_kwargs(exclude=("array", "extent")), "ctf": ctf, "detectors": detectors, "max_batch_reduction": max_batch_reduction, "pbar": pbar, } array = s_matrix_array.array ctf_chunks = tuple((n,) for n in ctf.ensemble_shape) chunks = array.chunks[:-3] + ctf_chunks + scan.shape drop_axis = (len(array.shape) - 3, len(array.shape) - 2, len(array.shape) - 1) new_axis = tuple( i for i in range( len(array.shape) - 3, len(array.shape) - 3 + len(scan.shape) + len(ctf_chunks), ) ) array = da.map_blocks( _lazy_reduce, array, scan=scan, drop_axis=drop_axis, new_axis=new_axis, chunks=chunks, **kwargs, meta=np.array((), dtype=np.complex64), ) dummy_probes = s_matrix_array.dummy_probes(scan=scan, ctf=ctf) measurements = _finalize_lazy_measurements( array, waves=dummy_probes, detectors=detectors, extra_ensemble_axes_metadata=s_matrix_array.ensemble_axes_metadata, ) return measurements
[docs] class SMatrixArray(BaseSMatrix, ArrayObject): """ A scattering matrix defined by a given array of dimension 3, where the first indexes the probe plane waves and the latter two are the `y` and `x` scan directions. Parameters ---------- array : numpy.ndarray Array defining the scattering matrix. Must be 3D or higher, dimensions before the last three dimensions should represent ensemble dimensions, the next dimension indexes the plane waves and the last two dimensions represent the spatial extent of the plane waves. wave_vectors : numpy.ndarray Array defining the wave vectors corresponding to each plane wave. Must have shape Nx2, where N is equal to the number of plane waves. semiangle_cutoff : float The radial cutoff of the plane-wave expansion [mrad]. energy : float Electron energy [eV]. sampling : one or two float, optional Lateral sampling of wave functions [Å]. Provide only if potential is not given. Will be ignored if 'gpts' is also provided. extent : one or two float, optional Lateral extent of wave functions [Å]. Provide only if potential is not given. interpolation : one or two int, optional Interpolation factor in the `x` and `y` directions (default is 1, ie. no interpolation). If a single value is provided, assumed to be the same for both directions. window_gpts : tuple of int The number of grid points describing the cropping window of the wave functions. window_offset : tuple of int The number of grid points from the origin the cropping windows of the wave functions is displaced. periodic: tuple of bool Specifies whether the SMatrix should be assumed to be periodic along the x and y-axis. device : str, optional The calculations will be carried out on this device ('cpu' or 'gpu'). Default is 'cpu'. The default is determined by the user configuration. ensemble_axes_metadata : list of AxesMetadata Axis metadata for each ensemble axis. The axis metadata must be compatible with the shape of the array. metadata : dict A dictionary defining wave function metadata. All items will be added to the metadata of measurements derived from the waves. """ def __init__( self, array: np.ndarray, wave_vectors: np.ndarray, semiangle_cutoff: float, energy: float = None, interpolation: int | tuple[int, int] = (1, 1), sampling: float | tuple[float, float] = None, extent: float | tuple[float, float] = None, window_gpts: tuple[int, int] = (0, 0), window_offset: tuple[int, int] = (0, 0), periodic: tuple[bool, bool] = (True, True), device: str = None, ensemble_axes_metadata: list[AxisMetadata] = None, metadata: dict = None, ): self._grid = Grid( extent=extent, gpts=array.shape[-2:], sampling=sampling, lock_gpts=True ) self._accelerator = Accelerator(energy=energy) self._wave_vectors = wave_vectors super().__init__( array=array, ensemble_axes_metadata=ensemble_axes_metadata, metadata=metadata, ) self._semiangle_cutoff = semiangle_cutoff self._window_gpts = tuple(window_gpts) self._window_offset = tuple(window_offset) self._interpolation = _validate_interpolation(interpolation) self._device = device self._periodic = periodic @classmethod def _pack_kwargs(cls, kwargs): kwargs["wave_vectors"] = _pack_wave_vectors(kwargs["wave_vectors"]) return super()._pack_kwargs(kwargs) @classmethod def _unpack_kwargs(cls, attrs): kwargs = super()._unpack_kwargs(attrs) kwargs["wave_vectors"] = np.array(kwargs["wave_vectors"], dtype=np.float32) return kwargs # kwargs["wave_vectors"] = _pack_wave_vectors(kwargs["wave_vectors"])
[docs] def copy_to_device(self, device: str) -> "SMatrixArray": """Copy SMatrixArray to specified device.""" s_matrix = super().copy_to_device(device) s_matrix._wave_vectors = copy_to_device(self._wave_vectors, device) return s_matrix
@staticmethod def _packed_wave_vectors(wave_vectors): return _pack_wave_vectors(wave_vectors)
[docs] def from_array_and_metadata(array, axes_metadata, metadata): raise NotImplementedError
@property def device(self): """The device on which the SMatrixArray is reduced.""" return self._device @property def storage_device(self): """The device on which the SMatrixArray is stored.""" return super().device @classmethod def _from_waves(cls, waves: Waves, **kwargs): common_kwargs = _common_kwargs(cls, Waves) kwargs.update({key: getattr(waves, key) for key in common_kwargs}) kwargs["ensemble_axes_metadata"] = kwargs["ensemble_axes_metadata"][:-1] return cls(**kwargs) @property def waves(self) -> Waves: """The wave vectors describing each plane wave.""" kwargs = { key: getattr(self, key) for key in _common_kwargs(self.__class__, Waves) } kwargs["ensemble_axes_metadata"] = ( kwargs["ensemble_axes_metadata"] + self.base_axes_metadata[:-2] ) return Waves(**kwargs) def _copy_with_new_waves(self, waves): keys = set( inspect.signature(self.__class__).parameters.keys() ) - _common_kwargs(self.__class__, Waves) kwargs = {key: getattr(self, key) for key in keys} return self._from_waves(waves, **kwargs) @property def periodic(self) -> tuple[bool, bool]: """If True the SMatrix is assumed to be periodic along corresponding axis.""" return self._periodic @property def metadata(self) -> dict: self._metadata["energy"] = self.energy return self._metadata @property def ensemble_axes_metadata(self) -> list[AxisMetadata]: """Axis metadata for each ensemble axis.""" return self._ensemble_axes_metadata @property def ensemble_shape(self) -> tuple[int, int]: return self.array.shape[:-3] @property def interpolation(self) -> tuple[int, int]: return self._interpolation
[docs] def rechunk(self, chunks: Chunks, in_place: bool = True): array = self.array.rechunk(chunks) if in_place: self._array = array return self else: kwargs = self._copy_kwargs(exclude=("array",)) return self.__class__(array, **kwargs)
@property def semiangle_cutoff(self) -> float: """The cutoff semiangle of the plane wave expansion.""" return self._semiangle_cutoff @property def wave_vectors(self) -> np.ndarray: return self._wave_vectors @property def window_gpts(self) -> tuple[int, int]: return self._window_gpts @property def window_extent(self) -> tuple[float, float]: return ( self.window_gpts[0] * self.sampling[0], self.window_gpts[1] * self.sampling[1], ) @property def window_offset(self) -> tuple[float, float]: """The number of grid points from the origin the cropping windows of the wave functions is displaced.""" return self._window_offset
[docs] def multislice(self, potential: BasePotential = None) -> "SMatrixArray": """ Parameters ---------- potential : Returns ------- """ waves = self.waves.multislice(potential) return self._copy_with_new_waves(waves)
def _reduce_to_waves( self, array, positions, position_coefficients, ): xp = self._xp if self._device == "gpu" and isinstance(array, np.ndarray): array = xp.asarray(array) position_coefficients = xp.array( position_coefficients, dtype=get_dtype(complex=True) ) if self.window_gpts != self.gpts: pixel_positions = positions / xp.array(self.waves.sampling) - xp.asarray( self.window_offset ) crop_corner, size, corners = minimum_crop(pixel_positions, self.window_gpts) array = wrapped_crop_2d(array, crop_corner, size) array = xp.tensordot(position_coefficients, array, axes=[-1, -3]) if len(self.waves.shape) > 3: array = xp.moveaxis(array, -3, 0) array = batch_crop_2d(array, corners, self.window_gpts) else: array = xp.tensordot(position_coefficients, array, axes=[-1, -3]) if len(self.waves.shape) > 3: array = xp.moveaxis(array, -3, 0) return array def _calculate_positions_coefficients(self, scan): xp = get_array_module(self.wave_vectors) if isinstance(scan, GridScan): x = xp.asarray(scan._x_coordinates()) y = xp.asarray(scan._y_coordinates()) coefficients = complex_exponential( -2.0 * xp.pi * x[:, None, None] * self.wave_vectors[None, None, :, 0] ) * complex_exponential( -2.0 * xp.pi * y[None, :, None] * self.wave_vectors[None, None, :, 1] ) else: positions = xp.asarray(scan.get_positions()) coefficients = complex_exponential( -2.0 * xp.pi * positions[..., 0, None] * self.wave_vectors[:, 0][None] - 2.0 * xp.pi * positions[..., 1, None] * self.wave_vectors[:, 1][None] ) return coefficients def _calculate_ctf_coefficients(self, ctf): wave_vectors = self.wave_vectors xp = get_array_module(wave_vectors) alpha = ( xp.sqrt(wave_vectors[:, 0] ** 2 + wave_vectors[:, 1] ** 2) * ctf.wavelength ) phi = xp.arctan2(wave_vectors[:, 1], wave_vectors[:, 0]) array = ctf._evaluate_from_angular_grid(alpha, phi) # the coefficients may be complex when the ctf includes aberrations, hence # the normalization must use the absolute square array = array / xp.sqrt((xp.abs(array) ** 2).sum(axis=-1, keepdims=True)) return array def _batch_reduce_to_measurements( self, scan: BaseScan, ctf: CTF, detectors: list[BaseDetector], max_batch_reduction: int, pbar: bool = False, ) -> tuple[BaseMeasurements | Waves, ...]: dummy_probes = self.dummy_probes(scan=scan, ctf=ctf) measurements = allocate_multislice_measurements( dummy_probes, detectors, extra_ensemble_axes_shape=self.waves.ensemble_shape[:-1], extra_ensemble_axes_metadata=self.waves.ensemble_axes_metadata[:-1], ) xp = self._xp if self._device == "gpu" and isinstance(self.waves.array, np.ndarray): array = cp.asarray(self.waves.array) else: array = self.waves.array n_positions = int(np.prod(scan.shape + ctf.ensemble_shape)) pbar = TqdmWrapper(enabled=pbar, total=n_positions, leave=False, desc="reduce") for _, ctf_slics, sub_ctf in ctf.generate_blocks(1): sub_ctf = sub_ctf.item() ctf_coefficients = self._calculate_ctf_coefficients(sub_ctf) for _, slics, sub_scan in scan.generate_blocks(max_batch_reduction): sub_scan = sub_scan.item() positions = xp.asarray(sub_scan.get_positions()) positions_coefficients = self._calculate_positions_coefficients( sub_scan ) if ctf_coefficients is not None: ( expanded_ctf_coefficients, positions_coefficients, ) = expand_dims_to_broadcast( ctf_coefficients, positions_coefficients, match_dims=[(-1,), (-1,)], ) coefficients = positions_coefficients * expanded_ctf_coefficients else: coefficients = positions_coefficients ensemble_shape = len(array.shape[:-3]) + len(sub_ctf.ensemble_shape) ensemble_axes_metadata = [] ensemble_axes_metadata.extend( [UnknownAxis() for _ in range(ensemble_shape)] ) ensemble_axes_metadata.extend( [ScanAxis() for _ in range(len(scan.shape))] ) waves_array = self._reduce_to_waves(array, positions, coefficients) waves = Waves( waves_array, sampling=self.sampling, energy=self.energy, ensemble_axes_metadata=ensemble_axes_metadata, metadata=self.metadata, ) indices = ( (slice(None),) * (len(self.waves.shape) - 3) + ctf_slics + slics ) pbar.update_if_exists(len(sub_scan)) for detector, measurement in zip(detectors, measurements): measurement.array[indices] = detector.detect(waves).array pbar.close_if_exists() return tuple(measurements) @property def _window_margin(self): return -(self.window_gpts[0] // -2), -(self.window_gpts[1] // -2) def _pad(self, pad_width): array = self.array pad_width = ((0,) * 2,) * len(array.shape[:-2]) + tuple(pad_width) pad_amounts = sum(pad_width[-2]), sum(pad_width[-1]) pad_chunks = array.chunks[:-2] + ( array.shape[-2] + pad_amounts[-2], array.shape[-1] + pad_amounts[-1], ) array = array.map_blocks( np.pad, pad_width=pad_width, meta=array._meta, chunks=pad_chunks, mode="wrap", ) kwargs = self._copy_kwargs(exclude=("array", "extent")) kwargs["periodic"] = tuple( False if pad_amount else periodic for periodic, pad_amount in zip(kwargs["periodic"], pad_amounts) ) kwargs["window_offset"] = tuple( window_offset - pad_amount[0] for window_offset, pad_amount in zip( kwargs["window_offset"], pad_width[-2:] ) ) return self.__class__(array, **kwargs) def _chunks_for_reduction(self): chunks = ( -(self.gpts[0] // -(self.interpolation[0] * 2)), -(self.gpts[1] // -(self.interpolation[1] * 2)), ) num_chunks = self.gpts[0] // chunks[0], self.gpts[1] // chunks[1] if num_chunks[1] > num_chunks[0]: num_chunks = (1, num_chunks[1]) else: num_chunks = (num_chunks[0], 1) chunks = tuple( equal_sized_chunks(n, num_chunks=nsc) for n, nsc in zip(self.shape[-2:], num_chunks) ) if chunks is None: chunks = self.array.chunks[-2:] else: chunks = validate_chunks(self.shape[-2:], chunks) return chunks def _validate_max_batch_reduction( self, scan, max_batch_reduction: int | str = "auto" ): shape = (len(scan),) + self.window_gpts chunks = (max_batch_reduction, -1, -1) return validate_chunks(shape, chunks, dtype=np.dtype("complex64"))[0][0] def _validate_reduction_scheme(self, reduction_scheme): if self.interpolation == (1, 1) and reduction_scheme == "no-chunks": raise NotImplementedError if reduction_scheme == "auto" and max(self.interpolation) <= 2: return "no-chunks" elif reduction_scheme == "auto": return "multiple-rechunk" return reduction_scheme
[docs] def reduce( self, scan: BaseScan = None, ctf: CTF = None, detectors: BaseDetector | list[BaseDetector] = None, max_batch_reduction: int | str = "auto", reduction_scheme: str = "auto", ) -> BaseMeasurements | Waves | list[BaseMeasurements | Waves]: """ Scan the probe across the potential and record a measurement for each detector. Parameters ---------- detectors : list of Detector objects The detectors recording the measurements. scan : Scan object Scan defining the positions of the probe wave functions. ctf: CTF object, optional The probe contrast transfer function. Default is None (aperture is set by the planewave cutoff). max_batch_reduction : int or str, optional Number of positions per reduction operation. A large number of positions better utilize thread parallelization, but requires more memory and floating point operations. If 'auto' (default), the batch size is automatically chosen based on the abtem user configuration settings "dask.chunk-size" and "dask.chunk-size-gpu". rechunk : two int or str, optional Partitioning of the scan. The scattering matrix will be reduced in similarly partitioned chunks. Should be equal to or greater than the interpolation. """ self.accelerator.check_is_defined() if ctf is None: ctf = CTF(semiangle_cutoff=self.semiangle_cutoff) ctf.grid.match(self.dummy_probes()) ctf.accelerator.match(self) if ctf.semiangle_cutoff == np.inf: ctf.semiangle_cutoff = self.semiangle_cutoff if not isinstance(scan, BaseScan): squeeze = (-3,) else: squeeze = () if scan is None: scan = self.extent[0] / 2, self.extent[1] / 2 scan = validate_scan( scan, Probe._from_ctf(extent=self.extent, ctf=ctf, energy=self.energy) ) detectors = detectors = validate_detectors( detectors, self.dummy_probes(downsample=False) ) max_batch_reduction = self._validate_max_batch_reduction( scan, max_batch_reduction ) reduction_scheme = self._validate_reduction_scheme(reduction_scheme) pbar = config.get("diagnostics.task_progress", False) if self.is_lazy: measurements = _no_chunks_reduce( self, scan, detectors, ctf, max_batch_reduction, pbar=pbar ) # if reduction_scheme == "multiple-rechunk": # measurements = _multiple_rechunk_reduce( # self, scan, detectors, ctf, max_batch_reduction, pbar=pbar # ) # elif reduction_scheme == "single-rechunk": # raise NotImplementedError # measurements = _single_rechunk_reduce( # self, scan, detectors, ctf, max_batch_reduction # ) # elif reduction_scheme == "no-chunks": # else: # raise ValueError() else: measurements = self._batch_reduce_to_measurements( scan, ctf, detectors, max_batch_reduction, pbar=pbar ) measurements = [measurement.squeeze(squeeze) for measurement in measurements] out = _wrap_measurements(measurements) return out
[docs] def scan( self, scan: BaseScan = None, detectors: BaseDetector | list[BaseDetector] = None, ctf: CTF = None, max_batch_reduction: int | str = "auto", rechunk: tuple[int, int] | str = "auto", ): """ Reduce the SMatrix using coefficients calculated by a BaseScan and a CTF, to obtain the exit wave functions at given initial probe positions and aberrations. Parameters ---------- scan : BaseScan Positions of the probe wave functions. If not given, scans across the entire potential at Nyquist sampling. detectors : BaseDetector, list of BaseDetector, optional A detector or a list of detectors defining how the wave functions should be converted to measurements after running the multislice algorithm. See abtem.measurements.detect for a list of implemented detectors. ctf : CTF Contrast transfer function from used for calculating the expansion coefficients in the reduction of the SMatrix. max_batch_reduction : int or str, optional Number of positions per reduction operation. A large number of positions better utilize thread parallelization, but requires more memory and floating point operations. If 'auto' (default), the batch size is automatically chosen based on the abtem user configuration settings "dask.chunk-size" and "dask.chunk-size-gpu". rechunk : str or tuple of int, optional Parallel reduction of the SMatrix requires rechunking the Dask array from chunking along the expansion axis to chunking over the spatial axes. If given as a tuple of int of length the SMatrix is rechunked to have those chunks. If 'auto' (default) the chunks are taken to be identical to the interpolation factor. Returns ------- detected_waves : BaseMeasurements or list of BaseMeasurements The detected measurement (if detector(s) given). exit_waves : Waves Wave functions at the exit plane(s) of the potential (if no detector(s) given). """ if scan is None: scan = GridScan() if detectors is None: detectors = [FlexibleAnnularDetector()] return self.reduce( scan=scan, ctf=ctf, detectors=detectors, max_batch_reduction=max_batch_reduction, reduction_scheme=rechunk, )
# the coarse plane-wave expansion of the upsampled (C-PRISM) scattering matrix is # built on a disk around the aperture: every beam within this normalized radius # (unity at the aperture edge) of the aperture, or one coarse cell, whichever is # larger. The margin beams anchor the interpolation of the aperture-edge beams: # on the Ge benchmark cell 0.3 improves the single-probe pattern over no margin # by 30% inside the disk and 10-13% at and beyond its edge, for ~20% more # multislice runs; band-integrated scan errors are insensitive to it. The far # corners of the bounding rectangle are dropped either way. _COARSE_SUPPORT_MARGIN = 0.3 def _dense_wave_vector_indices( extent: tuple[float, float], gpts: tuple[int, int], energy: float, semiangle_cutoff: float, ) -> np.ndarray: """Integer Fourier-space indices of all plane waves inside the aperture at interpolation (1, 1), including the soft edge.""" probe = Probe._from_ctf( extent=extent, gpts=gpts, ctf=CTF(energy=energy, semiangle_cutoff=semiangle_cutoff), energy=energy, device="cpu", ) aperture = probe.aperture._evaluate_kernel(probe) indices = np.where(aperture > 0.0) n = np.fft.fftfreq(aperture.shape[0], d=1 / aperture.shape[0])[indices[0]] m = np.fft.fftfreq(aperture.shape[1], d=1 / aperture.shape[1])[indices[1]] return np.stack([n, m], axis=-1).astype(int) class _FullGridPixelatedDetector(PixelatedDetector): """Pixelated detection on a fixed real-space grid. Window-reduced wave functions are zero-padded to the full simulation grid before the diffraction patterns are computed, so the patterns of differently windowed reductions share one angular grid (the intensity of a diffraction pattern does not depend on where the window sits in the padded frame). This is what lets the two branches of a blended reduction be detected separately and their intensities added per pixel. """ def __init__(self, detector: PixelatedDetector, gpts: tuple[int, int]): if not detector.reciprocal_space: raise ValueError( "padding pixelated detection to the full grid requires " "reciprocal-space output" ) self._pad_gpts = tuple(int(n) for n in gpts) super().__init__( max_angle=detector.max_angle, resample=detector.resample, reciprocal_space=detector.reciprocal_space, to_cpu=detector.to_cpu, url=detector.url, ) def _padded(self, waves): if tuple(waves.gpts) == self._pad_gpts: return waves if isinstance(waves, Waves): xp = get_array_module(waves.array) array = xp.zeros( waves.array.shape[:-2] + self._pad_gpts, dtype=waves.array.dtype ) # a unit probe carries a real-space power of 1 / grid size, so the # window field is rescaled to the convention of the padded grid scale = np.sqrt( np.prod(waves.gpts) / np.prod(self._pad_gpts) ).astype(get_dtype(complex=False)) array[..., : waves.gpts[0], : waves.gpts[1]] = waves.array * scale return Waves( array, sampling=tuple(waves._valid_sampling), energy=waves.energy, ensemble_axes_metadata=waves.ensemble_axes_metadata, metadata=waves.metadata, ) # padding extends the extent at fixed sampling — setting only the gpts # would instead refine the window and mislabel the angular sampling waves = waves.copy() waves.extent = tuple( s * n for s, n in zip(waves._valid_sampling, self._pad_gpts) ) waves.gpts = self._pad_gpts return waves def angular_limits(self, waves): return super().angular_limits(self._padded(waves)) def _new_sampling_and_gpts(self, waves): return super()._new_sampling_and_gpts(self._padded(waves)) def _calculate_new_array(self, waves): return super()._calculate_new_array(self._padded(waves))
[docs] class CompressedSMatrixArray(BaseSMatrix, CopyMixin, EqualityMixin): """ A compressed scattering matrix defined by its truncated singular value decomposition, returned by :meth:`.SMatrix.build` when ``upsample=True`` (the C-PRISM algorithm). The coarse phase-removed scattering matrix is interpolated to the plane waves of the aperture at interpolation (1, 1) and factored as :math:`T \\approx U \\Sigma V^H`, where the left singular vectors :math:`U` are real-space images and the right singular vectors hold the plane-wave coefficients of each mode. Parameters ---------- u : numpy.ndarray Left singular vectors of the phase-removed scattering matrix of shape (K, gpts_x, gpts_y), where K is the number of retained modes. sigma : numpy.ndarray Retained singular values of shape (K,). vh_dense : numpy.ndarray Right singular vectors interpolated to the dense plane-wave expansion of shape (K, number of dense plane waves). dense_indices : numpy.ndarray Integer Fourier-space indices of the dense plane waves of shape (N, 2). semiangle_cutoff : float The radial cutoff of the plane-wave expansion [mrad]. energy : float Electron energy [eV]. extent : two float Lateral extent of the scattering matrix [Å]. interpolation : two int Interpolation factor used for the coarse plane-wave expansion. window_gpts : two int The number of grid points describing the cropping window of the reduced wave functions. position_quantization : int, optional If given, the fractional part of the probe positions is quantized to this number of fractions of a pixel. The default is None, ie. the positions are not quantized. device : str The device used for the reduction ('cpu' or 'gpu'). metadata : dict A dictionary defining wave function metadata. """ def __init__( self, u: np.ndarray, sigma: np.ndarray, vh_dense: np.ndarray, dense_indices: np.ndarray, semiangle_cutoff: float, energy: float, extent: tuple[float, float], interpolation: tuple[int, int], window_gpts: tuple[int, int], position_quantization: int = None, max_batch_expansion: int | str = "auto", blend_angle: float = None, device: str = None, metadata: dict = None, singular_values: np.ndarray = None, reference_depth: float = 0.0, ): self._u = u self._sigma = sigma self._vh_dense = vh_dense self._dense_indices = dense_indices self._singular_values = singular_values self._max_batch_expansion = max_batch_expansion self._blend_angle = blend_angle self._reference_depth = float(reference_depth) self._grid = Grid(extent=extent, gpts=u.shape[-2:], lock_gpts=True) self._accelerator = Accelerator(energy=energy) self._semiangle_cutoff = semiangle_cutoff self._interpolation = interpolation self._window_gpts = tuple(window_gpts) self._position_quantization = position_quantization self._device = validate_device(device) self._metadata = {} if metadata is None else metadata @staticmethod def _detects_the_wave(detectors) -> bool: """Whether any detector reads the wave rather than its diffracted intensity. The beams are referenced to a depth inside the specimen (see :meth:`SMatrix._reference_depth`), so the reduced wave is the exit wave propagated to that depth. A reciprocal-space intensity does not see the difference — the reference is a phase in ``q`` — but a wave function or a real-space intensity does, and is propagated back to the exit surface first. """ for detector in ensure_list(detectors): if isinstance(detector, WavesDetector): return True if isinstance(detector, PixelatedDetector) and not ( detector.reciprocal_space ): return True return False def _to_exit_reference(self, array): """Propagate reduced waves from the reference depth to the exit surface.""" if self._reference_depth == 0.0: return array xp = get_array_module(array) gpts = array.shape[-2:] sampling = tuple(e / n for e, n in zip(self.extent, self.gpts)) kx, ky = spatial_frequencies(gpts, sampling, xp=xp) propagator = complex_exponential( -np.pi * self.wavelength * self._reference_depth * (kx[:, None] ** 2 + ky[None] ** 2) ).astype(get_dtype(complex=True)) return ifft2(fft2(array) * propagator) @property def ensemble_axes_metadata(self) -> list[AxisMetadata]: return [] @property def ensemble_shape(self) -> tuple[int, ...]: return () @property def u(self) -> np.ndarray: """Left singular vectors of shape (K, gpts_x, gpts_y).""" return self._u @property def sigma(self) -> np.ndarray: """Retained singular values.""" return self._sigma @property def vh_dense(self) -> np.ndarray: """Right singular vectors at the dense plane-wave expansion.""" return self._vh_dense @property def rank(self) -> int: """Number of retained modes. At least the number of built beams — their row space is retained whole so that the plane-wave branch of the reduction is the PRISM algorithm exactly — unless ``max_rank`` was given. The stored modes and the reduction both scale with it.""" return len(self._sigma) @property def singular_values(self) -> np.ndarray: """The full singular-value spectrum of the interpolated operator, including the modes truncated by ``tolerance`` and ``max_rank``. Useful for choosing either: where the spectrum falls off is the intrinsic dimensionality of the specimen, and the rank may be cut towards it for proportional savings in memory and reduction time. The retained part is :attr:`sigma`.""" if self._singular_values is None: return self._sigma return self._singular_values @property def max_batch_expansion(self) -> int | str: """Number of plane waves expanded at a time by the full-window reduction; 'auto' materializes the full expansion.""" return self._max_batch_expansion @property def blend_angle(self) -> float | None: """Scattering angle [mrad] above which the reduction follows the plane-wave (PRISM) reduction of the built beams (None: no blending).""" return self._blend_angle @property def metadata(self) -> dict: self._metadata["energy"] = self.energy return self._metadata @property def interpolation(self) -> tuple[int, int]: return self._interpolation @property def semiangle_cutoff(self) -> float: return self._semiangle_cutoff @property def wave_vectors(self) -> np.ndarray: """The wave vectors of the dense plane-wave expansion.""" extent = self.extent dtype = get_dtype(complex=False) wave_vectors = self._dense_indices.astype(dtype) wave_vectors[:, 0] /= dtype(extent[0]) wave_vectors[:, 1] /= dtype(extent[1]) return wave_vectors @property def window_gpts(self) -> tuple[int, int]: return self._window_gpts @property def window_extent(self) -> tuple[float, float]: return ( self.window_gpts[0] * self.sampling[0], self.window_gpts[1] * self.sampling[1], ) def _calculate_ctf_coefficients(self, ctf: CTF): xp = self._xp wave_vectors = xp.asarray(self.wave_vectors) alpha = ( xp.sqrt(wave_vectors[:, 0] ** 2 + wave_vectors[:, 1] ** 2) * ctf.wavelength ) phi = xp.arctan2(wave_vectors[:, 1], wave_vectors[:, 0]) array = ctf._evaluate_from_angular_grid(alpha, phi) array = array / xp.sqrt((xp.abs(array) ** 2).sum(axis=-1, keepdims=True)) return array def _coefficient_values(self, coefficients): """The dense plane-wave amplitudes of each mode of the expansion.""" xp = self._xp dtype = self._complex_dtype values = xp.asarray(self._sigma[:, None] * self._vh_dense, dtype=dtype) return values * xp.asarray(coefficients, dtype=dtype)[None] def _lattice_coefficients(self, coefficients): """CTF coefficients restricted to the built (coarse lattice) plane waves. The trigonometric interpolation is interpolatory — the reconstruction at the lattice plane waves equals the built scattering matrix — hence reducing with the restricted (and renormalized) coefficients yields the wave functions of the PRISM algorithm from the same compressed factors. """ xp = self._xp indices = np.asarray(self._dense_indices) mask = (indices[:, 0] % self._interpolation[0] == 0) & ( indices[:, 1] % self._interpolation[1] == 0 ) restricted = coefficients * xp.asarray(mask) return restricted / xp.sqrt( (xp.abs(restricted) ** 2).sum(axis=-1, keepdims=True) ) def _blend_weight(self, blend_angle, window: tuple[int, int], taper=None): """Radial Fourier-space weight switching from the interpolated (C-PRISM) wave below ``blend_angle`` to the plane-wave (PRISM) wave above it, with a smooth cosine taper. ``blend_angle='aperture'`` uses the amplitude of the probe-forming aperture as the weight instead: the interpolated wave inside the bright-field disk, the plane-wave reduction outside it, with the soft aperture edge as the transition. """ xp = self._xp if isinstance(blend_angle, str): if blend_angle != "aperture": raise ValueError( f"blend_angle must be a number, 'aperture' or None; " f"got {blend_angle!r}" ) probe = self.dummy_probes() probe.grid.check_is_defined() # the dummy probe is built on this object's device, so the kernel # is a device array and must stay in its own array module: numpy # refuses to convert one implicitly kernel = xp.abs(probe.aperture._evaluate_kernel(probe)) if tuple(kernel.shape) != tuple(window): raise RuntimeError( "aperture weight does not match the reduction window" ) kernel = kernel / kernel.max() return kernel.astype(get_dtype(complex=False)) wavelength = self.wavelength qx = np.fft.fftfreq(window[0], d=self.sampling[0]) qy = np.fft.fftfreq(window[1], d=self.sampling[1]) angle = ( np.sqrt(qx[:, None] ** 2 + qy[None, :] ** 2) * wavelength * 1e3 ) # mrad # the blend angle is an upper bound on the validity of the # interpolation, hence the taper ends AT it rather than straddling it if taper is None: taper = max(4.0, 0.2 * blend_angle) if taper <= 0.0: # a sharp cut, used when the blend angle has been snapped to a # detector boundary: a taper would reach back into the band below # and mix the branches inside it weight = (angle < blend_angle).astype(np.float64) else: weight = 0.5 * ( 1.0 + np.cos(np.pi * (angle - blend_angle + taper) / taper) ) weight[angle <= blend_angle - taper] = 1.0 weight[angle >= blend_angle] = 0.0 return xp.asarray(weight, dtype=get_dtype(complex=False)) @staticmethod def _snapped_blend_angle(blend_angle, detectors): """The blend angle lowered to a detector collection boundary. Above the blend angle the reduction is the plane-wave branch alone, which is the PRISM algorithm exactly; below it the interpolated branch takes over. A detector whose collection range straddles the blend angle therefore mixes the two, and is the only way the blended reduction can come out worse than PRISM on a band. Snapping the angle down to the nearest boundary leaves every band wholly on one side: the bands above are PRISM, the bands below are the interpolated reduction. """ if blend_angle is None or isinstance(blend_angle, str): return blend_angle if detectors is None: return blend_angle if not isinstance(detectors, (list, tuple)): detectors = [detectors] bounds = set() for detector in detectors: for name in ("inner", "outer"): value = getattr(detector, name, None) if value is not None and np.isfinite(value): bounds.add(float(value)) tolerance = blend_angle * (1.0 + 1e-6) + 1e-9 below = [value for value in bounds if 0.0 < value <= tolerance] return max(below) if below else blend_angle @staticmethod def _blend_branches(blend_angle, blend_component): """Which of the two blended reductions the result actually needs. Selecting a component keeps a single branch, so the other one — and the window kernel it would be reduced with — is never evaluated. """ return ( blend_component != "high", blend_angle is not None and blend_component != "low", ) def _blend_wave_batches(self, interpolated, plane_wave, weight, component=None): """Combine the two reductions in Fourier space with the radial weight. ``component='low'`` returns the interpolated branch alone weighted by ``sqrt(weight)``, ``component='high'`` the plane-wave branch alone weighted by ``sqrt(1 - weight)``: detecting the two and summing the measurements blends the intensities instead of the amplitudes, which permits a different reduction window per branch. The branch a component discards may be given as ``None`` (see :meth:`_blend_branches`). """ if component == "low": interpolated = fft2(interpolated, overwrite_x=True) interpolated *= np.sqrt(weight)[None] return ifft2(interpolated, overwrite_x=True) if component == "high": plane_wave = fft2(plane_wave, overwrite_x=True) plane_wave *= np.sqrt(1.0 - weight)[None] return ifft2(plane_wave, overwrite_x=True) interpolated = fft2(interpolated, overwrite_x=True) plane_wave = fft2(plane_wave, overwrite_x=True) interpolated *= weight[None] interpolated += plane_wave * (1.0 - weight)[None] return ifft2(interpolated, overwrite_x=True) def _window_kernel(self, values, fractional_offset, center: bool = True): """The window kernels :math:`B_k` obtained by reducing the dense plane waves for a probe displaced by a fraction of a pixel. With ``center=True`` (default) the kernel is cropped to the reduction window with the probe at its center; with ``center=False`` the kernel is returned on the full grid indexed by the displacement from the probe (used by the full-window mode reduction, which keeps the absolute frame). """ xp = self._xp dtype = self._complex_dtype gpts = self.gpts window_gpts = self.window_gpts if np.any(np.abs(fractional_offset) > 1e-9): offset = xp.asarray(fractional_offset * np.array(self.sampling)) wave_vectors = xp.asarray(self.wave_vectors) values = values * complex_exponential( -2.0 * xp.pi * (wave_vectors[:, 0] * offset[0] + wave_vectors[:, 1] * offset[1]) )[None].astype(dtype) indices = xp.asarray(self._dense_indices) scattered = xp.zeros((self.rank,) + gpts, dtype=dtype) scattered[:, indices[:, 0] % gpts[0], indices[:, 1] % gpts[1]] = values # The normalization of the wave functions scales with the number of grid # points of the cropping window, such that the intensity of each reduced # probe is unity. normalization = np.prod(gpts) * np.sqrt(np.prod(gpts) / np.prod(window_gpts)) kernel = ifft2(scattered, overwrite_x=True) kernel *= get_dtype(complex=False)(normalization) if not center: return kernel ix = (xp.arange(window_gpts[0]) - window_gpts[0] // 2) % gpts[0] iy = (xp.arange(window_gpts[1]) - window_gpts[1] // 2) % gpts[1] return kernel[:, ix[:, None], iy[None, :]] # memory budget of one gathered block in the batched mode contractions; # the modes are chunked so several probe positions fit in every batch even # at large ranks, keeping the device operations big and few. _REDUCE_BATCH_BYTES = 2**30 _REDUCE_MODE_CHUNK = 64 # the lattice reduction re-gathers a halo of window / step scan rows per # row block, hence the device rows blocks are several times larger _REDUCE_GPU_ROW_BLOCK_FACTOR = 8 # ... but never larger than the device can hold: detecting a block also # holds its Fourier transform, the transform work area and the detected # intensity, so the peak is a few times the block itself _REDUCE_GPU_MEMORY_FRACTION = 0.5 def _reduce_memory_budget(self): """Bytes that one reduced block of wave functions may occupy. Unbounded on the host; on the device a fraction of the memory that is actually free, counting the blocks the memory pool holds but is not using. """ if self._device != "gpu" or cp is None: return np.inf free = cp.cuda.Device().mem_info[0] + cp.get_default_memory_pool().free_bytes() return int(free * self._REDUCE_GPU_MEMORY_FRACTION) def _contract_modes_batched(self, fields, flat_indices, kernel, gather_kernel): """Contract the modes for a batch of probe positions: for every position ``p`` and window pixel ``w``, ``sum_k gathered[k, p, w] * fixed[k, w]``, where the gathered operand is indexed by ``flat_indices[p, w]``. ``gather_kernel`` selects which operand is gathered per position: the windows of the left singular vectors (windowed reduction) or the displaced kernel (full-window reduction). The contraction is chunked over the modes first — so the gathered blocks stay within the memory budget at several positions per batch — and accumulated. BOTH OPERANDS ARE MODES-FIRST, ``(K, pixels)``, and that is the whole point. A modes-last operand makes ``[..., k_start:k_stop]`` a strided view, so every mode chunk needs ``ascontiguousarray`` — and getting the operands into modes-last order in the first place cost a full contiguous transpose of each, K * gpts^2 * 8 bytes apiece. On Pt/C that was 12.2 GB per array at f=8 and 41 GB at f=4, held simultaneously with the originals, and it was what put C-PRISM f=8 out of reach of a 46 GB card and f=4 out of reach of everything. Modes-first slicing is already contiguous, so the chunks are free views and no transpose is needed anywhere. """ xp = self._xp dtype = self._complex_dtype num_positions = flat_indices.shape[0] out_shape = flat_indices.shape[1:] num_pixels = int(np.prod(out_shape)) num_modes = kernel.shape[0] flat_indices = flat_indices.reshape(num_positions, num_pixels) fields = fields.reshape(num_modes, -1) kernel = kernel.reshape(num_modes, -1) waves = xp.zeros((num_positions, num_pixels), dtype=dtype) mode_chunk = min(num_modes, self._REDUCE_MODE_CHUNK) max_batch = max( 1, int(self._REDUCE_BATCH_BYTES // max(num_pixels * mode_chunk * 8, 1)) ) for k_start in range(0, num_modes, mode_chunk): k_stop = min(k_start + mode_chunk, num_modes) # contiguous slices of a modes-first array: no copy gathered_source = (kernel if gather_kernel else fields)[k_start:k_stop] fixed = (fields if gather_kernel else kernel)[k_start:k_stop] for start in range(0, num_positions, max_batch): stop = min(start + max_batch, num_positions) gathered = gathered_source[:, flat_indices[start:stop]] waves[start:stop] += xp.einsum( "kpw,kw->pw", gathered, fixed, optimize=True ) return waves.reshape((num_positions,) + out_shape) def _lattice_geometry(self, scan, warn: bool = False): """Describe a scan as a lattice of the pixel grid, or return None. The fast reduction below requires the probe positions to form a regular grid whose step is a whole number of pixels, so that a window offset splits into a whole-step and a sub-step part. The scan may cover any part of the grid and its origin may fall between pixels — a common fractional offset is applied to the reduction kernel instead. Returns ``(origin, step, shape, offset)``: the whole-pixel origin, the integer pixel step, the scan shape, and the common fractional offset. """ if not isinstance(scan, GridScan): return None positions = np.asarray(scan.get_positions()) if positions.ndim != 3 or min(positions.shape[:2]) < 2: return None pixels = positions / np.array(self.sampling) origin = pixels[0, 0] steps = (pixels[1, 0] - pixels[0, 0], pixels[0, 1] - pixels[0, 0]) if abs(steps[0][1]) > 1e-6 or abs(steps[1][0]) > 1e-6: return None step = (steps[0][0], steps[1][1]) shape = positions.shape[:2] # the scan must be the exact lattice implied by its first row and column if not ( np.allclose(pixels[:, 0, 0], origin[0] + np.arange(shape[0]) * step[0]) and np.allclose(pixels[0, :, 1], origin[1] + np.arange(shape[1]) * step[1]) ): return None # the positions are stored in single precision, so the wholeness of # the step can only be resolved relative to its magnitude if any( abs(value - round(value)) > 1e-5 * max(1.0, abs(value)) for value in step ): if warn: suggestion = tuple( min( (d for d in range(1, gpts + 1) if gpts % d == 0), key=lambda d: abs(d - n), ) for n, gpts in zip(shape, self.gpts) ) warnings.warn( "The scan step is not a whole number of pixels " f"({step[0]:.3f}, {step[1]:.3f}), so the reduction of the " "compressed scattering matrix falls back to its general " "(much slower) implementation. Choose a scan whose step " "divides the grid — for this scattering matrix " f"{self.gpts} — for example gpts={suggestion}, or build " "the scan with GridScan.commensurate(potential, ...).", stacklevel=3, ) return None step = tuple(int(round(value)) for value in step) if min(step) < 1: return None # a common fractional origin is exact: it is applied as a sub-pixel # phase ramp on the reduction kernel whole = tuple(int(np.rint(value)) for value in origin) offset = np.array(origin) - np.array(whole) # a half-pixel offset rounds inconsistently along the scan (ties round # to even), which would centre the cropping windows of neighbouring # positions one pixel apart; leave those scans to the general path if np.any(np.abs(np.abs(offset) - 0.5) < 1e-6): return None return whole, step, shape, offset def _lattice_waves_block( self, u_modes, kernel, origin, step, scan_shape, x_start, x_stop ): """Reduce a block of scan rows by the lattice decomposition of the window offsets. The reduction ``psi[p, j] = sum_k U[r_p + j - c, k] B[j, k]`` gathers a window of the modes per probe position, which reuses no data: every gathered element is consumed by a single multiply, so it runs at a few percent of the achievable rate. When the probe positions lie on a lattice of the pixel grid with step ``s``, the window offset splits as ``j - c = a * s + b``, and the probe position enters only through the whole-step part:: psi[p, a s + b] = sum_k U[(p + a) s + b, k] B[a s + b, k] For each sub-step offset ``b`` the modes are sliced with stride ``s`` (a view, not a gather) and every ``(position + a)`` pair is evaluated by a single matrix product; the probe-dependent shift is then applied by extracting ``G[p + a, a]``. The matrix products reuse both operands across the whole block, which is what the gather formulation cannot do. """ xp = self._xp dtype = self._complex_dtype gpts = self.gpts # modes-first: (K, window_x, window_y) and (K, gpts_x, gpts_y) window = tuple(kernel.shape[1:]) num_modes = kernel.shape[0] step_x, step_y = step num_y = scan_shape[1] num_block = x_stop - x_start offsets = [] for length, center, stride in zip( window, (window[0] // 2, window[1] // 2), step ): displacement = np.arange(length) - center sub_step = displacement % stride offsets.append((sub_step, (displacement - sub_step) // stride)) (sub_x, whole_x), (sub_y, whole_y) = offsets def axis_indices(value, steps, start, num, axis): """Grid indices of the strided slice and the extraction offsets. When the scan lattice tiles the periodic axis the window offsets wrap onto the same points, otherwise the slice is extended by the halo of points the window reaches beyond the scan. """ if num * step[axis] == gpts[axis] and start == 0: index = (origin[axis] + value + np.arange(num) * step[axis]) % gpts[axis] return xp.asarray(index), xp.asarray( (np.arange(num)[:, None] + steps[None, :]) % num ) first = int(steps.min()) length = num + int(steps.max()) - first index = ( origin[axis] + value + (start + first + np.arange(length)) * step[axis] ) % gpts[axis] return xp.asarray(index), xp.asarray( np.arange(num)[:, None] + (steps - first)[None, :] ) waves = xp.zeros((num_block, num_y) + window, dtype=dtype) for value_x in range(step_x): select_x = np.flatnonzero(sub_x == value_x) if not len(select_x): continue index_x, row_index = axis_indices( value_x, whole_x[select_x], x_start, num_block, 0 ) for value_y in range(step_y): select_y = np.flatnonzero(sub_y == value_y) if not len(select_y): continue index_y, column_index = axis_indices( value_y, whole_y[select_y], 0, num_y, 1 ) modes = u_modes[:, index_x][:, :, index_y] block_kernel = kernel[:, xp.asarray(select_x)][ :, :, xp.asarray(select_y) ] # already (K, pixels): the .T the modes-last layout needed here # was a full copy of the block kernel_matrix = block_kernel.reshape(num_modes, -1) # the product holds every (grid row, grid column, window # offset) combination; at small scan steps its row size is # large (the sub-step groups hold window / step offsets each), # so the rows are processed in chunks within the batch budget # rather than materialized whole row_bytes = ( len(index_y) * len(select_x) * len(select_y) * 8 ) capacity = max(1, int(self._REDUCE_BATCH_BYTES // row_bytes)) if capacity >= len(index_x): product = ( modes.reshape(num_modes, -1).T @ kernel_matrix ).reshape( len(index_x), len(index_y), len(select_x), len(select_y) ) waves[ :, :, xp.asarray(select_x)[:, None], xp.asarray(select_y)[None, :], ] = product[ row_index[:, None, :, None], column_index[None, :, None, :], xp.arange(len(select_x))[None, None, :, None], xp.arange(len(select_y))[None, None, None, :], ] continue columns = xp.arange(num_y) offsets = xp.arange(len(select_y)) select_x = xp.asarray(select_x) select_y = xp.asarray(select_y) for chunk_start in range(0, len(index_x), capacity): chunk_stop = min(chunk_start + capacity, len(index_x)) product = ( modes[:, chunk_start:chunk_stop].reshape(num_modes, -1).T @ kernel_matrix ).reshape( chunk_stop - chunk_start, len(index_y), len(select_x), len(select_y), ) inside = (row_index >= chunk_start) & (row_index < chunk_stop) block_rows, window_rows = xp.nonzero(inside) if not len(block_rows): continue waves[ block_rows[:, None, None], columns[None, :, None], select_x[window_rows][:, None, None], select_y[None, None, :], ] = product[ (row_index[block_rows, window_rows] - chunk_start)[ :, None, None ], column_index[None, :, :], window_rows[:, None, None], offsets[None, None, :], ] return waves.reshape((num_block * num_y,) + window) def _lattice_batch_reduce_to_measurements( self, scan, ctf, detectors, lattice, pbar: bool = False, blend_angle: float = None, blend_component: str = None, blend_taper: float = None, ): """Windowed reduction of a lattice scan (see :meth:`_lattice_waves_block`).""" origin, step, scan_shape, offset = lattice measurements = allocate_multislice_measurements( self.dummy_probes(scan=scan, ctf=ctf), detectors, extra_ensemble_axes_shape=(), extra_ensemble_axes_metadata=[], ) xp = self._xp # modes-first, no transpose: _contract_modes_batched consumes (K, ...) u_modes = xp.asarray(self._u) window = self.window_gpts # block the scan rows so the reduced wave functions of one block stay # within a fixed budget; larger blocks amortize the halo of window / # step rows that neighbouring blocks re-gather and re-multiply, hence # the device budget is set several times higher than the host one budget = self._REDUCE_BATCH_BYTES * ( self._REDUCE_GPU_ROW_BLOCK_FACTOR if self._device == "gpu" else 2 ) budget = min(budget, self._reduce_memory_budget()) row_bytes = scan_shape[1] * int(np.prod(window)) * 8 num_rows = max(1, int(budget // max(row_bytes, 1))) num_rows = min(num_rows, scan_shape[0]) detect_rows = max(1, int(self._REDUCE_BATCH_BYTES // max(row_bytes, 1))) detect_rows = min(detect_rows, num_rows) keep_interpolated, keep_plane_wave = self._blend_branches( blend_angle, blend_component ) pbar = TqdmWrapper( enabled=pbar, total=int(np.prod(scan.shape + ctf.ensemble_shape)), leave=False, desc="reduce", ) for _, ctf_slics, sub_ctf in ctf.generate_blocks(1): sub_ctf = sub_ctf.item() coefficients = self._calculate_ctf_coefficients(sub_ctf) coefficients = coefficients.reshape((-1, coefficients.shape[-1]))[0] kernel = plane_wave_kernel = None if keep_interpolated: kernel = self._window_kernel( self._coefficient_values(coefficients), offset ) if keep_plane_wave: plane_wave_scale = get_dtype(complex=False)( np.sqrt(np.prod(self.gpts) / np.prod(self.window_gpts)) ) plane_wave_kernel = self._window_kernel( plane_wave_scale * self._coefficient_values( self._lattice_coefficients(coefficients) ), offset, ) if blend_angle is not None: blend_weight = self._blend_weight( blend_angle, self.window_gpts, taper=blend_taper ) for x_start in range(0, scan_shape[0], num_rows): x_stop = min(x_start + num_rows, scan_shape[0]) interpolated = ( self._lattice_waves_block( u_modes, kernel, origin, step, scan_shape, x_start, x_stop ) if keep_interpolated else None ) plane_wave = ( self._lattice_waves_block( u_modes, plane_wave_kernel, origin, step, scan_shape, x_start, x_stop, ) if keep_plane_wave else None ) # the row block is sized for the matrix products; the blend and # the detectors transform what it produces, which needs the # transform, its work area and the detected intensity live at # once, so they walk the block in plain-budget chunks for start in range(x_start, x_stop, detect_rows): stop = min(start + detect_rows, x_stop) rows = slice( (start - x_start) * scan_shape[1], (stop - x_start) * scan_shape[1], ) waves_array = interpolated[rows] if keep_interpolated else None if blend_angle is not None: waves_array = self._blend_wave_batches( waves_array, plane_wave[rows] if keep_plane_wave else None, blend_weight, component=blend_component, ) waves_array = waves_array.reshape( (1,) * len(sub_ctf.ensemble_shape) + (stop - start, scan_shape[1]) + window ) ensemble_axes_metadata = [ UnknownAxis() for _ in range(len(sub_ctf.ensemble_shape)) ] + [ScanAxis(), ScanAxis()] if self._detects_the_wave(detectors): waves_array = self._to_exit_reference(waves_array) waves = Waves( waves_array, sampling=tuple(self.sampling), energy=self.energy, ensemble_axes_metadata=ensemble_axes_metadata, metadata=self.metadata, ) indices = ctf_slics + (slice(start, stop),) pbar.update_if_exists((stop - start) * scan_shape[1]) for detector, measurement in zip(detectors, measurements): measurement.array[indices] = detector.detect(waves).array del interpolated, plane_wave pbar.close_if_exists() return tuple(measurements) def _flat_indices(self, anchor, span, gpts): """Flat index into a ``(gpts[0], gpts[1])`` array visited by a window of shape *span*, starting at *anchor* (shape ``(n, 2)``) and wrapping periodically. Shared by :meth:`_reduce_to_waves_batched` (windowed: ``anchor = snapped_pixels - window_gpts // 2``, ``span = window_gpts``) and :meth:`_reduce_to_waves_absolute` (full-grid: ``anchor = -snapped_pixels``, ``span = gpts``).""" xp = self._xp x = (anchor[:, 0, None] + xp.arange(span[0])[None]) % gpts[0] y = (anchor[:, 1, None] + xp.arange(span[1])[None]) % gpts[1] return (x[:, :, None] * gpts[1] + y[:, None, :]).astype(np.int32) def _reduce_to_waves_batched(self, u_windows, snapped_pixels, kernel): """Vectorized equivalent of :meth:`_reduce_to_waves`: the windows of the left singular vectors are gathered for batches of probe positions and contracted with the kernel in large batched einsums. The per-position loop of :meth:`_reduce_to_waves` evaluates thousands of small kernels, which is launch-overhead bound on the GPU. """ xp = self._xp gpts = self.gpts window_gpts = self.window_gpts corners = ( snapped_pixels - xp.asarray((window_gpts[0] // 2, window_gpts[1] // 2))[None] ) % xp.asarray(gpts)[None] flat_indices = self._flat_indices(corners, window_gpts, gpts) return self._contract_modes_batched( u_windows, flat_indices, kernel, gather_kernel=False ) def _reduce_to_waves_absolute(self, u_full, snapped_pixels, kernel): """Full-window reduction in the absolute frame: contract the modes with the kernel displaced to each probe position. ``kernel`` is the uncentered kernel on the full grid (mode axis last), indexed by the displacement from the probe; the result matches the reduction of the expanded scattering matrix to floating point precision. """ xp = self._xp gpts = self.gpts flat_indices = self._flat_indices(-snapped_pixels, gpts, gpts) return self._contract_modes_batched( u_full, flat_indices, kernel, gather_kernel=True ) def _reduce_to_waves(self, u_windows, snapped_pixels, kernel): """Reduce the compressed scattering matrix to wave functions at the given snapped pixel positions. Parameters ---------- u_windows : array Left singular vectors with the mode axis FIRST, of shape (K, gpts_x, gpts_y) -- the layout they are stored in, so that no transposed copy of them has to exist. snapped_pixels : array of int Whole-pixel probe positions of shape (n, 2). kernel : array Reduction kernel with the mode axis FIRST, of shape (K, window_gpts_x, window_gpts_y). """ xp = self._xp if xp is not np: # per-position loops are launch-overhead bound on the GPU return self._reduce_to_waves_batched(u_windows, snapped_pixels, kernel) gpts = self.gpts window_gpts = self.window_gpts corners = ( snapped_pixels - xp.asarray((window_gpts[0] // 2, window_gpts[1] // 2))[None] ) % xp.asarray(gpts)[None] corners = corners if xp is np else corners.get() waves = xp.zeros( (len(snapped_pixels),) + window_gpts, dtype=get_dtype(complex=True) ) # Each window is at most four contiguous blocks of the scattering matrix # (due to the periodic wrap-around), hence the contraction over the modes # is evaluated on views without gathering. The mode axis leads in both # operands, so the blocks are views into the stored arrays. def reduce_position(n): cx, cy = int(corners[n, 0]), int(corners[n, 1]) x_split = min(gpts[0] - cx, window_gpts[0]) y_split = min(gpts[1] - cy, window_gpts[1]) for wx0, wx1, sx in ((0, x_split, cx), (x_split, window_gpts[0], 0)): if wx0 == wx1: continue for wy0, wy1, sy in ((0, y_split, cy), (y_split, window_gpts[1], 0)): if wy0 == wy1: continue waves[n, wx0:wx1, wy0:wy1] = xp.einsum( "kij,kij->ij", u_windows[:, sx : sx + wx1 - wx0, sy : sy + wy1 - wy0], kernel[:, wx0:wx1, wy0:wy1], ) def reduce_chunk(chunk): for n in range(chunk.start, min(chunk.stop, len(snapped_pixels))): reduce_position(n) num_threads = int(config.get("fftw.threads", 1)) if xp is np else 1 if num_threads > 1 and len(snapped_pixels) > 1: # the contraction of each position releases the GIL for its large # array operations; the positions write to disjoint slices from concurrent.futures import ThreadPoolExecutor max_batch = -(len(snapped_pixels) // -(num_threads * 4)) chunks = [ slice(start, start + max_batch) for start in range(0, len(snapped_pixels), max_batch) ] with ThreadPoolExecutor(num_threads) as executor: list(executor.map(reduce_chunk, chunks)) else: reduce_chunk(slice(0, len(snapped_pixels))) return waves def _group_by_fractional_offset(self, pixel_positions, decimals: int = 4): """Group probe positions by their fractional pixel offset. The offsets are rounded to ``10**-decimals`` pixels, which is well below the numerical precision of the probe positions. """ xp = get_array_module(pixel_positions) snapped = xp.rint(pixel_positions).astype(int) fractional = pixel_positions - snapped fractional = fractional if xp is np else fractional.get() if self._position_quantization: fractional = ( np.round(fractional * self._position_quantization) / self._position_quantization ) rounded = np.round(fractional, decimals=decimals) rounded += 0.0 # remove negative zero unique, inverse = np.unique(rounded, axis=0, return_inverse=True) return snapped, unique, inverse def _expanded_slab(self, start: int, stop: int, xp, dtype) -> np.ndarray: """The plane waves ``[start, stop)`` of the scattering matrix expanded to interpolation (1, 1): one matrix product over the modes followed by the reattachment of the plane-wave phases.""" gpts = self.gpts extent = self.extent values = xp.asarray( self._sigma[:, None] * self._vh_dense[:, start:stop], dtype=dtype ) u = xp.asarray(self._u).reshape(self.rank, -1) slab = (values.T @ u).reshape((-1,) + tuple(gpts)) wave_vectors = xp.asarray(self.wave_vectors[start:stop]) real_dtype = get_dtype(complex=False) x = xp.linspace(0, extent[0], gpts[0], endpoint=False, dtype=real_dtype) y = xp.linspace(0, extent[1], gpts[1], endpoint=False, dtype=real_dtype) slab *= complex_exponential( 2.0 * xp.pi * wave_vectors[:, 0, None, None] * x[:, None] ) * complex_exponential( 2.0 * xp.pi * wave_vectors[:, 1, None, None] * y[None, :] ) return slab def _expanded_s_matrix_array(self) -> SMatrixArray: """Expand the compressed factorization to the interpolated scattering matrix at interpolation (1, 1). The expanded matrix has the same memory footprint as a PRISM scattering matrix at interpolation (1, 1); provide `max_batch_expansion` to stream the expansion instead, or `window_gpts` to reduce from the compressed modes. """ if getattr(self, "_s_matrix_array", None) is not None: return self._s_matrix_array xp = self._xp dtype = self._complex_dtype gpts = self.gpts n_dense = len(self.wave_vectors) array = xp.empty((n_dense,) + tuple(gpts), dtype=dtype) max_batch = self._expansion_batch_size(np.prod(gpts)) for start in range(0, n_dense, max_batch): stop = min(start + max_batch, n_dense) slab = self._expanded_slab(start, stop, xp, dtype) # the expanded beams carry their tilt ramp, so the reference depth # is undone here beam by beam and this matrix describes the exit # surface — it is handed to the plain scattering-matrix reduction, # which knows nothing of the reference array[start:stop] = self._to_exit_reference(slab) self._s_matrix_array = SMatrixArray( array, # the reduction multiplies these into the plane-wave coefficients, # so anything wider than the working precision promotes the whole # reduction (and doubles its memory) wave_vectors=np.asarray( self.wave_vectors, dtype=get_dtype(complex=False) ), semiangle_cutoff=self.semiangle_cutoff, energy=self.energy, interpolation=(1, 1), sampling=tuple(self.sampling), window_gpts=tuple(gpts), window_offset=(0, 0), periodic=(True, True), device=self._device, ensemble_axes_metadata=[], metadata=dict(self.metadata), ) return self._s_matrix_array def _new_batch_reduce_measurements( self, scan: BaseScan, ctf: CTF, detectors: list[BaseDetector], pbar: bool ) -> tuple[tuple[BaseMeasurements | Waves, ...], TqdmWrapper]: """Shared head of ``_batch_reduce_to_measurements`` and ``_streamed_batch_reduce_to_measurements``: allocate the output measurements and the progress bar.""" dummy_probes = self.dummy_probes(scan=scan, ctf=ctf) measurements = allocate_multislice_measurements( dummy_probes, detectors, extra_ensemble_axes_shape=(), extra_ensemble_axes_metadata=[], ) n_positions = int(np.prod(scan.shape + ctf.ensemble_shape)) pbar = TqdmWrapper(enabled=pbar, total=n_positions, leave=False, desc="reduce") return measurements, pbar def _finalize_batch_measurement( self, waves_array, scan_shape: tuple[int, ...], sub_ctf, detectors: list[BaseDetector], measurements: tuple[BaseMeasurements | Waves, ...], ctf_slics: tuple, slics: tuple, pbar: TqdmWrapper, n_reduced: int, ) -> None: """Shared tail of ``_batch_reduce_to_measurements`` and ``_streamed_batch_reduce_to_measurements``: wrap the computed wave array as a ``Waves`` object with the correct ensemble metadata, detect it, and write the result into each measurement's array slice.""" ensemble_axes_metadata = [ UnknownAxis() for _ in range(len(sub_ctf.ensemble_shape)) ] ensemble_axes_metadata += [ScanAxis() for _ in range(len(scan_shape))] if self._detects_the_wave(detectors): waves_array = self._to_exit_reference(waves_array) waves = Waves( waves_array, sampling=tuple(self.sampling), energy=self.energy, ensemble_axes_metadata=ensemble_axes_metadata, metadata=self.metadata, ) indices = ctf_slics + slics pbar.update_if_exists(n_reduced) for detector, measurement in zip(detectors, measurements): measurement.array[indices] = detector.detect(waves).array def _batch_reduce_to_measurements( self, scan: BaseScan, ctf: CTF, detectors: list[BaseDetector], max_batch_reduction: int, pbar: bool = False, absolute: bool = False, blend_angle: float = None, blend_component: str = None, blend_taper: float = None, ) -> tuple[BaseMeasurements | Waves, ...]: measurements, pbar = self._new_batch_reduce_measurements( scan, ctf, detectors, pbar ) xp = self._xp u_windows = xp.asarray(self._u) sampling = xp.asarray(self.sampling) keep_interpolated, keep_plane_wave = self._blend_branches( blend_angle, blend_component ) for _, ctf_slics, sub_ctf in ctf.generate_blocks(1): sub_ctf = sub_ctf.item() coefficients = self._calculate_ctf_coefficients(sub_ctf) # the generated blocks contain a single ensemble member coefficients = coefficients.reshape((-1, coefficients.shape[-1]))[0] values = self._coefficient_values(coefficients) if keep_plane_wave: # the plane-wave reduction spreads a unit probe over # prod(interpolation) periodized copies; a window holds # window / gpts of them, hence the amplitude is rescaled so the # in-window intensity matches the interpolated branch (exact # when the window is a multiple of the period gpts / # interpolation, unity for the full window) plane_wave_scale = get_dtype(complex=False)( np.sqrt(np.prod(self.gpts) / np.prod(self.window_gpts)) ) plane_wave_values = plane_wave_scale * self._coefficient_values( self._lattice_coefficients(coefficients) ) if blend_angle is not None: blend_weight = self._blend_weight( blend_angle, self.window_gpts, taper=blend_taper ) for _, slics, sub_scan in scan.generate_blocks(max_batch_reduction): sub_scan = sub_scan.item() positions = xp.asarray(sub_scan.get_positions()) scan_shape = positions.shape[:-1] positions = positions.reshape((-1, 2)) # in double precision independently of ``config['precision']``: # the positions are grouped by their fractional pixel offset # rounded to 1e-4 pixels, which single-precision positions of a # large cell cannot resolve pixel_positions = positions.astype(np.float64) / sampling snapped, unique_offsets, inverse = self._group_by_fractional_offset( pixel_positions ) waves_array = xp.zeros( (len(positions),) + self.window_gpts, dtype=get_dtype(complex=True), ) for i, offset in enumerate(unique_offsets): mask = xp.asarray(inverse == i) reduce_to_waves = ( self._reduce_to_waves_absolute if absolute else self._reduce_to_waves ) def branch(branch_values): kernel = self._window_kernel( branch_values, offset, center=not absolute ) return reduce_to_waves(u_windows, snapped[mask], kernel) new_waves = branch(values) if keep_interpolated else None if blend_angle is not None: new_waves = self._blend_wave_batches( new_waves, branch(plane_wave_values) if keep_plane_wave else None, blend_weight, component=blend_component, ) waves_array[mask] = new_waves waves_array = waves_array.reshape( (1,) * len(sub_ctf.ensemble_shape) + scan_shape + self.window_gpts ) self._finalize_batch_measurement( waves_array, scan_shape, sub_ctf, detectors, measurements, ctf_slics, slics, pbar, len(positions), ) pbar.close_if_exists() return tuple(measurements) def _streamed_batch_reduce_to_measurements( self, scan: BaseScan, ctf: CTF, detectors: list[BaseDetector], max_batch_reduction: int, max_batch_expansion: int, pbar: bool = False, ) -> tuple[BaseMeasurements | Waves, ...]: """Full-window reduction streaming the interpolation-(1, 1) expansion. Equivalent to ``self._expanded_s_matrix_array().reduce(...)``, but the expanded scattering matrix is never materialized: the plane waves are expanded in slabs of ``max_batch_expansion`` and contracted with the reduction coefficients on the fly. Peak memory is one slab plus one batch of reduced wave functions, instead of the full ``n x gpts`` expanded matrix. The expansion is repeated for every batch of probe positions, hence the relative overhead is ``rank / batch size`` matrix product work; the caller enlarges the reduction batches accordingly. The coefficients must match :meth:`SMatrixArray.reduce` exactly (same position phases and the same globally normalized CTF coefficients), so the streamed and expanded reductions agree to floating point precision. """ measurements, pbar = self._new_batch_reduce_measurements( scan, ctf, detectors, pbar ) xp = self._xp dtype = self._complex_dtype wave_vectors = xp.asarray(self.wave_vectors) n_dense = len(wave_vectors) for _, ctf_slics, sub_ctf in ctf.generate_blocks(1): sub_ctf = sub_ctf.item() # must match SMatrixArray._calculate_ctf_coefficients: normalized # by the absolute square over the full plane-wave expansion alpha = ( xp.sqrt(wave_vectors[:, 0] ** 2 + wave_vectors[:, 1] ** 2) * sub_ctf.wavelength ) phi = xp.arctan2(wave_vectors[:, 1], wave_vectors[:, 0]) ctf_coefficients = sub_ctf._evaluate_from_angular_grid(alpha, phi) ctf_coefficients = ctf_coefficients / xp.sqrt( (xp.abs(ctf_coefficients) ** 2).sum(axis=-1, keepdims=True) ) for _, slics, sub_scan in scan.generate_blocks(max_batch_reduction): sub_scan = sub_scan.item() # must match SMatrixArray._calculate_positions_coefficients if isinstance(sub_scan, GridScan): x = xp.asarray(sub_scan._x_coordinates()) y = xp.asarray(sub_scan._y_coordinates()) positions_coefficients = complex_exponential( -2.0 * xp.pi * x[:, None, None] * wave_vectors[None, None, :, 0] ) * complex_exponential( -2.0 * xp.pi * y[None, :, None] * wave_vectors[None, None, :, 1] ) else: positions = xp.asarray(sub_scan.get_positions()) positions_coefficients = complex_exponential( -2.0 * xp.pi * positions[..., 0, None] * wave_vectors[:, 0][None] - 2.0 * xp.pi * positions[..., 1, None] * wave_vectors[:, 1][None] ) ( expanded_ctf_coefficients, positions_coefficients, ) = expand_dims_to_broadcast( ctf_coefficients, positions_coefficients, match_dims=[(-1,), (-1,)], ) coefficients = xp.asarray( positions_coefficients * expanded_ctf_coefficients, dtype=dtype ) waves_array = xp.zeros( coefficients.shape[:-1] + tuple(self.gpts), dtype=dtype ) for start in range(0, n_dense, max_batch_expansion): stop = min(start + max_batch_expansion, n_dense) slab = self._expanded_slab(start, stop, xp, dtype) waves_array += xp.tensordot( coefficients[..., start:stop], slab, axes=[-1, -3] ) self._finalize_batch_measurement( waves_array, sub_scan.shape, sub_ctf, detectors, measurements, ctf_slics, slics, pbar, int(np.prod(sub_scan.shape)), ) pbar.close_if_exists() return tuple(measurements)
[docs] def reduce( self, scan: BaseScan = None, ctf: CTF = None, detectors: BaseDetector | list[BaseDetector] = None, max_batch_reduction: int | str = "auto", max_batch_expansion: int | str = None, method: str = "auto", blend_angle: float = None, blend_window_gpts: int | tuple[int, int] | str = None, blend_taper: float = None, _blend_component: str = None, _blend_taper: float = None, ) -> BaseMeasurements | Waves | list[BaseMeasurements | Waves]: """ Scan the probe across the potential and record a measurement for each detector. Parameters ---------- scan : BaseScan Positions of the probe wave functions. If not given, reduces a single probe at the center of the potential. ctf : CTF, optional The probe contrast transfer function. Default is None (aperture is set by the plane-wave cutoff). detectors : BaseDetector or list of BaseDetector The detectors recording the measurements. max_batch_reduction : int or str, optional Number of positions per reduction operation. If 'auto' (default), the batch size is automatically chosen based on the abTEM user configuration settings "dask.chunk-size" and "dask.chunk-size-gpu". max_batch_expansion : int or str, optional The number of plane waves expanded at a time when the reduced wave functions are not cropped. If 'auto', the full plane-wave expansion is materialized once (fastest, but with the memory footprint of a PRISM scattering matrix at interpolation 1); an integer streams the expansion instead, bounding the memory at one batch of plane waves plus one batch of reduced wave functions, at the cost of repeating the expansion for every batch of probe positions. If not given (default), the value set on the :class:`.SMatrix` is used. Only used with ``method='expand'``. method : {'auto', 'expand', 'modes'}, optional How the full-window reduction is evaluated. ``'expand'`` expands the compressed factorization to the interpolation-(1, 1) scattering matrix and reduces it with one large matrix product per batch of probe positions — high arithmetic intensity, fastest on the CPU, but with a cost proportional to the number of dense plane waves. ``'modes'`` contracts the retained modes directly against a probe-displaced reduction kernel — a cost proportional to the number of modes (usually far fewer than the plane waves), fastest on the GPU where the contraction saturates memory bandwidth. Both produce identical wave functions to floating point precision. ``'auto'`` (default) selects 'modes' on the GPU and 'expand' on the CPU, unless streaming was requested through ``max_batch_expansion``. Ignored when the reduced wave functions are cropped (``window_gpts``), which always contracts the modes. blend_angle : float, optional Above this scattering angle [mrad] the reduced wave functions follow the plane-wave (PRISM) reduction of the built beams, below it the interpolated (C-PRISM) reduction, with a smooth taper between them. The interpolation is band limited and aliases the contributions of electrons displaced beyond half its period, which harms high-angle detectors; the plane-wave reduction of the same built beams does not, hence blending bounds the high-angle error by that of the PRISM algorithm while keeping the interpolated accuracy at low angles. If not given, the value set on the :class:`.SMatrix` is used ('auto' derives it from the aliasing limit of the interpolation); a non-positive value disables blending. Returns ------- measurements : BaseMeasurements or Waves or list of BaseMeasurements or Waves """ self.accelerator.check_is_defined() explicit_blend = blend_angle is not None if blend_angle is None: blend_angle = self._blend_angle if ( blend_angle is not None and not isinstance(blend_angle, str) and blend_angle <= 0 ): blend_angle = None if ( blend_window_gpts is not None and blend_angle is not None and _blend_component is None ): return self._composite_blend_reduce( scan=scan, ctf=ctf, detectors=detectors, max_batch_reduction=max_batch_reduction, method=method, blend_angle=blend_angle, blend_window_gpts=blend_window_gpts, blend_taper=0.0 if blend_taper is None else blend_taper, ) if blend_angle is not None and _blend_component is None: cut = ( float(self._semiangle_cutoff) if isinstance(blend_angle, str) else blend_angle ) if detectors is not None: routed = self._routed_reduce( scan, ctf, detectors, cut, max_batch_reduction, method, blend_taper=0.0 if blend_taper is None else blend_taper, ) if routed is not None: return routed if not explicit_blend: # the default blend acts only through the routing: when the # detectors are not routable (a band straddles the cut, or the # output is wave functions or a full diffraction pattern) the # reduction is the plain interpolated one, and blending must be # requested explicitly blend_angle = None if _blend_taper is None and blend_taper is not None: _blend_taper = blend_taper if method not in ("auto", "expand", "modes"): raise ValueError( f"method must be 'auto', 'expand' or 'modes'; got {method!r}" ) if max_batch_expansion is None: max_batch_expansion = self._max_batch_expansion full_window = tuple(self.window_gpts) == tuple(self.gpts) if not full_window: if max_batch_expansion != "auto": raise ValueError( "max_batch_expansion applies to the reduction of the expanded " "scattering matrix; it cannot be combined with window_gpts." ) if method == "expand": raise ValueError( "method='expand' applies to the full-window reduction; with " "window_gpts the modes are always contracted directly." ) else: if method == "auto": if max_batch_expansion != "auto": method = "expand" elif self._device == "gpu": method = "modes" else: method = "expand" if method == "modes" and max_batch_expansion != "auto": raise ValueError( "max_batch_expansion streams the expanded scattering matrix; " "it cannot be combined with method='modes'." ) if blend_angle is not None and full_window and method == "expand": # the blend combines two kernel reductions, hence needs the mode path method = "modes" if full_window and method == "expand" and max_batch_expansion == "auto": return self._expanded_s_matrix_array().reduce( scan=scan, ctf=ctf, detectors=detectors, max_batch_reduction=max_batch_reduction, ) if ctf is None: ctf = CTF(semiangle_cutoff=self.semiangle_cutoff) ctf.grid.match(self.dummy_probes()) ctf.accelerator.match(self) if ctf.semiangle_cutoff == np.inf: ctf.semiangle_cutoff = self.semiangle_cutoff squeeze = () if isinstance(scan, BaseScan) else (-3,) if scan is None: scan = self.extent[0] / 2, self.extent[1] / 2 scan = validate_scan( scan, Probe._from_ctf(extent=self.extent, ctf=ctf, energy=self.energy) ) detectors = validate_detectors(detectors, self.dummy_probes()) from abtem.core.chunks import validate_chunks shape = (len(scan),) + self.window_gpts chunks = (max_batch_reduction, -1, -1) validated_max_batch_reduction = validate_chunks( shape, chunks, dtype=np.dtype("complex64") )[0][0] pbar = config.get("diagnostics.task_progress", False) if full_window and method == "expand": if max_batch_reduction == "auto": # the expansion is repeated for every batch of probe positions # with a relative matrix product overhead of rank / batch size; # enlarge the automatic batches so the overhead stays small validated_max_batch_reduction = min( max(validated_max_batch_reduction, 4 * self.rank), len(scan) ) measurements = self._streamed_batch_reduce_to_measurements( scan, ctf, detectors, validated_max_batch_reduction, int(max_batch_expansion), pbar=pbar, ) else: lattice = ( None if full_window else self._lattice_geometry(scan, warn=True) ) if lattice is not None: measurements = self._lattice_batch_reduce_to_measurements( scan, ctf, detectors, lattice, pbar=pbar, blend_angle=blend_angle, blend_component=_blend_component, blend_taper=_blend_taper, ) else: measurements = self._batch_reduce_to_measurements( scan, ctf, detectors, validated_max_batch_reduction, pbar=pbar, absolute=full_window, blend_angle=blend_angle, blend_component=_blend_component, blend_taper=_blend_taper, ) measurements = [measurement.squeeze(squeeze) for measurement in measurements] return _wrap_measurements(measurements)
def _routing_sides(self, cut, detectors, taper: float = 0.0): """Which branch each detector reads from, or None when not routable. A detector collecting only below the blend angle reads the interpolated reduction, one collecting only above it the plane-wave (PRISM) reduction; nothing is mixed, so no Fourier weighting is needed. With a taper, a band overlapping the taper zone ``[cut - taper, cut]`` reads the tapered combination of the two intensities, which makes the underlying angular density continuous across the cut. A detector straddling the cut without a taper, or one whose collection range is not an angular band, is not routable. """ sides = [] for detector in detectors: if isinstance(detector, (AnnularDetector, SegmentedDetector)): outer = detector.outer inner = detector.inner if outer is not None and outer <= cut - taper: sides.append("low") elif inner is not None and inner >= cut: sides.append("high") elif taper > 0.0 and inner is not None and outer is not None: sides.append("taper") elif outer is not None and outer <= cut: sides.append("low") else: return None elif isinstance(detector, PixelatedDetector): max_angle = detector.max_angle divisible = all( g % i == 0 for g, i in zip(self.gpts, self._interpolation) ) if tuple(self.window_gpts) == tuple(self.gpts): # the patterns are already on the simulation grid if isinstance(max_angle, (int, float)) and ( max_angle <= cut - taper ): sides.append("low") else: return None elif detector.reciprocal_space and not detector.resample and divisible: # patterns of a windowed reduction are always detected on # the full grid, whether or not the cut falls inside them: # the window is an internal accuracy device, and its # reciprocal sampling is not one a user asked for sides.append("pattern") else: return None else: return None return sides def _routed_reduce( self, scan, ctf, detectors, blend_angle, max_batch_reduction, method, blend_taper: float = 0.0, ): """Route each detector to the branch its band lies in, or return None. The blend angle is snapped to a detector boundary, so that every detector band lies wholly below or above it: the bands below read the interpolated (C-PRISM) reduction on this array's window, the bands above the plane-wave reduction on one interpolation period — the window and the algorithm of PRISM. This is the composite blend without any Fourier weighting, possible whenever no band straddles the cut. """ single = not isinstance(detectors, (list, tuple)) detectors = [detectors] if single else list(detectors) cut = self._snapped_blend_angle(blend_angle, detectors) sides = self._routing_sides(cut, detectors, taper=blend_taper) if sides is None: return None def measure(component, subset): if component == "pattern": measurements = self._stitched_pattern_reduce( scan, ctf, subset, cut, max_batch_reduction, method ) elif component == "taper": measurements = self._composite_blend_reduce( scan=scan, ctf=ctf, detectors=subset, max_batch_reduction=max_batch_reduction, method=method, blend_angle=cut, blend_window_gpts="period", blend_taper=blend_taper, snap=False, ) elif component == "low": measurements = self.reduce( scan=scan, ctf=ctf, detectors=subset, max_batch_reduction=max_batch_reduction, method=method, blend_angle=0.0, ) else: period = tuple( min(-(-g // i), g) for g, i in zip(self.gpts, self._interpolation) ) measurements = self._with_window(period).reduce( scan=scan, ctf=ctf, detectors=subset, max_batch_reduction=max_batch_reduction, blend_angle=cut, _blend_component="high", _blend_taper=0.0, ) if not isinstance(measurements, (list, tuple)): measurements = [measurements] return list(measurements) ordered = [None] * len(detectors) for component in ("low", "high", "taper", "pattern"): subset = [d for d, side in zip(detectors, sides) if side == component] if not subset: continue for index, measurement in zip( (i for i, side in enumerate(sides) if side == component), measure(component, subset), ): ordered[index] = measurement return ordered[0] if single else _wrap_measurements(ordered) def _stitched_pattern_reduce( self, scan, ctf, detectors, cut, max_batch_reduction, method ): """Diffraction patterns stitched from the two branches of the blend. Below the cut the pattern is the interpolated reduction of this array's window, detected on the full simulation grid (the window is zero-padded, which leaves the pattern of an isolated probe unchanged). At and above the cut it is the plane-wave (PRISM) reduction: the plane-wave field is periodic with one period ``gpts / interpolation``, so its full-grid pattern is its period-grid pattern scattered onto every interpolation-th pixel, exactly — zeros in between, no interpolation smearing of the Bragg reflections. The stitch is sharp at the cut; a blend taper does not apply to patterns. """ padded = [ _FullGridPixelatedDetector(detector, tuple(self.gpts)) for detector in detectors ] low_list = ensure_list( self.reduce( scan=scan, ctf=ctf, detectors=padded, max_batch_reduction=max_batch_reduction, method=method, blend_angle=0.0, ) ) # a cut beyond the largest detected angle leaves nothing to paste, so # the plane-wave branch is not reduced at all if not any( np.hypot( (measurement.array.shape[-2] // 2) * measurement.angular_sampling[0], (measurement.array.shape[-1] // 2) * measurement.angular_sampling[1], ) >= cut for measurement in low_list ): return low_list if self._device == "gpu" and cp is not None: cp.get_default_memory_pool().free_all_blocks() period = tuple(g // i for g, i in zip(self.gpts, self._interpolation)) # a vanishing blend angle keeps the plane-wave branch whole except at # the zero-frequency pixel, which lies far below any usable cut high_list = ensure_list( self._with_window(period).reduce( scan=scan, ctf=ctf, detectors=detectors, max_batch_reduction=max_batch_reduction, blend_angle=1e-12, _blend_component="high", _blend_taper=0.0, ) ) return [ self._stitch_patterns(low, high, cut) for low, high in zip(low_list, high_list) ] @staticmethod def _stitch_patterns(low, high, cut): """Paste the plane-wave pattern onto the interpolated one at and above the cut. Both patterns are fftshifted and centered on the zero-frequency pixel, and a pixel of the coarse (period-grid) pattern subtends the same angle as its lattice pixel on the fine grid, so the radial masks of the two grids agree exactly. """ xp = get_array_module(low.array) def centered_axes(measurement): return tuple( (np.arange(n) - n // 2) * sampling for n, sampling in zip( measurement.array.shape[-2:], measurement.angular_sampling ) ) fine_x, fine_y = centered_axes(low) low.array[ ..., xp.asarray(np.hypot(fine_x[:, None], fine_y[None, :]) >= cut) ] = 0.0 coarse_x, coarse_y = centered_axes(high) factors = tuple( int(round(high.angular_sampling[i] / low.angular_sampling[i])) for i in (0, 1) ) index_x = low.array.shape[-2] // 2 + factors[0] * ( np.arange(high.array.shape[-2]) - high.array.shape[-2] // 2 ) index_y = low.array.shape[-1] // 2 + factors[1] * ( np.arange(high.array.shape[-1]) - high.array.shape[-1] // 2 ) keep = np.hypot(coarse_x[:, None], coarse_y[None, :]) >= cut keep &= (index_x >= 0)[:, None] & (index_x < low.array.shape[-2])[:, None] keep &= (index_y >= 0)[None, :] & (index_y < low.array.shape[-1])[None, :] rows, cols = np.nonzero(keep) low.array[ ..., xp.asarray(index_x[rows]), xp.asarray(index_y[cols]) ] = high.array[..., xp.asarray(rows), xp.asarray(cols)] return low def _with_window(self, window_gpts): """A view of this compressed scattering matrix with another reduction window; the factors are shared, not copied.""" return self.__class__( u=self._u, sigma=self._sigma, vh_dense=self._vh_dense, dense_indices=self._dense_indices, semiangle_cutoff=self._semiangle_cutoff, energy=self.energy, extent=self.extent, interpolation=self._interpolation, window_gpts=window_gpts, position_quantization=self._position_quantization, blend_angle=self._blend_angle, device=self._device, metadata=self._metadata, singular_values=self._singular_values, reference_depth=self._reference_depth, ) def _composite_blend_reduce( self, scan, ctf, detectors, max_batch_reduction, method, blend_angle, blend_window_gpts, blend_taper: float = 0.0, snap: bool = True, ): """Blend the intensities of two reductions with different windows. The interpolated branch is reduced on this array's own (typically full) window, weighted by ``sqrt(weight)`` in Fourier space; the plane-wave branch is reduced on ``blend_window_gpts`` — ``'period'`` selects one period ``gpts / interpolation`` of its periodized wave functions, the window the PRISM algorithm itself uses, which restores the local high-angle signal that the full-grid periodized field averages over its copies — weighted by ``sqrt(1 - weight)``. The detected intensities add, hence the detectors must be intensity-valued (not :class:`WavesDetector`) and produce window-independent shapes (annular and radial detectors; the diffraction patterns of the two branches have different samplings). """ if isinstance(blend_window_gpts, str): if blend_window_gpts != "period": raise ValueError( "blend_window_gpts must be an int, a pair of ints or " f"'period'; got {blend_window_gpts!r}" ) window = tuple( min(-(-g // i), g) for g, i in zip(self.gpts, self._interpolation) ) elif np.isscalar(blend_window_gpts): window = (int(blend_window_gpts),) * 2 else: window = tuple(int(n) for n in blend_window_gpts) # snap to a collection boundary, so that no detector band straddles # the blend without a taper: every band is either the plane-wave # (PRISM) reduction exactly, or the interpolated one, or (inside the # taper zone) a convex combination of the two intensities if snap: blend_angle = self._snapped_blend_angle(blend_angle, detectors) low = self.reduce( scan=scan, ctf=ctf, detectors=detectors, max_batch_reduction=max_batch_reduction, method=method, blend_angle=blend_angle, _blend_component="low", _blend_taper=blend_taper, ) if self._device == "gpu" and cp is not None: cp.get_default_memory_pool().free_all_blocks() high_array = self.__class__( u=self._u, sigma=self._sigma, vh_dense=self._vh_dense, dense_indices=self._dense_indices, semiangle_cutoff=self._semiangle_cutoff, energy=self.energy, extent=self.extent, interpolation=self._interpolation, window_gpts=window, position_quantization=self._position_quantization, blend_angle=self._blend_angle, device=self._device, metadata=self._metadata, singular_values=self._singular_values, reference_depth=self._reference_depth, ) high = high_array.reduce( scan=scan, ctf=ctf, detectors=detectors, max_batch_reduction=max_batch_reduction, blend_angle=blend_angle, _blend_component="high", _blend_taper=blend_taper, ) low_list, high_list = ensure_list(low), ensure_list(high) for low_measurement, high_measurement in zip(low_list, high_list): if np.iscomplexobj(low_measurement.array) or ( low_measurement.array.shape != high_measurement.array.shape ): raise NotImplementedError( "blend_window_gpts adds the detected intensities of the " "two branches, hence it requires intensity-valued " "detectors whose measurements do not depend on the " "reduction window (for example annular detectors)." ) low_measurement.array[:] += high_measurement.array return low if not isinstance(low, list) else _wrap_measurements(low_list)
[docs] def scan( self, scan: BaseScan = None, detectors: BaseDetector | list[BaseDetector] = None, ctf: CTF = None, max_batch_reduction: int | str = "auto", max_batch_expansion: int | str = None, method: str = "auto", blend_angle: float = None, blend_window_gpts: int | tuple[int, int] | str = None, blend_taper: float = None, ): """ Reduce the compressed scattering matrix at the positions of a scan. See :meth:`.CompressedSMatrixArray.reduce`. """ if scan is None: scan = GridScan() return self.reduce( scan=scan, detectors=detectors, ctf=ctf, max_batch_reduction=max_batch_reduction, max_batch_expansion=max_batch_expansion, method=method, blend_angle=blend_angle, blend_window_gpts=blend_window_gpts, blend_taper=blend_taper, )
[docs] class SMatrix(BaseSMatrix, Ensemble, CopyMixin, EqualityMixin): """ The scattering matrix is used for simulating STEM experiments using the PRISM algorithm. Parameters ---------- semiangle_cutoff : float The radial cutoff of the plane-wave expansion [mrad]. energy : float or list of float Electron energy [eV]. A single float runs a standard single-energy calculation. A list or array of floats builds the scattering matrix at each energy independently; the plane-wave sets are zero-padded to the union of all energies' wave vectors (higher energies include more plane waves within the semiangle cutoff), and the result gains a leading :class:`.EnergyAxis` dimension. potential : Atoms or AbstractPotential, optional Atoms or a potential that the scattering matrix represents. If given as atoms, a default potential will be created. If nothing is provided the scattering matrix will represent a vacuum potential, in which case the sampling and extent must be provided. gpts : one or two int, optional Number of grid points describing the scattering matrix. Provide only if potential is not given. sampling : one or two float, optional Lateral sampling of scattering matrix [Å]. Provide only if potential is not given. Will be ignored if 'gpts' is also provided. extent : one or two float, optional Lateral extent of scattering matrix [Å]. Provide only if potential is not given. interpolation : one or two int, optional Interpolation factor in the `x` and `y` directions (default is 1, ie. no interpolation). If a single value is provided, assumed to be the same for both directions. upsample : bool, optional If True, interpolate the plane-wave expansion built at the given interpolation factor back to the full plane-wave expansion of the aperture and compress it by an exact adaptive truncated singular value decomposition (the C-PRISM algorithm); :meth:`.SMatrix.build` then returns a :class:`.CompressedSMatrixArray`. Every probe is reduced from the full expansion, avoiding the real-space cropping and coarsened aperture sampling errors of PRISM at the same interpolation factor: the interpolation factor only affects the number of multislice runs required to build the scattering matrix. At an interpolation factor of 1 the expansion is already complete and this option has no effect. Default is False. tolerance : float, optional Relative singular value threshold applied when ``upsample=True`` to the part of the interpolated scattering matrix that the built beams do not already span (default is 1e-3). Decrease for higher accuracy at increased cost of the reduction. Ignored when ``upsample=False``. Note that this does not set the rank on its own. The row space of the built beams is retained whole, which is what makes the plane-wave branch of the reduction the PRISM algorithm exactly, so the rank is at least the number of built beams however large the tolerance. Use ``max_rank`` to go below that. max_rank : int, optional Maximum number of modes retained by the compression when ``upsample=True``, keeping those carrying the largest amplitude. If None (default) every mode described above is retained. This is the parameter that trades accuracy for the memory and the reduction time, both of which are proportional to the rank: the modes are the bulk of the stored scattering matrix, and the reduction contracts them. It is worth setting when the beams outnumber the intrinsic dimensionality of the specimen, which is the case at small interpolation factors — halving the rank of a factor-2 expansion costs a few percent of the error on the cells measured, while at factor 4 there is little to no slack and truncating is expensive. Inspect :attr:`CompressedSMatrixArray.singular_values` to see where the spectrum of a given specimen falls off. blend_angle : float or str, optional Scattering angle [mrad] above which the reduction of the compressed scattering matrix follows the plane-wave (PRISM) reduction of the built beams, below which the interpolated (C-PRISM) reduction. Acts through the detector routing of the reduction: the angle is snapped down to a detector collection boundary and each detector reads the branch its band lies in, guaranteeing the dark-field bands match the PRISM algorithm. 'auto' (default with ``upsample=True``) derives the angle from the aliasing limit of the interpolation, ``extent / (2 * interpolation * thickness)``; a number fixes it; 0 disables blending. Only used when ``upsample=True``. window_gpts : one or two int or 'full', optional The number of grid points describing the cropping window of the wave functions reduced from the compressed scattering matrix. Only used when ``upsample=True``. If None (default), the window is inferred from the specimen and the probe (the probe tails plus the beam spreading over the thickness), falling back to the full grid when there is no potential; 'full' disables cropping. Unlike the PRISM cropping window, this window is decoupled from the interpolation factor. position_quantization : int, optional If given, the fractional part of the probe positions is quantized to this number of fractions of a pixel, limiting the number of reduction kernels calculated by the windowed compressed reduction for scans that are incommensurate with the grid of the scattering matrix. The maximum position error is half a quantization step. Only used when ``upsample=True``. The default is None, ie. the positions are not quantized. max_batch_expansion : int, optional The number of plane waves expanded at a time by the reduction of the compressed scattering matrix. By default the full plane-wave expansion is materialized once (fastest, but with the memory footprint of a PRISM scattering matrix at interpolation 1); providing a batch size streams the expansion instead, bounding the memory at one batch of plane waves plus one batch of reduced wave functions, at the cost of repeating the expansion for every batch of probe positions. Only used when ``upsample=True`` and the reduced wave functions are not cropped. downsample : {'cutoff', 'valid'} or float or bool Controls whether to downsample the scattering matrix after running the multislice algorithm. ``cutoff`` : Downsample to the antialias cutoff scattering angle (default). ``valid`` : Downsample to the largest rectangle that fits inside the circle with a radius defined by the antialias cutoff scattering angle. float : Downsample to a specified maximum scattering angle [mrad]. device : str, optional The calculations will be carried out on this device ('cpu' or 'gpu'). Default is 'cpu'. The default is determined by the user configuration. store_on_host : bool, optional If True, store the scattering matrix in host (cpu) memory so that the necessary memory is transferred as chunks to the device to run calculations (default is False). """ def __init__( self, semiangle_cutoff: float, energy: float | list | np.ndarray, potential: Atoms | BasePotential = None, gpts: int | tuple[int, int] = None, sampling: float | tuple[float, float] = None, extent: float | tuple[float, float] = None, interpolation: int | tuple[int, int] = 1, upsample: bool = False, tolerance: float = 1e-3, blend_angle: float | str = None, max_rank: int = None, window_gpts: int | tuple[int, int] = None, position_quantization: int = None, max_batch_expansion: int | str = "auto", downsample: bool | str = "cutoff", # tilt: Tuple[float, float] = (0.0, 0.0), device: str = None, store_on_host: bool = False, ): if downsample is True: downsample = "cutoff" self._device = validate_device(device) self._grid = Grid(extent=extent, gpts=gpts, sampling=sampling) if potential is None: try: self.grid.check_is_defined() except GridUndefinedError: raise ValueError("Provide a potential or provide 'extent' and 'gpts'.") else: potential = validate_potential(potential) self.grid.match(potential) self._grid = potential.grid self._potential = potential self._interpolation = _validate_interpolation(interpolation) self._semiangle_cutoff = semiangle_cutoff self._downsample = downsample if not upsample: invalid = tuple( name for name, value in ( ("max_rank", max_rank), ("blend_angle", blend_angle), ("position_quantization", position_quantization), ("window_gpts", window_gpts), ( "max_batch_expansion", None if max_batch_expansion == "auto" else max_batch_expansion, ), ) if value is not None ) if invalid: raise ValueError( f"{' and '.join(invalid)} require(s) upsample=True." ) self._upsample = bool(upsample) self._tolerance = tolerance # the blend against the plane-wave (PRISM) reduction is on by default: # it acts through the detector routing of the reduction, which uses the # interpolated wave functions below the blend angle and the plane-wave # reduction above it; pass 0 to disable if upsample and blend_angle is None: blend_angle = "auto" self._blend_angle = blend_angle self._max_rank = max_rank self._position_quantization = position_quantization self._max_batch_expansion = max_batch_expansion self._energies = np.atleast_1d(np.asarray(energy, dtype=float)).ravel() self._accelerator = Accelerator(energy=float(self._energies[0])) # self._beam_tilt = BeamTilt(tilt=tilt) # window_gpts is only used by the compressed (upsampled) reduction; it is # rejected above otherwise. Copies pass back the value given here rather # than the derived window (see :meth:`_copy_kwargs`), so a PRISM # scattering matrix round-trips without tripping that rejection. if isinstance(window_gpts, str): if window_gpts != "full": raise ValueError( f"window_gpts must be an int, a pair of ints, 'full' or " f"None (automatic); got {window_gpts!r}" ) elif window_gpts is not None: if np.isscalar(window_gpts): window_gpts = (int(window_gpts),) * 2 else: window_gpts = tuple(int(n) for n in window_gpts) if window_gpts == tuple(self.downsampled_gpts): # a window covering the full grid is no window; copies pass the # derived full-grid window back through this argument window_gpts = "full" elif max_batch_expansion != "auto": raise ValueError( "max_batch_expansion applies to the reduction of the expanded " "scattering matrix; it cannot be combined with window_gpts." ) self._window_gpts = window_gpts self._store_on_host = store_on_host assert semiangle_cutoff > 0.0 if not self._upsample and not all( n % f == 0 for f, n in zip(self.interpolation, self.gpts) ): warnings.warn( "The interpolation factor does not exactly divide 'gpts', normalization " "may not be exactly preserved." ) def _copy_kwargs(self, exclude: tuple[str, ...] = (), cls=None) -> dict: """The constructor arguments of this scattering matrix. ``window_gpts`` is passed back as it was given rather than as the derived cropping window: the window is derived from the specimen and the probe when it is not given, and copying the derived value would both pin it and make a copy of a PRISM scattering matrix (where the window is an internal quantity) look like a user request. """ kwargs = super()._copy_kwargs(exclude=exclude, cls=cls) if "window_gpts" in kwargs: kwargs["window_gpts"] = copy.deepcopy(self._window_gpts) return kwargs @property def base_shape(self) -> tuple[int, int, int]: """Shape of the base axes of the SMatrix.""" return len(self), self.gpts[0], self.gpts[1] @property def tilt(self): """The small-angle tilt of applied to the Fresnel propagator [mrad].""" return 0.0, 0.0
[docs] def round_gpts_to_interpolation(self) -> SMatrix: """ Round the gpts of the SMatrix to the closest multiple of the interpolation factor. Returns ------- s_matrix_with_rounded_gpts : SMatrix """ rounded = _round_gpts_to_multiple_of_interpolation( self.gpts, self.interpolation ) if rounded == self.gpts: return self self.gpts = rounded return self
@property def downsample(self) -> str | bool: """How to downsample the scattering matrix after running the multislice algorithm.""" return self._downsample @property def store_on_host(self) -> bool: """Store the SMatrix in host memory. The reduction may still be calculated on the device.""" return self._store_on_host @property def metadata(self): return {"energy": self.energy} @property def shape(self) -> tuple[int, ...]: """Shape of the SMatrix.""" return self.ensemble_shape + (len(self),) + self.gpts @property def ensemble_shape(self) -> tuple[int, ...]: """Shape of the SMatrix ensemble axes.""" energy_shape = (len(self._energies),) if len(self._energies) > 1 else () potential_shape = ( self.potential.ensemble_shape if self.potential is not None else () ) return energy_shape + potential_shape @property def ensemble_axes_metadata(self): """Axis metadata for each ensemble axis.""" energy_meta = ( [EnergyAxis(values=tuple(float(e) for e in self._energies))] if len(self._energies) > 1 else [] ) potential_meta = ( self.potential.ensemble_axes_metadata if self.potential is not None else [] ) return energy_meta + potential_meta def _with_energy(self, e: float) -> "SMatrix": """Return a single-energy clone of this SMatrix for use in multi-energy builds. The clone's ``_energies`` and ``_accelerator`` are set to *e* so that the normal single-energy :meth:`build` path is taken. The caller is responsible for zero-padding and stacking the resulting :class:`.SMatrixArray` objects into the union wave-vector basis. """ clone = self.copy() clone._energies = np.array([float(e)]) clone._accelerator = Accelerator(energy=float(e)) return clone @property def wave_vectors(self) -> np.ndarray: """The wave vectors of the plane-wave expansion. When upsampling, the coarse expansion spans a disk around the aperture (see :meth:`_coarse_mask`), padding it by a support margin so that the interpolation of the compressed modes is supported on all sides.""" self.grid.check_is_defined() self.accelerator.check_is_defined() if self._upsample_enabled: bounds = self._coarse_bounds() dtype = get_dtype(complex=False) n = np.arange(-bounds[0], bounds[0] + 1, dtype=dtype) m = np.arange(-bounds[1], bounds[1] + 1, dtype=dtype) w, h = self.extent kx = n / w * dtype(self.interpolation[0]) ky = m / h * dtype(self.interpolation[1]) kx, ky = np.meshgrid(kx, ky, indexing="ij") mask = self._coarse_mask() xp = get_array_module(self.device) return xp.asarray([kx.ravel()[mask], ky.ravel()[mask]]).T dummy_probes = self.dummy_probes(device="cpu") aperture = dummy_probes.aperture._evaluate_kernel(dummy_probes) indices = np.where(aperture > 0.0) n = np.fft.fftfreq(aperture.shape[0], d=1 / aperture.shape[0])[indices[0]] m = np.fft.fftfreq(aperture.shape[1], d=1 / aperture.shape[1])[indices[1]] w, h = self.extent dtype = get_dtype(complex=False) kx = n / w * dtype(self.interpolation[0]) ky = m / h * dtype(self.interpolation[1]) xp = get_array_module(self.device) return xp.asarray([kx, ky]).T @property def potential(self) -> BasePotential: """The potential described by the SMatrix.""" return self._potential @potential.setter def potential(self, potential: BasePotential): self._potential = potential self._grid = potential.grid @property def semiangle_cutoff(self) -> float: """Plane-wave expansion cutoff.""" return self._semiangle_cutoff @semiangle_cutoff.setter def semiangle_cutoff(self, value: float): self._semiangle_cutoff = value @property def interpolation(self) -> tuple[int, int]: return self._interpolation @property def upsample(self) -> bool: """Interpolate the coarse plane-wave expansion back to the full expansion of the aperture and compress it (the C-PRISM algorithm).""" return self._upsample @property def tolerance(self) -> float: """Relative singular value threshold applied to the part of the interpolated scattering matrix that the built beams do not already span. The row space of the built beams is retained whole regardless, so this does not lower the rank below their number — see :attr:`max_rank`.""" return self._tolerance @property def max_rank(self) -> int | None: """Maximum number of retained modes, or None to keep every one. The rank sets both the memory of the compressed scattering matrix and the cost of its reduction, and is the parameter to lower on a small device.""" return self._max_rank @property def position_quantization(self) -> int | None: """Quantization of the fractional probe positions in fractions of a pixel.""" return self._position_quantization @property def max_batch_expansion(self) -> int | str: """Number of plane waves expanded at a time by the reduction of the compressed scattering matrix; 'auto' materializes the full expansion.""" return self._max_batch_expansion @property def blend_angle(self) -> float | str | None: """Scattering angle [mrad] above which the reduction follows the plane-wave (PRISM) reduction of the built beams; 'auto' derives it from the aliasing limit of the interpolation, None disables blending.""" return self._blend_angle def _resolved_blend_angle(self) -> float | None: """The blend angle in mrad, resolving 'auto' from the aliasing limit. An electron scattered to an angle theta drifts theta * t laterally over the thickness t, and the band-limited interpolation aliases once the drift exceeds half its period extent / interpolation. The interpolated reduction is therefore trusted up to:: theta_max = min_i extent_i / (2 * interpolation_i * t) """ if self._blend_angle is None: return None if not isinstance(self._blend_angle, str): return float(self._blend_angle) if self._blend_angle == "aperture": return "aperture" if self._blend_angle != "auto": raise ValueError( f"blend_angle must be a number, 'auto', 'aperture' or None; " f"got {self._blend_angle!r}" ) thickness = self.potential.thickness if self.potential is not None else 0.0 if thickness <= 0.0: return None # the beams are referenced to the middle of the specimen, which centres # the tilt spectrum on zero: the drift the interpolation must resolve # spans +/- theta t / 2 rather than [0, theta t], so the same beams # reach twice the angle (see :meth:`_reference_depth`) centred = 2.0 if self._reference_depth > 0.0 else 1.0 angle = 1e3 * min( centred * extent / (2.0 * interpolation * thickness) for extent, interpolation in zip(self.extent, self.interpolation) ) if angle < self.semiangle_cutoff: # blending below the bright-field disk imports the periodized ghost # probes of the plane-wave reduction; clamp to the aperture edge and # warn that the interpolation is aliased inside the disk itself warnings.warn( "The interpolation of the compressed scattering matrix is " f"aliased above {angle:.1f} mrad, inside the bright-field disk " f"({self.semiangle_cutoff:.1f} mrad): the interpolation factor " "is too large for this thickness and the accuracy will be " "degraded at every angle. The blend is clamped to the aperture " "edge." ) return "aperture" # theta_max bounds where the interpolation is *valid*, which is the # right default when the goal is accuracy against multislice: below it # the extra beams carry real information and usually beat the sparse # plane-wave sampling. It does not, however, promise the interpolated # reduction beats PRISM on every band below it. Pass # ``blend_angle='aperture'`` to confine the interpolation to the # bright-field disk, which trades some low-angle accuracy for # PRISM-or-better on every dark-field band of any specimen. return angle @property def _upsample_enabled(self) -> bool: """The compression applies only when the coarse expansion is incomplete; at an interpolation factor of (1, 1) the scattering matrix is identical to the PRISM scattering matrix.""" return self._upsample and self.interpolation != (1, 1) def _wave_vector_chunks(self, max_batch): if isinstance(max_batch, int): max_batch = max_batch * reduce(operator.mul, self.gpts) chunks = validate_chunks( shape=(len(self),) + self.gpts, chunks=("auto", -1, -1), max_elements=max_batch, dtype=np.dtype("complex64"), device=self.device, ) return chunks @property def downsampled_gpts(self) -> tuple[int, int]: """The gpts of the SMatrix after downsampling. When upsampling, the downsampled gpts are independent of the interpolation factor, hence probe positions commensurate with the grid remain commensurate at any interpolation.""" if self.downsample: downsampled_gpts = self._gpts_within_angle(self.downsample) if self._upsample: return tuple(n + (-n) % 4 for n in downsampled_gpts) rounded = _round_gpts_to_multiple_of_interpolation( downsampled_gpts, self.interpolation ) return rounded else: return self.gpts # empirical extent of the reduced wave functions, calibrated against # multislice on thick cells: the exit wave spreads by roughly twice # thickness x aperture through multiple scattering, and the aperture-limited # probe carries tails of several Airy lobes _WINDOW_SPREAD_FACTOR = 2.0 _WINDOW_TAIL_LOBES = 6.0 def _auto_window_gpts(self): """The cropping window inferred from the specimen and the probe, or None (the full grid) when there is no potential to infer it from.""" if self._potential is None: return None thickness = self._potential.thickness alpha = self._semiangle_cutoff * 1e-3 if alpha <= 0.0 or thickness is None: return None half_extent = ( self._WINDOW_SPREAD_FACTOR * thickness * alpha + self._WINDOW_TAIL_LOBES * self.wavelength / alpha ) window = () for extent, gpts, interpolation in zip( self.extent, self.downsampled_gpts, self.interpolation ): n = int(np.ceil(2.0 * half_extent / (extent / gpts) / 16.0)) * 16 # at a window of exactly one period the reduction loses its # bright-field advantage over PRISM (measured +7% at any # thickness); at 1.75 periods the bright-field error reaches its # floor (measured 0.06% on the Ge benchmark cell, where 1.4 # periods gives 0.2-0.4%) period = safe_ceiling_int(gpts / interpolation) n = max(n, int(np.ceil(1.75 * period / 16.0)) * 16) window += (min(n, gpts),) return window @property def window_gpts(self): """The number of grid points describing the cropping window of the reduced wave functions.""" if self._upsample: if self._window_gpts == "full": return self.downsampled_gpts if self._window_gpts is None: window = self._auto_window_gpts() return self.downsampled_gpts if window is None else window return ( min(self._window_gpts[0], self.downsampled_gpts[0]), min(self._window_gpts[1], self.downsampled_gpts[1]), ) return ( safe_ceiling_int(self.downsampled_gpts[0] / self.interpolation[0]), safe_ceiling_int(self.downsampled_gpts[1] / self.interpolation[1]), ) @property def window_extent(self): sampling = ( self.extent[0] / self.downsampled_gpts[0], self.extent[1] / self.downsampled_gpts[1], ) return ( self.window_gpts[0] * sampling[0], self.window_gpts[1] * sampling[1], )
[docs] def multislice( self, potential=None, lazy: bool = None, max_batch: int | str = "auto", ): """ Parameters ---------- potential lazy : bool, optional If True, create the wave functions lazily, otherwise, calculate instantly. If not given, defaults to the setting in the user configuration file. max_batch : int or str, optional The number of expansion plane waves in each run of the multislice algorithm. Returns ------- """ s_matrix = self.__class__( potential=potential, **self._copy_kwargs(exclude=("potential",)) ) return s_matrix.build(lazy=lazy, max_batch=max_batch)
@property def _default_ensemble_chunks(self): return self.potential._default_ensemble_chunks def _partition_args(self, chunks=(1,), lazy: bool = True): if self.potential is not None: return self.potential._partition_args(chunks, lazy=lazy) else: array = np.empty((), dtype=object) if lazy: array = da.from_array(array, chunks=1) return (array,) @staticmethod def _s_matrix(*args, potential_partial, **kwargs): potential = potential_partial(*args).item() s_matrix = SMatrix(potential=potential, **kwargs) return _wrap_with_array(s_matrix) def _from_partitioned_args(self, *args, **kwargs): if self.potential is not None: potential_partial = self.potential._from_partitioned_args() kwargs = self._copy_kwargs(exclude=("potential", "sampling", "extent")) else: def potential_partial(*args, **kwargs): return _wrap_with_array(None, 1) # potential_partial = lambda *args, **kwargs: _wrap_with_array(None, 1) kwargs = self._copy_kwargs(exclude=("potential",)) return partial(self._s_matrix, potential_partial=potential_partial, **kwargs) @staticmethod def _wave_vector_blocks(wave_vector_chunks, lazy: bool = True): wave_vector_blocks = chunk_ranges(wave_vector_chunks)[0] array = np.zeros(len(wave_vector_blocks), dtype=object) for i, wave_vector_block in enumerate(wave_vector_blocks): itemset(array, i, wave_vector_block) if lazy: array = da.from_array(array, chunks=1) return array @staticmethod def _build_s_matrix(s_matrix, wave_vector_range=slice(None), pbar: bool = False): if isinstance(s_matrix, np.ndarray): s_matrix = s_matrix.item() if isinstance(wave_vector_range, np.ndarray): wave_vector_range = slice(*wave_vector_range.item()) xp = get_array_module(s_matrix.device) wave_vectors = xp.asarray(s_matrix.wave_vectors, dtype=xp.float32) array = plane_waves( wave_vectors[wave_vector_range], s_matrix.extent, s_matrix.gpts ) array *= np.prod(s_matrix.interpolation) / np.prod(array.shape[-2:]) waves = Waves( array, energy=s_matrix.energy, extent=s_matrix.extent, ensemble_axes_metadata=[ OrdinalAxis(values=wave_vectors[wave_vector_range]) ], ) if s_matrix.potential is not None: waves = multislice_and_detect( waves, s_matrix.potential, [WavesDetector()], pbar=pbar )[0] if s_matrix.downsampled_gpts != s_matrix.gpts: waves.metadata["adjusted_antialias_cutoff_gpts"] = ( waves.antialias_cutoff_gpts ) waves = waves.downsample( gpts=s_matrix.downsampled_gpts, normalization="intensity", ) if s_matrix.store_on_host and s_matrix.device == "gpu": waves = waves.to_cpu() return waves.array def _dense_indices(self) -> np.ndarray: return _dense_wave_vector_indices( self.extent, self.downsampled_gpts, self.energy, self.semiangle_cutoff ) def _coarse_bounds(self) -> tuple[int, int]: dense_indices = self._dense_indices() bounds = () for i in range(2): n_max = int(np.abs(dense_indices[:, i]).max()) bounds += (-(n_max // -self.interpolation[i]) + 1,) return bounds def _coarse_mask(self) -> np.ndarray: """Boolean mask over the raveled coarse bounding rectangle selecting the beams that are built (run through the multislice algorithm). Only the beams within a support disk around the aperture are kept: those with a normalized radius (unity at the aperture edge) within one coarse cell, or a fixed margin, of the aperture. The far corners of the bounding rectangle are dropped. At a coarse interpolation the grid under-samples the scattering matrix at those corners, and including them makes the trigonometric interpolation overfit; dropping them both reduces the number of multislice runs and improves the interpolation, most strongly at large interpolation factors where the corners dominate the rectangle. """ bounds = self._coarse_bounds() dense_indices = self._dense_indices() n_max = [max(1, int(np.abs(dense_indices[:, i]).max())) for i in range(2)] n = np.arange(-bounds[0], bounds[0] + 1) m = np.arange(-bounds[1], bounds[1] + 1) radius_x = (n[:, None] * self.interpolation[0]) / n_max[0] radius_y = (m[None, :] * self.interpolation[1]) / n_max[1] radius = np.sqrt(radius_x**2 + radius_y**2) cell = max(self.interpolation[0] / n_max[0], self.interpolation[1] / n_max[1]) keep_radius = 1.0 + max(cell, _COARSE_SUPPORT_MARGIN) return (radius <= keep_radius).ravel() def _coarse_fill_indices(self) -> np.ndarray: """For every position of the coarse bounding rectangle, the index (into the built, disk-masked beams) of the nearest built beam. The dropped corners of the rectangle are filled by the nearest built beam rather than by zeros, so that the trigonometric interpolation extends the scattering matrix smoothly into the corners instead of dropping a discontinuity there. In particular a rank-one (vacuum) scattering matrix, constant over the built beams, is filled to a constant and reconstructed exactly. """ from scipy import ndimage bounds = self._coarse_bounds() shape = (2 * bounds[0] + 1, 2 * bounds[1] + 1) kept = self._coarse_mask().reshape(shape) nearest = ndimage.distance_transform_edt( ~kept, return_distances=False, return_indices=True ) nearest_flat = (nearest[0] * shape[1] + nearest[1]).ravel() # map a flat rectangle index to its position in the built (kept) order rectangle_to_kept = np.full(int(np.prod(shape)), -1, dtype=int) rectangle_to_kept[np.flatnonzero(kept.ravel())] = np.arange(int(kept.sum())) return rectangle_to_kept[nearest_flat] def _interpolate_beam_functions(self, functions, dense_indices) -> np.ndarray: """Trigonometric interpolation of functions of the coarse plane waves (given as rows over the coarse rectangle) to the dense plane-wave expansion. The trigonometric interpolant is band limited: it does not alias the interpolation error to displaced copies of the probe. A local (spline) interpolant leaks such attenuated ghost probes displaced by extent/interpolation, which an annular detector integrates as a large error even when the interpolant is more accurate in the mean-square sense. """ xp = get_array_module(functions) bounds = self._coarse_bounds() shape = (2 * bounds[0] + 1, 2 * bounds[1] + 1) # the built beams span a disk, not the full rectangle; extend them into # the rectangle expected by the (rectangular) fft by filling the dropped # corners with the nearest built beam (smooth, and exact for a rank-one # scattering matrix) rather than with zeros num_rectangle = int(np.prod(shape)) if functions.shape[-1] != num_rectangle: fill_indices = xp.asarray(self._coarse_fill_indices()) functions = functions[..., fill_indices] functions = functions.reshape((-1,) + shape) coefficients = xp.fft.fft2(functions, axes=(-2, -1)) coefficients /= get_dtype(complex=False)(np.prod(shape)) dtype = self._complex_dtype kernels = () for i, (bound, length) in enumerate(zip(bounds, shape)): frequencies = xp.fft.fftfreq(length, d=1 / length).astype(int) dense_coordinate = ( xp.arange( -bound * self.interpolation[i], bound * self.interpolation[i] + 1 ) / self.interpolation[i] ) kernels += ( complex_exponential( 2.0 * xp.pi * (dense_coordinate[:, None] + bound) * frequencies[None] / length ).astype(dtype), ) interpolated = xp.tensordot(coefficients, kernels[0], axes=[[-2], [-1]]) interpolated = xp.tensordot(interpolated, kernels[1], axes=[[-2], [-1]]) offset = (bounds[0] * self.interpolation[0], bounds[1] * self.interpolation[1]) return interpolated[ :, dense_indices[:, 0] + offset[0], dense_indices[:, 1] + offset[1] ] @property def _reference_depth(self) -> float: """The depth the beams are referenced to for the interpolation [Å]. The tilt dependence of a beam is a shear of the specimen: scattering at depth ``z`` to a frequency ``q`` displaces laterally by ``lambda (t - z) q`` before it reaches the exit surface, so the interpolated function of the tilt has a spectrum occupying ``[0, lambda t q]`` — one sided, anchored at zero. Trigonometric interpolation resolves a spectrum spanning one period ``extent / interpolation`` *centred* on zero, hence half of that budget is spent on an empty half interval. Referencing the beams to the middle of the specimen (a Fresnel propagation, undone after the compression) centres the spectrum on zero and doubles the scattering angle the same beams interpolate without aliasing. """ thickness = self.potential.thickness if self.potential is not None else 0.0 return 0.5 * thickness def _defocus_phase(self, wave_vectors) -> np.ndarray: """The propagation (defocus) phase :math:`\\exp(-i \\pi \\lambda t |k|^2)` accumulated by each plane wave propagating through the cell of thickness :math:`t`. Factoring this phase out of the scattering matrix before the interpolation (and adding it back on the dense plane waves) references the beams to the entrance surface, where the probe is focused. It flattens the quadratic phase variation of the phase-removed scattering matrix across the aperture, which the coarse interpolation would otherwise have to capture, improving the accuracy at no additional cost. In vacuum, or for a zero-thickness potential, the phase is unity and the compression is unchanged. """ thickness = self.potential.thickness if self.potential is not None else 0.0 wavelength = self.wavelength squared_wave_vectors = wave_vectors[..., 0] ** 2 + wave_vectors[..., 1] ** 2 return complex_exponential( -np.pi * wavelength * (thickness - self._reference_depth) * squared_wave_vectors ) def _reference_propagator(self, gpts, xp, sign: float): """The Fresnel propagator moving the beams to the reference depth. The beams are given on the downsampled grid, whose sampling follows from their own number of grid points rather than from this object. """ sampling = tuple(e / n for e, n in zip(self.extent, gpts)) kx, ky = spatial_frequencies(gpts, sampling, xp=xp) squared = kx[:, None] ** 2 + ky[None] ** 2 return complex_exponential( sign * np.pi * self.wavelength * self._reference_depth * squared ).astype(get_dtype(complex=True)) def _compress(self, array) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Phase removal, interpolation to the dense plane-wave expansion and exact truncated SVD of the interpolated operator. The plane-wave tilt phase and the propagation (defocus) phase are factored out of each beam before the interpolation, then added back on the dense plane waves (see :meth:`_defocus_phase`). The phase-removed scattering matrix is factored as :math:`T = L Q` with orthonormal :math:`Q`; the interpolation acts on the small beam-side factor :math:`L`, hence the singular value decomposition of the interpolated operator is obtained exactly without ever forming it. """ xp = get_array_module(array) dtype = self._complex_dtype gpts = array.shape[-2:] extent = self.extent wave_vectors = self.wave_vectors real_dtype = get_dtype(complex=False) x = xp.linspace(0, extent[0], gpts[0], endpoint=False, dtype=real_dtype) y = xp.linspace(0, extent[1], gpts[1], endpoint=False, dtype=real_dtype) array = array.reshape((-1,) + tuple(gpts)) # reference the beams to the middle of the specimen, which centres the # tilt spectrum the interpolation has to resolve (see # :meth:`_reference_depth`). The propagator does not commute with the # tilt ramp, so it cannot be undone beam by beam; it is left in place # and the reduction inherits it, which is exact because the propagator # is the same for every beam: the reduced wave is the exit wave of the # probe propagated to the same depth. Diffraction patterns are # unchanged by it, and a cropped one is improved — the drift of the # reduced wave is halved, so less of it is lost to the window. if self._reference_depth > 0.0: # IN BATCHES OVER BEAMS. Transforming the whole matrix at once # needs two further copies of it -- fft2's output and the product # -- so the peak is three times the scattering matrix. On the 100 A # Pt/C cell at f=8 that is 3 x 15.8 GB and the build cannot run on # a 46 GB card, even though the matrix itself is only a third of # it. The transform and the phase are independent per beam, so # batching is exact and the result is written back in place. propagator = self._reference_propagator(gpts, xp, 1.0)[None] reference_batch = self._row_batch_size( np.prod(gpts), dtype, self._EXPANSION_BATCH_BYTES ) for start in range(0, len(array), reference_batch): stop = min(start + reference_batch, len(array)) array[start:stop] = ifft2(fft2(array[start:stop]) * propagator) # the conjugate of the propagation phase flattens the quadratic phase # variation of the phase-removed matrix; it is added back below defocus_phase = self._defocus_phase(wave_vectors).conj() normalization = np.prod(self.interpolation).astype(real_dtype) max_batch = self._expansion_batch_size(np.prod(gpts)) for start in range(0, len(array), max_batch): chunk = slice(start, start + max_batch) phase = complex_exponential( -2.0 * xp.pi * wave_vectors[chunk, 0, None, None] * x[:, None] ) * complex_exponential( -2.0 * xp.pi * wave_vectors[chunk, 1, None, None] * y[None, :] ) array[chunk] *= phase * defocus_phase[chunk, None, None] / normalization matrix = array.reshape((len(array), -1)) # T = L Q with the rows of Q orthonormal. The orthonormal factor is # obtained from the Gram matrix G = T T^H rather than a QR # factorization: the tall QR is far slower than the two matrix products # this costs (measured 5-14x) and materializes Q, a full extra copy of # the scattering matrix, whereas here Q is contracted on the fly. The # Gram matrix is accumulated in double precision independently of # ``config['precision']``: its eigenvalues are the SQUARES of the # singular values, so single precision would resolve the spectrum only # to ~1e-3 relative, well above the smallest supported tolerance. The # eigenvalues near the round-off floor (the coarse expansion is rank # deficient) are dropped. gram = xp.zeros((len(matrix), len(matrix)), dtype=np.complex128) pixel_batch = self._expansion_batch_size(len(matrix)) for start in range(0, matrix.shape[1], pixel_batch): chunk = matrix[:, start : start + pixel_batch].astype(np.complex128) gram += chunk @ chunk.T.conj() eigenvalues, eigenvectors = xp.linalg.eigh(gram) eigenvalues = xp.clip(eigenvalues[::-1], 0.0, None) eigenvectors = eigenvectors[:, ::-1] # a round-off floor only: the tolerance must not truncate here, or the # row space of the built beams is already incomplete before the # interpolation and the plane-wave branch stops being exact keep = max(1, int((eigenvalues > eigenvalues[0] * 1e-14).sum())) singular_values = xp.sqrt(eigenvalues[:keep]) # L = V diag(s) and Q = diag(1 / s) V^H T, with T = L Q exact on the # retained subspace beam_factor = (eigenvectors[:, :keep] * singular_values[None]).astype(dtype) dense_indices = self._dense_indices() # the interpolated operator T_dense = (P L) Q shares the pixel-side # factor Q, hence its exact SVD follows from the small matrix P L, # obtained by interpolating the columns of L over the coarse plane waves projected = self._interpolate_beam_functions( xp.ascontiguousarray(beam_factor.T), xp.asarray(dense_indices) ).T projected = xp.ascontiguousarray(projected.astype(dtype)) # The retained subspace is chosen in two parts rather than by singular # value alone. The plane-wave (PRISM) branch of the reduction uses only # the rows of the dense expansion that coincide with built beams, so # their row space is retained WHOLE; the leading directions of what is # left over are then retained by tolerance. This makes the plane-wave # branch exact at any tolerance — the blended reduction is bounded by # PRISM on every band it covers — while the tolerance still controls the # cost of the interpolated part. moment = projected.conj().T @ projected def leading(hermitian, threshold): values, vectors = xp.linalg.eigh(hermitian) values = xp.clip(values[::-1], 0.0, None) count = int((xp.sqrt(values) >= threshold).sum()) return vectors[:, ::-1][:, :count].T.conj(), xp.sqrt(values) _, spectrum = leading(moment, 0.0) largest = float(spectrum[0]) if len(spectrum) else 0.0 lattice = (dense_indices[:, 0] % self._interpolation[0] == 0) & ( dense_indices[:, 1] % self._interpolation[1] == 0 ) # the floor sits above the single-precision noise of the coarse matrix # (~1e-7 relative) and far below any usable tolerance, so the row space # is captured whole without admitting round-off directions lattice_rows = projected[xp.asarray(lattice)] lattice_basis, _ = leading( lattice_rows.conj().T @ lattice_rows, largest * 1e-6 ) # what the built beams do not already span, by tolerance residual = xp.eye(moment.shape[0], dtype=dtype) residual = residual - lattice_basis.conj().T @ lattice_basis extra_basis, _ = leading( residual @ moment @ residual, self._tolerance * largest ) w = xp.concatenate([lattice_basis, extra_basis], axis=0) # order the retained modes by how much of the expansion they carry, so # that a rank cap keeps the largest and the reported spectrum descends amplitudes = xp.ascontiguousarray((projected @ w.conj().T).T) order = xp.argsort(xp.linalg.norm(amplitudes, axis=1))[::-1] if self._max_rank is not None: order = order[: max(1, self._max_rank)] w, amplitudes = w[order], xp.ascontiguousarray(amplitudes[order]) rank = max(1, len(w)) # U = W Q = (W diag(1 / s) V^H) T without ever materializing Q project = ( w * (1.0 / singular_values)[None].astype(dtype) ) @ eigenvectors[:, :keep].T.conj().astype(dtype) u = (project @ matrix).reshape((rank,) + tuple(gpts)) # the dense plane-wave amplitudes of each retained mode sigma = xp.linalg.norm(amplitudes, axis=1) vh_dense = amplitudes / xp.clip(sigma, 1e-30, None)[:, None] singular_values = spectrum # add the propagation phase back on the dense plane waves dense_wave_vectors = xp.asarray(dense_indices, dtype=real_dtype) dense_wave_vectors = dense_wave_vectors / xp.asarray( extent, dtype=real_dtype ) vh_dense = vh_dense * self._defocus_phase(dense_wave_vectors)[None].astype( dtype ) return ( u, sigma[:rank].astype(get_dtype(complex=False)), vh_dense.astype(dtype), dense_indices, singular_values.astype(get_dtype(complex=False)), )
[docs] def build( self, lazy: bool = None, max_batch: int | str = "auto", bound: bool = None ) -> SMatrixArray | CompressedSMatrixArray: """ Build the plane waves of the scattering matrix and propagate them through the potential using the multislice algorithm. When ``upsample=True``, the scattering matrix is subsequently compressed by phase removal, interpolation to the full plane-wave expansion and an adaptive truncated singular value decomposition, and a :class:`.CompressedSMatrixArray` is returned. The multislice stage may be computed lazily, however, the compression requires the scattering matrix in memory, hence the returned :class:`.CompressedSMatrixArray` is always computed. At an interpolation factor of (1, 1) the plane-wave expansion is complete, hence the compression provides no benefit and the uncompressed :class:`.SMatrixArray` is returned; the reduction is then identical to the PRISM algorithm. Parameters ---------- lazy : bool, optional If True, create the wave functions lazily, otherwise, calculate instantly. If not given, defaults to the setting in the user configuration file. max_batch : int or str, optional The number of expansion plane waves in each run of the multislice algorithm. Returns ------- s_matrix_array : SMatrixArray or CompressedSMatrixArray The built scattering matrix. """ if self._upsample_enabled and len(self._energies) > 1: raise NotImplementedError( "SMatrix.build does not support multiple energies with " "upsample=True: the compressed expansion is an energy-specific " "SVD basis, so the per-energy bases have different ranks and " "cannot be stacked into one array. Use SMatrix.reduce or " "SMatrix.scan, which reduce each energy separately and stack " "the measurements along an EnergyAxis." ) if self._upsample_enabled and np.prod(self.ensemble_shape) > 1: raise NotImplementedError( "SMatrix.build does not support ensemble potentials with " "upsample=True; use SMatrix.reduce or SMatrix.scan, which average " "over the potential ensemble." ) lazy = validate_lazy(lazy) # --- Multi-energy path --- if len(self._energies) > 1: results = [ self._with_energy(float(e)).build(lazy=lazy, max_batch=max_batch) for e in self._energies ] # Wave-vector counts differ per energy (higher energy → more plane waves # within the semiangle cutoff). The sets are nested subsets, so the # result with the most wave vectors is the union. n_wvs = [r.array.shape[0] for r in results] max_idx = int(np.argmax(n_wvs)) union_wave_vectors = results[max_idx].wave_vectors n_union = len(union_wave_vectors) # Build a fast lookup: (qx, qy) → union index union_wv_dict = { (float(q[0]), float(q[1])): i for i, q in enumerate(union_wave_vectors) } def _embed_wave_vectors(arr, indices, n_union): """Embed arr (n_wv, ...) into (n_union, ...) at the given indices.""" out = np.zeros((n_union,) + arr.shape[1:], dtype=arr.dtype) out[indices] = arr return out embedded_arrays = [] for r in results: if r.array.shape[0] == n_union: embedded_arrays.append(r.array) else: indices = np.array( [union_wv_dict[(float(q[0]), float(q[1]))] for q in r.wave_vectors] ) if isinstance(r.array, da.Array): # _embed_wave_vectors assumes it receives the whole # per-energy array in one call (indices/n_union are # sized to the full wave-vector axis); force a single # chunk along that axis so map_blocks cannot invoke it # once per pre-existing block with only a chunk-sized # arr. array = r.array.rechunk({0: -1}) new_chunks = (n_union,) + array.chunks[1:] embedded = array.map_blocks( _embed_wave_vectors, dtype=array.dtype, chunks=new_chunks, indices=indices, n_union=n_union, ) else: embedded = _embed_wave_vectors(r.array, indices, n_union) embedded_arrays.append(embedded) stacked_array = da.stack(embedded_arrays, axis=0) energy_ax = EnergyAxis(values=tuple(float(e) for e in self._energies)) return SMatrixArray( array=stacked_array, wave_vectors=union_wave_vectors, semiangle_cutoff=self.semiangle_cutoff, energy=None, interpolation=self.interpolation, extent=self.extent, window_gpts=results[0].window_gpts, device=self.device, ensemble_axes_metadata=[energy_ax] + results[0].ensemble_axes_metadata, metadata=results[0].metadata, ) # --- Single-energy path (unchanged) --- downsampled_gpts = self.downsampled_gpts s_matrix_blocks = self.ensemble_blocks(1) xp = get_array_module(self.device) wave_vector_chunks = self._wave_vector_chunks(max_batch) if lazy: wave_vector_blocks = self._wave_vector_blocks( wave_vector_chunks, lazy=False ) if not hasattr(s_matrix_blocks, "len"): s_matrix_blocks = s_matrix_blocks[None] if self.potential is not None and self.potential.ensemble_shape: s_matrix_blocks = s_matrix_blocks[0] wave_vector_blocks = np.tile( wave_vector_blocks[None], (len(s_matrix_blocks), 1) ) wave_vector_blocks = da.from_array(wave_vector_blocks, chunks=1) from dask.graph_manipulation import bind if bound is not None: wave_vector_blocks = bind(wave_vector_blocks, bound) adjust_chunks = { 1: wave_vector_chunks[0], 2: (downsampled_gpts[0],), 3: (downsampled_gpts[1],), } symbols = (0, 1, 2, 3) if self.potential is None or not self.potential.ensemble_shape: symbols = symbols[1:] pbar = config.get("diagnostics.task_progress", False) array = da.blockwise( self._build_s_matrix, symbols, s_matrix_blocks, (0,), wave_vector_blocks[..., None, None], (0, 1, 2, 3), concatenate=True, adjust_chunks=adjust_chunks, pbar=pbar, meta=xp.array((), dtype=get_dtype(complex=True)), ) else: wave_vector_blocks = self._wave_vector_blocks( wave_vector_chunks, lazy=False ) if self.store_on_host: array = np.zeros( self.ensemble_shape + (len(self),) + self.downsampled_gpts, dtype=np.complex64, ) else: array = xp.zeros( self.ensemble_shape + (len(self),) + self.downsampled_gpts, dtype=np.complex64, ) pbar = config.get("diagnostics.task_progress", False) for i, _, s_matrix in self.generate_blocks(1): s_matrix = s_matrix.item() for start, stop in wave_vector_blocks: items = (slice(start, stop),) if self.ensemble_shape: items = i + items new_array = self._build_s_matrix( s_matrix, slice(start, stop), pbar=pbar ) if self.store_on_host: new_array = xp.asnumpy(new_array) array[items] = new_array waves = Waves( array, energy=self.energy, extent=self.extent, ensemble_axes_metadata=self.ensemble_axes_metadata + self.base_axes_metadata[:1], ) if self.downsampled_gpts != self.gpts: waves.metadata["adjusted_antialias_cutoff_gpts"] = _antialias_cutoff_gpts( self.window_gpts, self.sampling ) s_matrix_array = SMatrixArray._from_waves( waves, wave_vectors=self.wave_vectors, interpolation=self.interpolation, semiangle_cutoff=self.semiangle_cutoff, window_gpts=self.window_gpts, device=self.device, ) if not self._upsample_enabled: return s_matrix_array compress_array = s_matrix_array.array if s_matrix_array.is_lazy: compress_array = compress_array.compute() metadata = dict(s_matrix_array.metadata) pbar = config.get("diagnostics.task_progress", False) if pbar: print( f"compressing scattering matrix: {len(self)} plane waves " f"interpolated to {len(self._dense_indices())}", flush=True, ) u, sigma, vh_dense, dense_indices, singular_values = self._compress( compress_array ) if pbar: print(f"kept {len(sigma)} modes at tolerance {self._tolerance:g}") return CompressedSMatrixArray( u=u, sigma=sigma, vh_dense=vh_dense, dense_indices=dense_indices, semiangle_cutoff=self.semiangle_cutoff, energy=self.energy, extent=self.extent, interpolation=self.interpolation, window_gpts=self.window_gpts, position_quantization=self._position_quantization, max_batch_expansion=self._max_batch_expansion, blend_angle=self._resolved_blend_angle(), device=self.device, metadata=metadata, singular_values=singular_values, reference_depth=self._reference_depth, )
[docs] def scan( self, scan: np.ndarray | BaseScan = None, detectors: BaseDetector | list[BaseDetector] = None, ctf: CTF | dict = None, max_batch_multislice: str | int = "auto", max_batch_reduction: str | int = "auto", reduction_scheme: str = "auto", disable_s_matrix_chunks: bool = "auto", lazy: bool = None, ) -> BaseMeasurements | Waves | list[BaseMeasurements | Waves]: """ Run the multislice algorithm, then reduce the SMatrix using coefficients calculated by a BaseScan and a CTF, to obtain the exit wave functions at given initial probe positions and aberrations. Parameters ---------- scan : BaseScan Positions of the probe wave functions. If not given, scans across the entire potential at Nyquist sampling. detectors : BaseDetector, list of BaseDetector, optional A detector or a list of detectors defining how the wave functions should be converted to measurements after running the multislice algorithm. See abtem.measurements.detect for a list of implemented detectors. ctf : CTF Contrast transfer function from used for calculating the expansion coefficients in the reduction of the SMatrix. max_batch_multislice : int, optional The number of wave functions in each chunk of the Dask array. If 'auto' (default), the batch size is automatically chosen based on the abTEM user configuration settings "dask.chunk-size" and "dask.chunk-size-gpu". max_batch_reduction : int or str, optional Number of positions per reduction operation. A large number of positions better utilize thread parallelization, but requires more memory and floating point operations. If 'auto' (default), the batch size is automatically chosen based on the abtem user configuration settings "dask.chunk-size" and "dask.chunk-size-gpu". reduction_scheme : str or tuple of int, optional Parallel reduction of the SMatrix requires rechunking the Dask array from chunking along the expansion axis to chunking over the spatial axes. If given as a tuple of int of length the SMatrix is rechunked to have those chunks. If 'auto' (default) the chunks are taken to be identical to the interpolation factor. disable_s_matrix_chunks : bool, optional If True, each S-Matrix is kept as a single chunk, thus lowering the communication overhead, but providing fewer opportunities for parallelization. lazy : bool, optional If True, create the measurements lazily, otherwise, calculate instantly. If None, this defaults to the value set in the configuration file. Returns ------- detected_waves : BaseMeasurements or list of BaseMeasurements The detected measurement (if detector(s) given). exit_waves : Waves Wave functions at the exit plane(s) of the potential (if no detector(s) given). """ if scan is None: scan = GridScan( start=(0, 0), end=self.extent, sampling=self.dummy_probes().aperture.nyquist_sampling, ) if detectors is None: detectors = FlexibleAnnularDetector() return self.reduce( scan=scan, detectors=detectors, max_batch_reduction=max_batch_reduction, max_batch_multislice=max_batch_multislice, ctf=ctf, reduction_scheme=reduction_scheme, disable_s_matrix_chunks=disable_s_matrix_chunks, lazy=lazy, )
def _build_ensemble_shape_metadata(self): extra_ensemble_axes_shape = () extra_ensemble_axes_metadata = [] for shape, axis_metadata in zip( self.ensemble_shape, self.ensemble_axes_metadata ): extra_ensemble_axes_metadata += [axis_metadata] if axis_metadata._ensemble_mean: extra_ensemble_axes_shape += (1,) else: extra_ensemble_axes_shape += (shape,) if self.potential is not None and len(self.potential.exit_planes) > 1: extra_ensemble_axes_shape = extra_ensemble_axes_shape + ( len(self.potential.exit_planes), ) extra_ensemble_axes_metadata = extra_ensemble_axes_metadata + [ self.potential.base_axes_metadata[0] ] return extra_ensemble_axes_shape, extra_ensemble_axes_metadata def _eager_transition_potential_scan( self, scan, detectors, transition_potentials, sites, double_channel, inelastic_crop=None, squeeze=True, ): from abtem.inelastic.core_loss import prism_transition_potential_scan extra_ensemble_axes_shape, extra_ensemble_axes_metadata = ( self._build_ensemble_shape_metadata() ) if self.ensemble_shape: dummy_waves = self.build(lazy=True).dummy_probes(scan) measurements = allocate_multislice_measurements( dummy_waves, detectors, extra_ensemble_axes_shape, extra_ensemble_axes_metadata, ) else: measurements = None num_blocks = 0 for i, _, s_matrix in self.generate_blocks(1): s_matrix = s_matrix.item() new_measurements = ensure_list( prism_transition_potential_scan( s_matrix=s_matrix, transition_potentials=transition_potentials, scan=scan, detectors=detectors, sites=sites, double_channel=double_channel, inelastic_crop=inelastic_crop, ) ) if measurements is None: measurements = new_measurements else: for measurement, new_measurement in zip( measurements, new_measurements ): if measurement.axes_metadata[0]._ensemble_mean: measurement.array[:] += new_measurement.array else: measurement.array[i] = new_measurement.array num_blocks += 1 for idx, measurement in enumerate(measurements): if measurement.axes_metadata[0]._ensemble_mean: if num_blocks > 1: measurement.array[:] /= num_blocks if squeeze: measurements[idx] = measurement.squeeze((0,)) return measurements @staticmethod def _lazy_transition_potential_scan( s_matrix, scan, detectors, transition_potentials, sites, double_channel, inelastic_crop=None, ): s_matrix = s_matrix.item() measurements = s_matrix._eager_transition_potential_scan( scan=scan, detectors=detectors, transition_potentials=transition_potentials, sites=sites, double_channel=double_channel, inelastic_crop=inelastic_crop, squeeze=False, ) array = np.zeros((1,) + (1,) * len(scan.shape), dtype=object) itemset(array, 0, measurements) return array
[docs] def transition_potential_scan( self, transition_potentials, scan=None, detectors=None, sites=None, double_channel: bool = False, inelastic_crop=None, lazy: bool = None, ): """**Experimental** PRISM-based core-loss scan. Mirrors :meth:`Probe.transition_potential_scan` but uses the S-matrix plane-wave decomposition instead of running a full multislice per scan position. Supports any ``interpolation`` factor and both single- and double-channel modes. At ``interpolation=(1, 1)`` the result is bit-equivalent to ``Probe.transition_potential_scan`` (float32 noise) against the matching ``double_channel`` setting. At ``interpolation > 1`` the reduced wave functions are returned at ``window_gpts`` size, matching the elastic :meth:`scan` convention. See :func:`abtem.inelastic.core_loss.prism_transition_potential_scan` for the algorithm details. Parameters ---------- transition_potentials : BaseTransitionPotential Atomic transition potential (single instance). scan : BaseScan or tuple, optional Scan positions. Defaults to a ``GridScan`` over the full extent at Nyquist sampling, mirroring :meth:`scan`. detectors : BaseDetector or list, optional Detectors. Defaults to a ``FlexibleAnnularDetector``. sites : Atoms or SliceIndexedAtoms, optional Scattering sites. Auto-extracted from the potential if not given. double_channel : bool, optional If True, propagate the scattered wave through the remaining potential slices to the exit before detection (matching the multislice EELS ``double_channel=True`` branch). If False (default), detect immediately at the scatter slice — Brown's single-channel approximation. inelastic_crop : float or tuple of float, optional Real-space side length [Å] of the window on which the transition potential and scattered wave are evaluated (Brown et al. Sec. IV B). Smaller windows speed up the scatter and double-channel propagation at the cost of truncating the transition-potential tails. Defaults to ``None`` (the full PRISM cell, ``extent / interpolation``). Values larger than the PRISM cell are clamped with a warning. lazy : bool, optional If True, create the measurements lazily using Dask; otherwise, compute eagerly. Defaults to the user configuration value. Returns ------- BaseMeasurements or list of BaseMeasurements One measurement per detector. """ from abtem.inelastic.core_loss import ( prism_transition_potential_scan, ) if scan is None: scan = GridScan( start=(0, 0), end=self.extent, sampling=self.dummy_probes().aperture.nyquist_sampling, ) detectors = validate_detectors(detectors) scan = validate_scan(scan, self) lazy = validate_lazy(lazy) if not lazy: measurements = self._eager_transition_potential_scan( scan=scan, detectors=detectors, transition_potentials=transition_potentials, sites=sites, double_channel=double_channel, inelastic_crop=inelastic_crop, ) return _wrap_measurements(measurements) blocks = self.ensemble_blocks(1) chunks = () drop_axis = () if not self.ensemble_shape: blocks = blocks[None] drop_axis = (0,) new_axis = tuple_range(offset=0, length=len(scan.shape)) else: chunks += blocks.chunks new_axis = tuple_range( offset=len(blocks.shape), length=len(scan.shape) ) chunks += scan.shape arrays = blocks.map_blocks( self._lazy_transition_potential_scan, drop_axis=drop_axis, new_axis=new_axis, chunks=chunks, scan=scan, detectors=detectors, transition_potentials=transition_potentials, sites=sites, double_channel=double_channel, inelastic_crop=inelastic_crop, meta=np.array((), dtype=object), ) waves = self.build(lazy=True).dummy_probes(scan=scan) extra_axes_metadata = [] if self.potential is not None: extra_axes_metadata = self.potential.ensemble_axes_metadata measurements = _finalize_lazy_measurements( arrays, waves, detectors, extra_axes_metadata ) return _wrap_measurements(measurements)
def _eager_build_s_matrix_detect(self, scan, ctf, detectors, squeeze): extra_ensemble_axes_shape, extra_ensemble_axes_metadata = ( self._build_ensemble_shape_metadata() ) detectors = validate_detectors(detectors) if self.ensemble_shape: if self._upsample_enabled: # building would compute the compression of a single ensemble # member eagerly (and raises for ensemble potentials); the # builder's dummy probes carry the same grid and metadata dummy_probes = self.dummy_probes(scan, ctf) else: dummy_probes = self.build(lazy=True).dummy_probes(scan, ctf) measurements = allocate_multislice_measurements( dummy_probes, detectors, extra_ensemble_axes_shape, extra_ensemble_axes_metadata, ) else: measurements = None num_blocks = 0 for i, _, s_matrix in self.generate_blocks(1): s_matrix = s_matrix.item() s_matrix_array = s_matrix.build(lazy=False) new_measurements = s_matrix_array.reduce( scan=scan, detectors=detectors, ctf=ctf ) new_measurements = ensure_list(new_measurements) if measurements is None: measurements = new_measurements else: for measurement, new_measurement in zip(measurements, new_measurements): if measurement.axes_metadata[0]._ensemble_mean: measurement.array[:] += new_measurement.array else: measurement.array[i] = new_measurement.array num_blocks += 1 # measurements = list(measurements.values()) for i, measurement in enumerate(measurements): if ( measurement.axes_metadata and measurement.axes_metadata[0]._ensemble_mean ): if num_blocks > 1: measurement.array[:] /= num_blocks if squeeze: measurements[i] = measurement.squeeze((0,)) return measurements @staticmethod def _lazy_build_s_matrix_detect(s_matrix, scan, ctf, detectors): s_matrix = s_matrix.item() measurements = s_matrix._eager_build_s_matrix_detect( scan=scan, ctf=ctf, detectors=detectors, squeeze=False ) # measurements = ensure_list(measurements) array = np.zeros((1,) + (1,) * len(scan.shape), dtype=object) itemset(array, 0, measurements) return array
[docs] def reduce( self, scan: np.ndarray | BaseScan = None, detectors: BaseDetector | list[BaseDetector] = None, ctf: CTF | dict = None, reduction_scheme: str = "auto", max_batch_multislice: str | int = "auto", max_batch_reduction: str | int = "auto", disable_s_matrix_chunks: bool = "auto", lazy: bool = None, ) -> BaseMeasurements | Waves | list[BaseMeasurements | Waves]: """ Run the multislice algorithm, then reduce the SMatrix using coefficients calculated by a BaseScan and a CTF, to obtain the exit wave functions at given initial probe positions and aberrations. Parameters ---------- scan : BaseScan Positions of the probe wave functions. If not given, scans across the entire potential at Nyquist sampling. detectors : BaseDetector, list of BaseDetector, optional A detector or a list of detectors defining how the wave functions should be converted to measurements after running the multislice algorithm. See abtem.measurements.detect for a list of implemented detectors. ctf : CTF Contrast transfer function from used for calculating the expansion coefficients in the reduction of the SMatrix. max_batch_multislice : int, optional The number of wave functions in each chunk of the Dask array. If 'auto' (default), the batch size is automatically chosen based on the abTEM user configuration settings "dask.chunk-size" and "dask.chunk-size-gpu". max_batch_reduction : int or str, optional Number of positions per reduction operation. A large number of positions better utilize thread parallelization, but requires more memory and floating point operations. If 'auto' (default), the batch size is automatically chosen based on the abtem user configuration settings "dask.chunk-size" and "dask.chunk-size-gpu". reduction_scheme : str, optional Parallel reduction of the SMatrix requires rechunking the Dask array from chunking along the expansion axis to chunking over the spatial axes. If given as a tuple of int of length the SMatrix is rechunked to have those chunks. If 'auto' (default) the chunks are taken to be identical to the interpolation factor. disable_s_matrix_chunks : bool, optional If True, each S-Matrix is kept as a single chunk, thus lowering the communication overhead, but providing fewer opportunities for parallelization. lazy : bool, optional If True, create the measurements lazily, otherwise, calculate instantly. If None, this defaults to the value set in the configuration file. Returns ------- measurements : BaseMeasurements or Waves or list of BaseMeasurements or list of Waves The detected measurement (if detector(s) given). """ # --- Multi-energy path --- # The reduction contracts away the expansion axis, so each energy can be # reduced independently and only the measurements are stacked. This needs # no shared expansion basis across energies, hence it works for both the # plane-wave (PRISM) and the compressed (C-PRISM) reduction. if len(self._energies) > 1: # Resolve the scan once against the whole ensemble: the Nyquist # sampling of a default scan is wavelength-dependent, so validating # it separately per energy would give each energy a different number # of probe positions and the measurements could not be stacked. if scan is None: scan = (self.extent[0] / 2, self.extent[1] / 2) scan = validate_scan(scan, self) results = [ self._with_energy(float(e)).reduce( scan=scan, detectors=detectors, ctf=ctf, reduction_scheme=reduction_scheme, max_batch_multislice=max_batch_multislice, max_batch_reduction=max_batch_reduction, disable_s_matrix_chunks=disable_s_matrix_chunks, lazy=lazy, ) for e in self._energies ] energy_axis = EnergyAxis( values=tuple(float(e) for e in self._energies) ) if isinstance(results[0], (list, ComputableList)): return _wrap_measurements( [ stack([r[i] for r in results], energy_axis) for i in range(len(results[0])) ] ) return stack(results, energy_axis) if self._upsample_enabled: # the compressed scattering matrix spans the full downsampled grid detectors = validate_detectors(detectors, self.dummy_probes()) else: detectors = validate_detectors( detectors, self.dummy_probes(downsample=False) ) if scan is None: scan = (self.extent[0] / 2, self.extent[1] / 2) lazy = validate_lazy(lazy) if ctf is None: ctf = CTF(semiangle_cutoff=self.semiangle_cutoff) elif isinstance(ctf, dict): ctf = CTF(semiangle_cutoff=self.semiangle_cutoff, **ctf) if self._upsample_enabled: # the compression requires the scattering matrix in memory, hence each # member of the potential ensemble is built and reduced as one task disable_s_matrix_chunks = True elif self.device == "gpu" and disable_s_matrix_chunks == "auto": disable_s_matrix_chunks = True elif disable_s_matrix_chunks == "auto": disable_s_matrix_chunks = False if not lazy: scan = validate_scan(scan, self) measurements = self._eager_build_s_matrix_detect( scan, ctf, detectors, squeeze=True ) return _wrap_measurements(measurements) if disable_s_matrix_chunks: scan = validate_scan(scan, self) blocks = self.ensemble_blocks(1) chunks = () drop_axis = () if not self.ensemble_shape: blocks = blocks[None] # expand 0-d to 1-d so drop_axis=(0,) is valid drop_axis = (0,) new_axis = tuple_range( offset=0, length=len(scan.shape) + len(ctf.ensemble_shape) ) else: chunks += blocks.chunks new_axis = tuple_range( offset=len(blocks.shape), length=len(scan.shape) + len(ctf.ensemble_shape), ) chunks += ctf.ensemble_shape + scan.shape arrays = blocks.map_blocks( self._lazy_build_s_matrix_detect, drop_axis=drop_axis, new_axis=new_axis, chunks=chunks, scan=scan, ctf=ctf, detectors=detectors, meta=np.array((), dtype=object), ) if self._upsample_enabled: # building would compute the compression eagerly; the builder's # dummy probes carry the same grid and metadata waves = self.dummy_probes(scan=scan) else: waves = self.build(lazy=True).dummy_probes(scan=scan) extra_axes_metadata = [] if self.potential is not None: extra_axes_metadata = self.potential.ensemble_axes_metadata extra_axes_metadata = extra_axes_metadata + ctf.ensemble_axes_metadata measurements = _finalize_lazy_measurements( arrays, waves, detectors, extra_axes_metadata ) return _wrap_measurements(measurements) s_matrix_array = self.build(max_batch=max_batch_multislice, lazy=lazy) return s_matrix_array.reduce( scan=scan, detectors=detectors, reduction_scheme=reduction_scheme, max_batch_reduction=max_batch_reduction, ctf=ctf, )