from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Optional, Protocol, runtime_checkable
import numpy as np
from ase import Atoms
from scipy.spatial.transform import Rotation as R # type: ignore
from abtem.atoms import plane_to_axes
from abtem.bloch.dynamical import equal_slice_thicknesses
from abtem.core.fft import fft_interpolate
from abtem.inelastic.phonons import BaseFrozenPhonons
from abtem.magnetism.iam import (
BaseMagneticField,
BaseVectorPotential,
MagneticFieldArray,
VectorPotentialArray,
)
from abtem.magnetism.utils import bohr_magneton, vacuum_permeability
from abtem.potentials.charge_density import curl_fourier, integrate_gradient_fourier
from abtem.potentials.gpaw import GPAWPotential
from abtem.potentials.iam import PotentialArray, _FieldBuilder
def _calculate_non_periodic_magnetic_vector_potential():
# A_np = mu_0 * M x r
pass
[docs]
def calculate_constant_magnetic_field():
# B_avg = mu_0 * M
pass
def _apply_rotation_matrix(
vector_field: np.ndarray, rotation_matrix: np.ndarray
) -> np.ndarray:
shape = vector_field.shape[1:]
vector_field_reshaped = vector_field.reshape(3, -1)
rotated_field_reshaped = rotation_matrix @ vector_field_reshaped
return rotated_field_reshaped.reshape((3,) + shape)
[docs]
def rotate_vector_field(
vector_field: np.ndarray, euler_angles: tuple[float, float, float]
) -> np.ndarray:
"""
Rotate a 3D vector field defined on a grid using Euler angles.
Parameters
----------
vector_field : numpy.ndarray
3xNxMxK array representing the 3D vector field.
euler_angles : tuple
Euler angles (xyz) for the rotation.
Returns
-------
rotated_field : numpy.ndarray
Rotated 3D vector field.
"""
rotation_matrix = R.from_euler("xyz", euler_angles).as_matrix()
return _apply_rotation_matrix(vector_field, rotation_matrix)
[docs]
def calculate_magnetic_vector_potential(spin_density, cell):
m = np.stack(
[np.zeros_like(spin_density), np.zeros_like(spin_density), spin_density]
)
j = bohr_magneton * curl_fourier(m, cell)
A = -vacuum_permeability * integrate_gradient_fourier(j, cell)
return A
[docs]
def get_vector_potential_from_gpaw(calc, gridrefinement=2, assume_colinear=True):
if not assume_colinear:
raise NotImplementedError("Non-collinear calculations not supported.")
n = calc.get_all_electron_density(spin=True, gridrefinement=gridrefinement)
rho = n[0][0] - n[0][1]
A = calculate_magnetic_vector_potential(rho, calc.atoms.cell)
return A
[docs]
def get_magnetic_field_from_gpaw(calc, gridrefinement=2, assume_colinear=True):
if not assume_colinear:
raise NotImplementedError("Non-collinear calculations not supported.")
A = get_vector_potential_from_gpaw(calc, gridrefinement=gridrefinement)
B = curl_fourier(A, calc.atoms.cell)
return B
#: Sentinel default for `rotate_field`: automatically rotate the
#: largest-magnitude in-plane component into z (see
#: `_auto_rotation_matrix_for_vector_field`). Pass an explicit Euler-angle
#: tuple to pick a specific orientation, or `None` to disable rotation and
#: see the raw (Az == 0) output.
_AUTO_ROTATE_FIELD = "auto"
#: Swaps x into z: (Ax, Ay, 0) -> (0, Ay, -Ax). Euler angles (0, pi/2, 0).
_ROTATION_X_INTO_Z = R.from_euler("xyz", (0.0, np.pi / 2, 0.0)).as_matrix()
#: Swaps y into z: (Ax, Ay, 0) -> (Ax, 0, Ay). Euler angles (pi/2, 0, 0).
_ROTATION_Y_INTO_Z = R.from_euler("xyz", (np.pi / 2, 0.0, 0.0)).as_matrix()
def _auto_rotation_matrix_for_vector_field(vector_field: np.ndarray) -> np.ndarray:
"""
Rotation matrix that swaps whichever of the in-plane (x, y) components
of `vector_field` has the larger aggregate magnitude into z.
`calculate_magnetic_vector_potential` always builds the magnetization as
m = (0, 0, rho): collinear spin has no real-space direction, so GPAW's
internal spin axis is arbitrary. Because curl(m) and the subsequent
Poisson solve are applied component-wise, this makes the z-component of
`vector_field` (and hence the only component `adjust_coulomb_potential`
uses) identically zero for every collinear calculation, not just some.
Only the two 90-degree swaps (x into z, or y into z) are considered,
not an arbitrary in-plane rotation angle: x, y and z are the only
directions with a physical meaning here (the orthogonal axes of the
simulation cell), so a continuous "optimal" blend of Ax and Ay has no
real-space interpretation as a magnetization direction -- it would just
fit whatever numerical asymmetry happens to be in the grid.
"""
Ax = vector_field[0].astype(np.float64, copy=False)
Ay = vector_field[1].astype(np.float64, copy=False)
Sxx = float(np.sum(Ax * Ax))
Syy = float(np.sum(Ay * Ay))
return _ROTATION_X_INTO_Z if Sxx >= Syy else _ROTATION_Y_INTO_Z
def _check_unsupported_ensemble_params(frozen_phonons, repetitions):
if frozen_phonons is not None:
raise NotImplementedError(
"frozen_phonons is not supported for magnetic fields/vector "
"potentials; build the field from a single calculator and combine "
"it with an electrostatic FrozenPhonons ensemble instead."
)
if tuple(repetitions) != (1, 1, 1):
raise NotImplementedError(
"repetitions is not supported for magnetic fields/vector "
"potentials; build the field for a single unit cell and call "
".tile() on the resulting array instead."
)
[docs]
@runtime_checkable
class GPAW(Protocol):
@property
def atoms(self) -> Atoms:
...
[docs]
def get_number_of_grid_points(self) -> np.ndarray:
...
class _GPAWMagnetics(_FieldBuilder):
def __init__(
self,
calculators: GPAW | list[GPAW] | list[str] | str,
array_object,
quantity: str = "magnetic_field",
projection: str = "fft",
gpts: Optional[int | tuple[int, int]] = None,
sampling: Optional[float | tuple[float, float]] = None,
slice_thickness: float | tuple[float, ...] = 1.0,
exit_planes: Optional[int | tuple[int, ...]] = None,
plane: str = "xy",
rotate_field: Optional[tuple[float, float, float]] | str = _AUTO_ROTATE_FIELD,
origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
box: Optional[tuple[float, float, float]] = None,
periodic: bool = True,
frozen_phonons: Optional[BaseFrozenPhonons] = None,
repetitions: tuple[int, int, int] = (1, 1, 1),
gridrefinement: int = 4,
device: Optional[str] = None,
assume_colinear: bool = True,
):
if not assume_colinear:
raise NotImplementedError("Non-collinear calculations not supported.")
_check_unsupported_ensemble_params(frozen_phonons, repetitions)
self.gridrefinement = gridrefinement
assert isinstance(calculators, GPAW)
self._calculators = calculators
atoms = calculators.atoms
cell = atoms.cell
self._rotate_field = rotate_field
if projection == "real_space" and isinstance(slice_thickness, (float, int)):
n_z = calculators.get_number_of_grid_points()[2] * gridrefinement
slice_thickness = float(slice_thickness)
axes = plane_to_axes(plane)
depth = np.diag(cell)[axes[2]]
slice_thickness, n_per_slice = equal_slice_thicknesses(
n_z, slice_thickness, depth=depth
)
elif projection == "real_space":
raise NotImplementedError(
"Non-uniform slice thicknesses not supported for real-space projection."
)
self._projection = projection
self._quantity = quantity
super().__init__(
array_object=array_object,
gpts=gpts,
cell=cell,
sampling=sampling,
slice_thickness=slice_thickness,
exit_planes=exit_planes,
device=device,
plane=plane,
origin=origin,
box=box,
periodic=periodic,
)
@property
def num_configurations(self):
return 1
@property
def base_axes_metadata(self):
pass
@property
def plane(self):
assert isinstance(self._plane, str)
return self._plane
def generate_slices(self, first_slice: int = 0, last_slice: Optional[int] = None):
if last_slice is None:
last_slice = self.num_slices
vector_potential = get_vector_potential_from_gpaw(
self._calculators, gridrefinement=self.gridrefinement
)
if self._quantity == "vector_potential":
array = vector_potential
elif self._quantity == "magnetic_field":
array = curl_fourier(vector_potential, self._calculators.atoms.cell)
else:
raise ValueError(f"Unknown quantity: {self._quantity}")
if self.plane != "xy":
axes = plane_to_axes(self.plane)
moved_axes = (axes[0] + 1, axes[1] + 1)
array = np.moveaxis(array, moved_axes, (1, 2))[axes, ...]
vector_potential = np.moveaxis(vector_potential, moved_axes, (1, 2))[
axes, ...
]
rotate_field = self._rotate_field
if isinstance(rotate_field, str) and rotate_field == _AUTO_ROTATE_FIELD:
rotation_matrix = _auto_rotation_matrix_for_vector_field(vector_potential)
array = _apply_rotation_matrix(array, rotation_matrix)
elif rotate_field:
array = rotate_vector_field(array, rotate_field)
slice_thicknesses = np.array(self.slice_thickness)
slice_shape = (3,) + self._valid_gpts
if self._projection == "real_space":
depth = self._calculators.atoms.cell[2, 2]
pixels_per_slice = (slice_thicknesses / depth * array.shape[-1]).astype(int)
dz = slice_thicknesses.sum() / array.shape[-1]
start = 0
for i, slice_idx in enumerate(range(first_slice, last_slice)):
slice_array = (
array[..., start : start + pixels_per_slice[i]].sum(-1) * dz
)
start += pixels_per_slice[i]
if self._valid_gpts != slice_array.shape[1:]:
slice_array = fft_interpolate(slice_array, slice_shape)
yield self._array_object(
slice_array[None],
extent=self.extent,
slice_thickness=slice_thicknesses[i],
)
else:
shape = array.shape[:-1] + (self.num_slices,)
array = fft_interpolate(array, shape)
for i, slice_idx in enumerate(range(first_slice, last_slice)):
slice_array = array[..., slice_idx]
if self._valid_gpts != slice_array.shape[1:]:
slice_array = fft_interpolate(slice_array, slice_shape)
yield self._array_object(
slice_array[None],
extent=self.extent,
slice_thickness=slice_thicknesses[i],
)
def build(
self,
first_slice: int = 0,
last_slice: Optional[int] = None,
max_batch: int | str = 1,
lazy: Optional[bool] = None,
):
if lazy:
raise ValueError("Lazy not supported for magnetics.")
return super().build(
first_slice=first_slice,
last_slice=last_slice,
max_batch=max_batch,
lazy=False,
)
[docs]
class GPAWMagneticField(_GPAWMagnetics, BaseMagneticField):
def __init__(
self,
calculators: GPAW | list[GPAW] | list[str] | str,
gpts: Optional[int | tuple[int, int]] = None,
sampling: Optional[float | tuple[float, float]] = None,
slice_thickness: float | tuple[float, ...] = 1.0,
exit_planes: Optional[int | tuple[int, ...]] = None,
plane: str = "xy",
rotate_field: Optional[tuple[float, float, float]] | str = _AUTO_ROTATE_FIELD,
origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
box: Optional[tuple[float, float, float]] = None,
periodic: bool = True,
frozen_phonons: Optional[BaseFrozenPhonons] = None,
repetitions: tuple[int, int, int] = (1, 1, 1),
gridrefinement: int = 1,
projection: str = "fft",
device: Optional[str] = None,
):
_check_unsupported_ensemble_params(frozen_phonons, repetitions)
super().__init__(
calculators=calculators,
array_object=MagneticFieldArray,
quantity="magnetic_field",
gpts=gpts,
sampling=sampling,
slice_thickness=slice_thickness,
exit_planes=exit_planes,
device=device,
plane=plane,
rotate_field=rotate_field,
origin=origin,
box=box,
gridrefinement=gridrefinement,
projection=projection,
periodic=periodic,
)
[docs]
class GPAWVectorPotential(_GPAWMagnetics, BaseVectorPotential):
def __init__(
self,
calculators: GPAW | list[GPAW] | list[str] | str,
gpts: Optional[int | tuple[int, int]] = None,
sampling: Optional[float | tuple[float, float]] = None,
slice_thickness: float | tuple[float, ...] = 1.0,
exit_planes: Optional[int | tuple[int, ...]] = None,
plane: str = "xy",
rotate_field: Optional[tuple[float, float, float]] | str = _AUTO_ROTATE_FIELD,
origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
box: Optional[tuple[float, float, float]] = None,
periodic: bool = True,
frozen_phonons: Optional[BaseFrozenPhonons] = None,
repetitions: tuple[int, int, int] = (1, 1, 1),
gridrefinement: int = 1,
projection: str = "fft",
device: Optional[str] = None,
):
_check_unsupported_ensemble_params(frozen_phonons, repetitions)
super().__init__(
calculators=calculators,
array_object=VectorPotentialArray,
quantity="vector_potential",
gpts=gpts,
sampling=sampling,
slice_thickness=slice_thickness,
exit_planes=exit_planes,
device=device,
plane=plane,
rotate_field=rotate_field,
origin=origin,
box=box,
gridrefinement=gridrefinement,
projection=projection,
periodic=periodic,
)
[docs]
@dataclass
class GPAWMagneticFields:
"""
Bundles the electrostatic potential, magnetic vector potential and
(optionally) magnetic field built from the same GPAW calculator(s) by
`gpaw_magnetic_fields`.
`potential` may carry a frozen-phonon ensemble axis (or, after tiling,
come from a `CrystalPotential` build); `vector_potential` and
`magnetic_field` are always for a single, rigid configuration -- see
`_check_unsupported_ensemble_params`. Use `.tile()` to bring the
magnetic components up to a repeated crystal's size, and
`.combined_potential()` to fold the vector potential into an
electrostatic potential via `adjust_coulomb_potential`.
"""
potential: PotentialArray
vector_potential: VectorPotentialArray
magnetic_field: Optional[MagneticFieldArray] = None
[docs]
def tile(
self, repetitions: tuple[int, int] | tuple[int, int, int]
) -> "GPAWMagneticFields":
"""
Tile `vector_potential` (and `magnetic_field`, if present) to match
a separately tiled/repeated electrostatic potential, e.g. built via
`abtem.CrystalPotential`.
`potential` is left untouched here -- tile or rebuild it separately
(e.g. `CrystalPotential(electrostatic_ensemble, repetitions=...)`)
before calling `combined_potential`.
"""
return replace(
self,
vector_potential=self.vector_potential.tile(repetitions),
magnetic_field=(
self.magnetic_field.tile(repetitions)
if self.magnetic_field is not None
else None
),
)
[docs]
def combined_potential(
self, energy: float, potential: Optional[PotentialArray] = None
) -> PotentialArray:
"""
Combine an electrostatic potential with `vector_potential` via
`VectorPotentialArray.adjust_coulomb_potential`.
Parameters
----------
energy : float
Electron energy [eV].
potential : PotentialArray, optional
The electrostatic potential to combine with `vector_potential`.
Defaults to `self.potential`; pass a separately
tiled/ensembled potential (e.g. a `CrystalPotential` build)
after calling `.tile()` for the frozen-phonon workflow.
Returns
-------
PotentialArray
"""
if potential is None:
potential = self.potential
return self.vector_potential.adjust_coulomb_potential(potential, energy=energy)
[docs]
def show(
self,
tile: tuple[int, int] = (1, 1),
figsize: tuple[int, int] = (8, 8),
):
"""
Show side-by-side projections of the electrostatic potential and
the x and z components of the vector potential -- and, if built,
the magnetic field. `Az`/`Bz` are the components that matter for
`combined_potential`; `Ax`/`Bx` are shown alongside for a sanity
check that the in-plane and z-swapped components look sensible
relative to each other.
Parameters
----------
tile : two int, optional
Tile the projected images before plotting, e.g. to preview the
periodicity of a repeated unit cell.
figsize : two int, optional
Figure size passed to `matplotlib.pyplot.figure`.
Returns
-------
fig : matplotlib.figure.Figure
"""
import matplotlib.pyplot as plt
from mpl_toolkits.axes_grid1 import ImageGrid
panels = [
(self.potential.project(), "$V$", "$Å^{-3}$"),
(self.vector_potential.project()[0], "$A_x$", "ÅT"),
(self.vector_potential.project()[2], "$A_z$", "ÅT"),
]
if self.magnetic_field is not None:
panels += [
(self.magnetic_field.project()[0], "$B_x$", "T"),
(self.magnetic_field.project()[2], "$B_z$", "T"),
]
# Create the figure with pyplot's interactive auto-display off, then
# return it: otherwise Jupyter's inline backend renders it once from
# the auto-display hook and a second time from the returned value,
# showing the same plot twice.
with plt.ioff():
fig = plt.figure(figsize=figsize)
grid = ImageGrid(
fig,
111,
nrows_ncols=(1, len(panels)),
cbar_mode="edge",
cbar_location="bottom",
cbar_pad=0.1,
cbar_size=0.1,
axes_pad=0.1,
)
for ax, (image, name, unit) in zip(grid, panels):
array = np.asarray(image.tile(tile).array)
if np.abs(array).max() < 1e-5:
vmin, vmax = -1e-5, 1e-5
else:
vmin, vmax = None, None
im = ax.imshow(array.T, vmin=vmin, vmax=vmax, origin="lower")
ax.set_title(name)
ax.cax.colorbar(im, label=unit)
ax.xaxis.set_visible(False)
ax.yaxis.set_visible(False)
return fig
[docs]
def gpaw_magnetic_fields(
calculators: GPAW | list[GPAW] | list[str] | str,
gpts: Optional[int | tuple[int, int]] = None,
sampling: Optional[float | tuple[float, float]] = None,
slice_thickness: float | tuple[float, ...] = 1.0,
exit_planes: Optional[int | tuple[int, ...]] = None,
plane: str = "xy",
origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
box: Optional[tuple[float, float, float]] = None,
periodic: bool = True,
frozen_phonons: Optional[BaseFrozenPhonons] = None,
rotate_field: Optional[tuple[float, float, float]] | str = _AUTO_ROTATE_FIELD,
include_magnetic_field: bool = False,
magnetic_calculator: Optional[GPAW] = None,
device: Optional[str] = None,
lazy: Optional[bool] = None,
potential_kwargs: Optional[dict] = None,
field_kwargs: Optional[dict] = None,
) -> GPAWMagneticFields:
"""
Build the electrostatic potential, magnetic vector potential and
(optionally) magnetic field from the same GPAW calculator(s) in one
call.
`frozen_phonons` (an ensemble of atomic-displacement configurations) is
only supported for the electrostatic `potential` -- the magnetic
components come from a single, rigid ab initio calculation and cannot
vary per configuration. Tile the returned object with `.tile()` to
match a separately built, possibly-ensembled electrostatic potential
(e.g. from `abtem.CrystalPotential`), then combine with
`.combined_potential()`.
Parameters
----------
calculators : (list of) gpaw.calculator.GPAW or (list of) str
One or more converged GPAW calculators (or paths to `.gpw` files).
Forwarded to `GPAWPotential`. If a list (a frozen-phonon ensemble),
`magnetic_calculator` must be given explicitly, since
`GPAWVectorPotential`/`GPAWMagneticField` only support a single
calculator.
gpts : one or two int, optional
Forwarded to all built components. See `GPAWPotential`.
sampling : one or two float, optional
Forwarded to all built components. See `GPAWPotential`.
slice_thickness : float or sequence of float, optional
Forwarded to all built components. See `GPAWPotential`.
exit_planes : int or tuple of int, optional
Forwarded to all built components. See `GPAWPotential`.
plane : str or two tuples of three float, optional
Forwarded to all built components. See `GPAWPotential`.
origin : three float, optional
Forwarded to all built components. See `GPAWPotential`.
box : three float, optional
Forwarded to all built components. See `GPAWPotential`.
periodic : bool
Forwarded to all built components. See `GPAWPotential`.
device : str, optional
Forwarded to all built components. See `GPAWPotential`.
frozen_phonons : BaseFrozenPhonons, optional
Forwarded to `GPAWPotential` only.
rotate_field : tuple of three float, "auto", or None
Forwarded to `GPAWVectorPotential`/`GPAWMagneticField`. Defaults to
`"auto"`: automatically swap the larger-magnitude in-plane
component into z (see `_auto_rotation_matrix_for_vector_field`).
include_magnetic_field : bool
If True, also build the magnetic field `B` (not used by
`combined_potential`, only for inspection/visualization). Roughly
doubles the GPAW-side cost of the magnetic part, so it is off by
default.
magnetic_calculator : gpaw.calculator.GPAW, optional
The single calculator representing the (rigid) magnetic
contribution. Defaults to `calculators` when that is a single
calculator; required when `calculators` is a list.
lazy : bool, optional
Passed to the electrostatic potential's `.build()`. The magnetic
components are always built eagerly, since
`GPAWVectorPotential`/`GPAWMagneticField` do not support lazy
building.
potential_kwargs, field_kwargs : dict, optional
Extra keyword arguments forwarded only to `GPAWPotential`, or only
to `GPAWVectorPotential`/`GPAWMagneticField`, respectively (e.g.
their differing `gridrefinement` defaults).
Returns
-------
GPAWMagneticFields
"""
if isinstance(calculators, (list, tuple)):
if magnetic_calculator is None:
raise ValueError(
"calculators is a list (a frozen-phonon ensemble); "
"GPAWVectorPotential/GPAWMagneticField only support a "
"single calculator. Pass magnetic_calculator explicitly to "
"pick which one represents the (rigid) magnetic "
"contribution."
)
elif magnetic_calculator is None:
magnetic_calculator = calculators
potential_kwargs = dict(potential_kwargs or {})
field_kwargs = dict(field_kwargs or {})
shared = dict(
gpts=gpts,
sampling=sampling,
slice_thickness=slice_thickness,
exit_planes=exit_planes,
plane=plane,
origin=origin,
box=box,
periodic=periodic,
device=device,
)
potential = GPAWPotential(
calculators,
frozen_phonons=frozen_phonons,
**shared,
**potential_kwargs,
).build(lazy=lazy)
if not lazy:
potential = potential.compute()
vector_potential = (
GPAWVectorPotential(
magnetic_calculator,
rotate_field=rotate_field,
**shared,
**field_kwargs,
)
.build()
.compute()
)
magnetic_field = None
if include_magnetic_field:
magnetic_field = (
GPAWMagneticField(
magnetic_calculator,
rotate_field=rotate_field,
**shared,
**field_kwargs,
)
.build()
.compute()
)
return GPAWMagneticFields(
potential=potential,
vector_potential=vector_potential,
magnetic_field=magnetic_field,
)
[docs]
class SpinDensityMagneticField:
def __init__(self, spin_density, cell):
raise NotImplementedError