"""
generic fields
=======================
This module implements the generic ``Field`` class, and some perfect fields
(Offset, Gradient, Quadrupole). Those are abstract classes, and actual implementations
are gathered in other packages
See also
---------
atomsmltr.environment.fields.magnetic
"""
# % IMPORTS
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
from abc import abstractmethod
# % LOCAL IMPORTS
from ..envbase import EnvObject
from ...utils.infostring import InfoString
# % ABSTRACT CLASSES
[docs]
class Field(EnvObject):
"""A generic class to describe Field objects."""
def __init__(self, *args, **kwargs):
super(Field, self).__init__(*args, **kwargs)
@property
def vector(self):
return True
@property
@abstractmethod
def unit():
"""str: returns the unit of the field"""
pass
[docs]
def get_value(self, position: np.ndarray) -> np.ndarray:
"""returns the value of the field at a given position.
Parameters
----------
position : np.ndarray, shape (,3) or (n1, n2, .., 3)
positions at which the intensity is computed, given as cartesian coordinates
in the lab frame.
Returns
-------
value : np.ndarray, same shape as position
returns the (vector) field value
Notes
-------
`position` is an array_like object, with shape (3,) or (n1, n2, .., 3).
In all cases, the last dimension contains cordinates (x, y, z), in meter and in the lab frame
The field value is returned as an array with the same shape as `position`.
>>> field_value = field.get_value(position)
>>> X, Y, Z = position.T
>>> Fx, Fy, Fz = field_value.T
"""
# Check position
position = self._check_position_array(position)
# call hidden function that actually does the computation
return self._field_value_func(position)
@abstractmethod
def _field_value_func(self, position):
"""Actual method for field computation ; defined for each subclass"""
[docs]
def get_norm(self, position: np.ndarray) -> np.ndarray:
"""Returns the field norm at a given position in the lab frame
Parameters
----------
position : np.ndarray, shape (,3) or (n1, n2, .., 3)
positions at which the intensity is computed, given as cartesian coordinates
in the lab frame.
Returns
-------
norm : np.ndarray, shape (,1) or (n1, n2, .., 1)
returns the (scalar) field norm
"""
F = self.get_value(position)
Fx, Fy, Fz = F.T
F_norm = np.sqrt(Fx**2 + Fy**2 + Fz**2).T
return F_norm
[docs]
def plot1D(
self,
start: np.ndarray,
stop: np.ndarray,
Npoints: int = 100,
component: str = "Bz",
ax=None,
show: bool = False,
space_scale: float = 1.0,
):
"""Plots a 1D line cut of the magnetic field using Matplotlib
Parameters
----------
start : array-like, shape (3,)
Starting point (x, y, z) of the line along which to sample the field.
stop : array-like, shape (3,)
Ending point (x, y, z) of the line along which to sample the field.
Npoints : int, optional
Number of points sampled along the line. Defaults to 100.
component : str, optional
Field component to plot. Accepted values are "Bx", "By", "Bz" for vector components,
or "B" for total field magnitude. Defaults to "Bz".
ax : Matplotlib Axes, optional
The matplotlib axis on which to plot.
If None is given, a new figure is created. Defaults to None.
show : bool, optional
Whether to show the figure after calling the method. Defaults to False.
space_scale : float, optional
Space coordinates will be multiplied by this when plotting. Defaults to 1.
Returns
-------
ax : Matplotlib Axes
The axis on which the plot was performed.
Notes
------
The field is sampled along a straight line between `start` and `stop` using
`Npoints` equally spaced positions. The field component or magnitude is computed
and plotted as a function of the distance along the line.
Examples
---------
>>> field.plot1D(start=[0, 0, -10], stop=[0, 0, 10], Npoints=200)
>>> field.plot1D(start=[0, 0, -5], stop=[5, 0, 0], component="B", show=True)
>>> field.plot1D(start=[-5, 0, 0], stop=[5, 0, 0], component="Bx", space_scale=1e-3)
"""
# Create points along the line
start = np.array(start)
stop = np.array(stop)
t = np.linspace(0, 1, Npoints)
points = start[None, :] + t[:, None] * (stop - start)[None, :]
# Compute field
B = self.get_value(points)
# Choose component
if component == "Bx":
values = B[:, 0]
elif component == "By":
values = B[:, 1]
elif component == "Bz":
values = B[:, 2]
elif component == "B":
values = np.linalg.norm(B, axis=1)
else:
raise ValueError(
f"Unknown component '{component}'. Choose 'Bx', 'By', 'Bz', or 'B'."
)
# Compute position along the line (scaled if needed)
distances = np.linspace(0, np.linalg.norm(stop - start), Npoints) * space_scale
# Plot
if ax is None:
fig, ax = plt.subplots()
ax.plot(distances, values, label=component)
ax.set_xlabel("Distance along line")
ax.set_ylabel(f"Magnetic field [{component}]")
ax.set_title(f"Field along 1D line from {start} to {stop}")
ax.grid(True)
ax.legend()
if show:
plt.show()
return ax
[docs]
def plot2D(
self,
limits: np.ndarray,
Npoints: np.ndarray,
cut: float = 0.0,
ax=None,
plane: str = "XY",
cmap=None,
show: bool = False,
space_scale: float = 1.0,
):
"""Plots a 2D cut of the field, using Matplotlib streamplot()
Parameters
----------
limits : array, shape (4,)
an array of size 4, providing (xmin, xmax, ymin, ymax).
Npoints : int or array of shape (2,)
number of points for each dimension,
either a int or an array of two ints (Nx, Ny).
cut : float, optional
coordinate of the third axis for the cut. Defaults to 0.
ax : Matplotlib Axes, optional
the matplotlib axis on which to plot.
If None is given a new figure is created.
Defaults to None.
plane : str, optional
the plane for the cut. Accepted values are "XY", "YZ" and "ZX". Defaults to "XY".
cmap : Matplotlib cmap, optional
passed to matplotlib streamplot() function
show : bool, optional
whether to show the figure after calling the method. Defaults to False.
space_scale : float, optional
space coordinates will be multiplied by this when plotting. Defaults to 1.
Returns
-------
ax : Matplotlib Axes
the axis on which the plot was performed.
Notes
------
The limits are given via an array of size 4 'limits', providing providing (xmin, xmax, ymin, ymax)
Number of points are given with 'Npoints', either as an integer (same value for x and y) or an array of size 2
the coordinate of the cut axis is given by 'cut'
Examples
---------
>>> field.plot2D(limits=(-5, 5, -4, 4), Npoints=200)
>>> field.plot2D(limits=(-5, 5, -4, 4), Npoints=200, cut=-5)
>>> field.plot2D(limits=(-5, 5, -4, 4), Npoints=(200, 100))
"""
# - process arguments using the Plottable builtin method
ax, position, X, Y = self._process_2D_plot_args(
ax=ax,
plane=plane,
limits=limits,
Npoints=Npoints,
cut=cut,
)
# - compute field
mag_field = self.get_value(position)
Bx, By, Bz = mag_field.T
Bx = Bx.T
By = By.T
Bz = Bz.T
# - get relevant part
match plane.upper():
case "XY":
u = Bx
v = By
case "YZ":
u = By
v = Bz
case "ZX":
u = Bz
v = Bx
color = np.sqrt(Bx**2 + By**2 + Bz**2)
# Transpose if needed, since streamplot is quite strict..
if not np.allclose(X[0], X):
X = X.T
Y = Y.T
u = u.T
v = v.T
color = color.T
# - plot
ax.streamplot(X * space_scale, Y * space_scale, u, v, color=color, cmap=cmap)
ax.set_xlabel(plane.upper()[0])
ax.set_ylabel(plane.upper()[1])
# - show ?
if show:
plt.show()
return ax
[docs]
def plot3D(
self,
limits,
Npoints,
ax=None,
color=None,
name=None,
show=False,
scale=1.0,
normalize=False,
):
"""plots a 3D representation of the field, using Matplotlib quiver()
Parameters
----------
limits : array, shape (,6)
limits for the plot (xmin, xmax, ymin, ymax, zmin, zmax)
Npoints : int or array of shape (,3)
number of points for each dimension,
either a int or an array of three ints (Nx, Ny, Nz)
ax : custom Axes3D, optional
the axis in which to plot.
If None is given (default value) a new ax is generated
color : str, optional
a matplotlib compatible color. Defaults to None.
name : str, optional
the name of the field, passed as a label when plotting. Defaults to None.
show : bool, optional
whether the show the figure after calling the method. Defaults to False.
scale : float, optional
a scale factor for plotting the arrows (defaults to 1)
normalize : bool, optional
if set to True, we normalize the magnetic field to have a max value of 1 before plotting.
Defaults to False
Returns
-------
ax : Matplotlib Axes
the axis on which the plot was performed.
"""
# ------------------------- START ARGUMENT CHECKING ----------------
# - check plot config
assert ax is None or isinstance(ax, Axes), "'ax' should be a matplotlib axis."
# - check axis config
# limits
assert np.asanyarray(limits).size == 6, "`limits` should be an array of size 6"
# Npoints
Npoints = np.asanyarray(Npoints)
msg = "`Npoints` should be an int or a list of three ints"
assert Npoints.size in [1, 3], msg
assert issubclass(Npoints.dtype.type, np.integer), msg
# ------------------------- STOP ARGUMENT CHECKING ----------------
# - init ax (if needed)
ax = self._init_ax(ax, ax3D=True)
# - generate grid
xmin, xmax, ymin, ymax, zmin, zmax = limits
Nx, Ny, Nz = (Npoints, Npoints, Npoints) if Npoints.size == 1 else Npoints
grid = np.mgrid[
xmin : xmax : Nx * 1j, ymin : ymax : Ny * 1j, zmin : zmax : Nz * 1j
]
X, Y, Z = grid
position = grid.T
# - get magnetic field
B = self.get_value(position)
# - normalize ?
if normalize:
B = B / np.max(np.abs(B.ravel()))
B = B * scale
Bx, By, Bz = B.T
# - plot
ax.quiver(
X,
Y,
Z,
Bx,
By,
Bz,
label=name,
color=color,
)
# - axes names
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
# - show
if show:
plt.show()
return ax
# -- USEFUL CHECK FUNCTIONS
def _check_real_number(self, value, name):
if np.asanyarray(value).size > 1:
raise ValueError(f"'{name}' should be a scalar")
if not np.isreal(value):
raise TypeError(f"'{name}' should be a real numbers")
return value
def _check_3D_vector(self, value, name, norm=False):
value = np.asanyarray(value)
if value.size != 3:
raise ValueError(f"'{name}' should be an array of size 3")
if not np.all(np.isreal(value)):
raise TypeError(f"'{name}' should be an array of real numbers")
if norm:
value = value / np.linalg.norm(value)
return value
# % TOOL CLASSES
[docs]
class ConstantField(Field):
"""Generates a constant field
Parameters
----------
field_value : np.ndarray, shape (3,), optional
Constant field value, by default (0, 0, 0)
tag : str, optional
Field tag, by default None
"""
def __init__(self, field_value: np.ndarray = (0, 0, 0), tag: str = None):
self.field_value = field_value
super(Field, self).__init__(tag)
# -- getters and setters
@property
def field_value(self) -> np.ndarray:
return self.__field_value
@field_value.setter
def field_value(self, value: np.ndarray):
"""field_value (array): constant field value"""
self.__field_value = self._check_3D_vector(value, "value")
# -- requested methods for Field
# pylint : disable=method_hidden
def _field_value_func(self, position):
"""Returns field value at point position
position should be an array of shape (3,) or (n1,n2,..,3)
last axis contains coordinates x, y, z
NB: position is already checked and converted to an array in the
`Field` class
"""
# 'position' already has the right size here
# as it contains 3D vectors (position)
# so we can generate an homogeneous field quite easily
value = position * 0.0 + self.__field_value
return value
[docs]
def gen_infostring_obj(self):
unit = self.unit
title = self.type
title = title[:1].upper() + title[1:] # capitalize first letter
info = InfoString(title=title)
info.add_section("Parameters")
info.add_element("type", "constant field")
info.add_element("tag", self.tag)
info.add_element(f"field_value ({unit})", f"{self.field_value}")
info.add_element(f"norm ({unit})", f"{np.linalg.norm(self.field_value):.3g}")
return info
[docs]
class GradientField(Field):
"""A perfect linear field gradient
Parameters
----------
origin : array, shape (,3)
origin for the gradient, in cartesian coordinates in the lab frame
slope : float
the slope of the gradient, in 'field unit' / m
gradient_direction : array, shape (.3)
the direction of the gradient
field_direction : array, shae (,3)
the direction of the field
offset : float, optional
an offset for the field amplitude at origin, in 'field unit', by default 0.0
tag : str, optional
field tag, by default None
Notes
-----
Note that 'gradient_direction' and 'field_direction' are meant to be
unit vectors, but the class will take care of normalizing any non normalized entry
Examples
---------
Create a field pointing along z, with a amplitude increasing linearly along x
>>> gradient = GradientField(
... origin=(0, 0, 0),
... slope=1,
... gradient_direction=(1, 0, 0),
... field_direction=(0, 0, 1),
... tag="gradient",
... )
"""
def __init__(
self,
origin: np.ndarray,
slope: float,
gradient_direction: np.ndarray,
field_direction: np.ndarray,
offset: float = 0.0,
tag: str = None,
):
self.slope = slope
self.offset = offset
self.origin = origin
self.gradient_direction = gradient_direction
self.field_direction = field_direction
super(GradientField, self).__init__(tag)
# -- value
# pylint : disable=method_hidden
def _field_value_func(self, position):
"""Returns field value at point position
position should be an array of shape (3,) or (n1,n2,..,3)
last axis contains coordinates x, y, z
NB: position is already checked and converted to an array in the
`Field` class
"""
# - get X, Y, and Z
x, y, z = position.T
x, y, z = x.T, y.T, z.T
# - get gradient vector angles
theta = self.__gradient_theta
phi = self.__gradient_phi
# - get coordinates w.r.t origin
x0, y0, z0 = self.origin
xc = x - x0
yc = y - y0
zc = z - z0
# compute coordinates in rotated frame
# we want z
z_rot = (
xc * np.sin(theta) * np.cos(phi)
+ yc * np.sin(theta) * np.sin(phi)
+ zc * np.cos(theta)
)
# - value at position
value = (self.offset + z_rot * self.slope)[
..., np.newaxis
] * self.field_direction
return value
[docs]
def gen_infostring_obj(self):
"""Generates an info string object"""
unit = self.unit
title = self.type
title = title[:1].upper() + title[1:] # capitalize first letter
info = InfoString(title=title)
info.add_section("Parameters")
info.add_element("type", "perfect gradient")
info.add_element("tag", self.tag)
info.add_element(f"slope ({unit}/m)", f"{self.slope:.3g}")
info.add_element("gradient direction", f"{self.gradient_direction}")
info.add_element("field direction", f"{self.field_direction}")
info.add_element(f"origin (m)", f"{self.origin}")
info.add_element(f"offset ({unit})", f"{self.offset:3g}")
return info
# -- getters and setters
# -
@property
def slope(self) -> float:
"""float: the field amplitude gradient slope"""
return self.__slope
@slope.setter
def slope(self, value: float):
self.__slope = self._check_real_number(value, "slope")
# -
@property
def offset(self) -> float:
"""float: the field amplitude value at origin"""
return self.__offset
@offset.setter
def offset(self, value: float):
self.__offset = self._check_real_number(value, "offset")
# -
@property
def origin(self) -> np.ndarray:
"""array, shape (,3): the origin for the gradient"""
return self.__origin
@origin.setter
def origin(self, value: np.ndarray):
self.__origin = self._check_3D_vector(value, "origin")
# -
@property
def gradient_direction(self) -> np.ndarray:
"""array, shape (,3): the gradient direction"""
return self.__gradient_direction
@gradient_direction.setter
def gradient_direction(self, value: np.ndarray):
value = self._check_3D_vector(value, "gradient_direction", norm=True)
assert np.allclose(
np.linalg.norm(value), 1
), "We did not manage to normalize gradient_direction, something is fishy.."
# compute angles
ux, uy, uz = value
theta = np.arctan2(np.sqrt(ux**2 + uy**2), uz)
phi = np.arctan2(uy, ux)
self.__gradient_direction = value
self.__gradient_theta = theta
self.__gradient_phi = phi
# -
@property
def field_direction(self) -> np.ndarray:
"""array, shape (,3): the field direction"""
return self.__field_direction
@field_direction.setter
def field_direction(self, value: np.ndarray):
self.__field_direction = self._check_3D_vector(
value, "field_direction", norm=True
)
[docs]
class BaseQuadrupoleField(Field):
"""A perfect quadrupole field
Parameters
----------
origin : array, shape (,3)
origin for the quadrupole, in cartesian coordinates in the lab frame
strong_axis : array, shape (,3)
the direction of the strong axis
slope : float
the gradient of the weak axes
tag : str, optional
field tag, by default None
Notes
------
Note that 'strong_axis' is meant to be a unit vector, but the class will take care of normalizing
any non normalized entry
"""
def __init__(
self,
origin: np.ndarray,
strong_axis: np.ndarray,
slope: float,
tag: str = None,
):
self.slope = slope
self.origin = origin
self.strong_axis = strong_axis
super(BaseQuadrupoleField, self).__init__(tag)
# -- getters and setters
# -
@property
def slope(self) -> float:
"""float: the gradient of the weak axes"""
return self.__slope
@slope.setter
def slope(self, value: float):
self.__slope = self._check_real_number(value, "slope")
# -
@property
def origin(self) -> np.ndarray:
"""array, shape (3,): the origin of the quadrupole"""
return self.__origin
@origin.setter
def origin(self, value: np.ndarray):
self.__origin = self._check_3D_vector(value, "origin")
# -
@property
def strong_axis(self) -> np.ndarray:
"""array, shape (3,): the direction of the strong axis"""
return self.__strong_axis
@strong_axis.setter
def strong_axis(self, value: np.ndarray):
value = self._check_3D_vector(value, "strong_axis", norm=True)
assert np.allclose(
np.linalg.norm(value), 1
), "We did not manage to normalize strong_axis, something is fishy.."
# compute angles
ux, uy, uz = value
theta = np.arctan2(np.sqrt(ux**2 + uy**2), uz)
phi = np.arctan2(uy, ux)
self.__strong_axis = value
self.__theta = theta
self.__phi = phi
[docs]
def gen_infostring_obj(self):
"""Generates an info string object"""
unit = self.unit
title = self.type
title = title[:1].upper() + title[1:] # capitalize first letter
info = InfoString(title=title)
info.add_section("Parameters")
info.add_element("type", "perfect quadrupole")
info.add_element("tag", self.tag)
info.add_element(f"slope ({unit}/m)", f"{self.slope:.3g}")
info.add_element("strong axis", f"{self.strong_axis}")
info.add_element(f"origin (m)", f"{self.origin}")
return info
[docs]
class QuadrupoleFieldX(BaseQuadrupoleField):
"""A perfect quadrupole field with strong axis along X
Parameters
----------
origin : array, shape (,3)
origin for the quadrupole, in cartesian coordinates in the lab frame
slope : float
the gradient of the weak axes
tag : str, optional
field tag, by default None
Notes
------
Note that 'strong_axis' is meant to be a unit vector, but the class will take care of normalizing
any non normalized entry
"""
def __init__(
self,
origin: np.ndarray,
slope: float,
tag: str = None,
):
super(QuadrupoleFieldX, self).__init__(
origin=origin, slope=slope, tag=tag, strong_axis=(1, 0, 0)
)
def _field_value_func(self, position):
"""Returns field value at point position
position should be an array of shape (3,) or (n1,n2,..,3)
last axis contains coordinates x, y, z
NB: position is already checked and converted to an array in the
`Field` class
"""
# - get X, Y, and Z
x, y, z = position.T
# - get coordinates w.r.t origin
x0, y0, z0 = self.origin
xc, yc, zc = x - x0, y - y0, z - z0
# - compute
slope = self.slope
value = np.array([-2 * slope * xc, slope * yc, slope * zc]).T
return value
[docs]
class QuadrupoleFieldY(BaseQuadrupoleField):
"""A perfect quadrupole field with strong axis along Y
Parameters
----------
origin : array, shape (,3)
origin for the quadrupole, in cartesian coordinates in the lab frame
slope : float
the gradient of the weak axes
tag : str, optional
field tag, by default None
Notes
------
Note that 'strong_axis' is meant to be a unit vector, but the class will take care of normalizing
any non normalized entry
"""
def __init__(
self,
origin: np.ndarray,
slope: float,
tag: str = None,
):
super(QuadrupoleFieldY, self).__init__(
origin=origin, slope=slope, tag=tag, strong_axis=(0, 1, 0)
)
def _field_value_func(self, position):
"""Returns field value at point position
position should be an array of shape (3,) or (n1,n2,..,3)
last axis contains coordinates x, y, z
NB: position is already checked and converted to an array in the
`Field` class
"""
# - get X, Y, and Z
x, y, z = position.T
# - get coordinates w.r.t origin
x0, y0, z0 = self.origin
xc, yc, zc = x - x0, y - y0, z - z0
# - compute
slope = self.slope
value = np.array([slope * xc, -2 * slope * yc, slope * zc]).T
return value
[docs]
class QuadrupoleFieldZ(BaseQuadrupoleField):
"""A perfect quadrupole field with strong axis along Z
Parameters
----------
origin : array, shape (,3)
origin for the quadrupole, in cartesian coordinates in the lab frame
slope : float
the gradient of the weak axes
tag : str, optional
field tag, by default None
Notes
------
Note that 'strong_axis' is meant to be a unit vector, but the class will take care of normalizing
any non normalized entry
"""
def __init__(
self,
origin: np.ndarray,
slope: float,
tag: str = None,
):
super(QuadrupoleFieldZ, self).__init__(
origin=origin, slope=slope, tag=tag, strong_axis=(0, 0, 1)
)
def _field_value_func(self, position):
"""Returns field value at point position
position should be an array of shape (3,) or (n1,n2,..,3)
last axis contains coordinates x, y, z
NB: position is already checked and converted to an array in the
`Field` class
"""
# - get X, Y, and Z
x, y, z = position.T
# - get coordinates w.r.t origin
x0, y0, z0 = self.origin
xc, yc, zc = x - x0, y - y0, z - z0
# - compute
slope = self.slope
value = np.array([slope * xc, slope * yc, -2 * slope * zc]).T
return value
[docs]
class QuadrupoleField(BaseQuadrupoleField):
"""A perfect quadrupole field with strong axis along a given vector
Parameters
----------
origin : array, shape (,3)
origin for the quadrupole, in cartesian coordinates in the lab frame
strong_axis : array, shape (,3)
the direction of the strong axis
slope : float
the gradient of the weak axes
tag : str, optional
field tag, by default None
Notes
------
Note that 'strong_axis' is meant to be a unit vector, but the class will take care of normalizing
any non normalized entry
"""
def __init__(
self,
origin: np.ndarray,
strong_axis: np.ndarray,
slope: float,
tag: str = None,
):
super(QuadrupoleField, self).__init__(
origin=origin, slope=slope, tag=tag, strong_axis=strong_axis
)
def _field_value_func(self, position):
"""Returns field value at point position
position should be an array of shape (3,) or (n1,n2,..,3)
last axis contains coordinates x, y, z
NB: position is already checked and converted to an array in the
`Field` class
"""
# - get X, Y, and Z
x, y, z = np.moveaxis(position, -1, 0)
# - get coordinates w.r.t origin
x0, y0, z0 = self.origin
xc, yc, zc = x - x0, y - y0, z - z0
rc = np.stack((xc, yc, zc), axis=-1)
# - compute
slope = self.slope
s = self.strong_axis / np.linalg.norm(self.strong_axis)
projection = np.sum(rc * s, axis=-1, keepdims=True)
value = slope * projection * s - (slope / 2) * (rc - projection * s)
return value