Add Bloch eigenproblem

This commit is contained in:
jan 2017-12-09 18:21:37 -08:00
parent 6503b488ce
commit 4aa2d07cef
3 changed files with 420 additions and 7 deletions

406
fdfd_tools/bloch.py Normal file
View File

@ -0,0 +1,406 @@
'''
Bloch eigenmode solver/operators
This module contains functions for generating and solving the
3D Bloch eigenproblem. The approach is to transform the problem
into the (spatial) fourier domain, transforming the equation
1/mu * curl(1/eps * curl(H)) = (w/c)^2 H
into
conv(1/mu_k, ik x conv(1/eps_k, ik x H_k)) = (w/c)^2 H_k
where:
- the _k subscript denotes a 3D fourier transformed field
- each component of H_k corresponds to a plane wave with wavevector k
- x is the cross product
- conv denotes convolution
Since k and H are orthogonal for each plane wave, we can use each
k to create an orthogonal basis (k, m, n), with k x m = n, and
|m| = |n| = 1. The cross products are then simplified with
k @ h = kx hx + ky hy + kz hz = 0 = hk
h = hk + hm + hn = hm + hn
k = kk + km + kn = kk = |k|
k x h = (ky hz - kz hy,
kz hx - kx hz,
kx hy - ky hx)
= ((k x h) @ k, (k x h) @ m, (k x h) @ n)_kmn
= (0, (m x k) @ h, (n x k) @ h)_kmn # triple product ordering
= (0, kk (-n @ h), kk (m @ h))_kmn # (m x k) = -|k| n, etc.
= |k| (0, -h @ n, h @ m)_kmn
k x h = (km hn - kn hm,
kn hk - kk hn,
kk hm - km hk)_kmn
= (0, -kk hn, kk hm)_kmn
= (-kk hn)(mx, my, mz) + (kk hm)(nx, ny, nz)
= |k| (hm * (nx, ny, nz) - hn * (mx, my, mz))
where h is shorthand for H_k, (...)_kmn deontes the (k, m, n) basis,
and e.g. hm is the component of h in the m direction.
We can also simplify conv(X_k, Y_k) as fftn(X * ifftn(Y_k)).
Using these results and storing H_k as h = (hm, hn), we have
e_xyz = fftn(1/eps * ifftn(|k| (hm * n - hn * m)))
b_mn = |k| (-e_xyz @ n, e_xyz @ m)
h_mn = fftn(1/mu * ifftn(b_m * m + b_n * n))
which forms the operator from the left side of the equation.
We can then use ARPACK in shift-invert mode (via scipy.linalg.eigs)
to find the eigenvectors for this operator.
This approach is similar to the one used in MPB and derived at the start of
SG Johnson and JD Joannopoulos, Block-iterative frequency-domain methods
for Maxwell's equations in a planewave basis, Optics Express 8, 3, 173-190 (2001)
===
Typically you will want to do something like
recip_lattice = numpy.diag(1/numpy.array(epsilon[0].shape * dx))
n, v = bloch.eigsolve(5, k0, recip_lattice, epsilon)
f = numpy.sqrt(-numpy.real(n[0]))
n_eff = norm(recip_lattice @ k0) / f
v2e = bloch.hmn_2_exyz(k0, recip_lattice, epsilon)
e_field = v2e(v[0])
k, f = find_k(frequency=1/1550,
tolerance=(1/1550 - 1/1551),
search_direction=[1, 0, 0],
G_matrix=recip_lattice,
epsilon=epsilon,
band=0)
'''
from typing import List, Tuple, Callable, Dict
import numpy
from numpy.fft import fftn, ifftn, fftfreq
import scipy
from scipy.linalg import norm
import scipy.sparse.linalg as spalg
from . import field_t
def generate_kmn(k0: numpy.ndarray,
G_matrix: numpy.ndarray,
shape: numpy.ndarray
) -> Tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]:
"""
Generate a (k, m, n) orthogonal basis for each k-vector in the simulation grid.
:param k0: [k0x, k0y, k0z], Bloch wavevector, in G basis.
:param G_matrix: 3x3 matrix, with reciprocal lattice vectors as columns.
:param shape: [nx, ny, nz] shape of the simulation grid.
:return: (|k|, m, n) where |k| has shape tuple(shape) + (1,)
and m, n have shape tuple(shape) + (3,).
All are given in the xyz basis (e.g. |k|[0,0,0] = norm(G_matrix @ k0)).
"""
k0 = numpy.array(k0)
Gi_grids = numpy.meshgrid(*(fftfreq(n, 1/n) for n in shape[:3]), indexing='ij')
Gi = numpy.stack(Gi_grids, axis=3)
k_G = k0[None, None, None, :] - Gi
k_xyz = numpy.rollaxis(G_matrix @ numpy.rollaxis(k_G, 3, 2), 3, 2)
m = numpy.broadcast_to([0, 1, 0], tuple(shape[:3]) + (3,)).astype(float)
n = numpy.broadcast_to([0, 0, 1], tuple(shape[:3]) + (3,)).astype(float)
xy_non0 = numpy.any(k_xyz[:, :, :, 0:1] != 0, axis=3)
if numpy.any(xy_non0):
u = numpy.cross(k_xyz[xy_non0], [0, 0, 1])
m[xy_non0, :] = u / norm(u, axis=1)[:, None]
z_non0 = numpy.any(k_xyz != 0, axis=3)
if numpy.any(z_non0):
v = numpy.cross(k_xyz[z_non0], m[z_non0])
n[z_non0, :] = v / norm(v, axis=1)[:, None]
k_mag = norm(k_xyz, axis=3)[:, :, :, None]
return k_mag, m, n
def maxwell_operator(k0: numpy.ndarray,
G_matrix: numpy.ndarray,
epsilon: field_t,
mu: field_t = None
) -> Callable[[numpy.ndarray], numpy.ndarray]:
"""
Generate the Maxwell operator
conv(1/mu_k, ik x conv(1/eps_k, ik x ___))
which is the spatial-frequency-space representation of
1/mu * curl(1/eps * curl(___))
The operator is a function that acts on a vector h_mn of size (2 * epsilon[0].size)
See the module-level docstring for more information.
:param k0: Bloch wavevector, [k0x, k0y, k0z].
:param G_matrix: 3x3 matrix, with reciprocal lattice vectors as columns.
:param epsilon: Dielectric constant distribution for the simulation.
All fields are sampled at cell centers (i.e., NOT Yee-gridded)
:param mu: Magnetic permability distribution for the simulation.
Default None (1 everywhere).
:return: Function which applies the maxwell operator to h_mn.
"""
shape = epsilon[0].shape + (1,)
k_mag, m, n = generate_kmn(k0, G_matrix, shape)
epsilon = numpy.stack(epsilon, 3)
if mu is not None:
mu = numpy.stack(mu, 3)
def operator(h: numpy.ndarray):
"""
Maxwell operator for Bloch eigenmode simulation.
h is complex 2-field in (m, n) basis, vectorized
:param h: Raveled h_mn; size (2 * epsilon[0].size).
:return: Raveled conv(1/mu_k, ik x conv(1/eps_k, ik x h_mn)).
"""
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
# cross product and transform into xyz basis
d_xyz = (n * hin_m -
m * hin_n) * k_mag
# divide by epsilon
e_xyz = ifftn(fftn(d_xyz, axes=range(3)) / epsilon, axes=range(3))
# cross product and transform into mn basis
b_m = numpy.sum(e_xyz * n, axis=3)[:, :, :, None] * -k_mag
b_n = numpy.sum(e_xyz * m, axis=3)[:, :, :, None] * +k_mag
if mu is None:
h_m, h_n = b_m, b_n
else:
# transform from mn to xyz
b_xyz = (m * b_m[:, :, :, None] +
n * b_n[:, :, :, None])
# divide by mu
h_xyz = ifftn(fftn(b_xyz, axes=range(3)) / mu, axes=range(3))
# transform back to mn
h_m = numpy.sum(h_xyz * m, axis=3)
h_n = numpy.sum(h_xyz * n, axis=3)
return numpy.hstack((h_m.ravel(), h_n.ravel()))
return operator
def hmn_2_exyz(k0: numpy.ndarray,
G_matrix: numpy.ndarray,
epsilon: field_t,
) -> Callable[[numpy.ndarray], field_t]:
"""
Generate an operator which converts a vectorized spatial-frequency-space
h_mn into an E-field distribution, i.e.
ifft(conv(1/eps_k, ik x h_mn))
The operator is a function that acts on a vector h_mn of size (2 * epsilon[0].size)
See the module-level docstring for more information.
:param k0: Bloch wavevector, [k0x, k0y, k0z].
:param G_matrix: 3x3 matrix, with reciprocal lattice vectors as columns.
:param epsilon: Dielectric constant distribution for the simulation.
All fields are sampled at cell centers (i.e., NOT Yee-gridded)
:return: Function for converting h_mn into E_xyz
"""
shape = epsilon[0].shape + (1,)
epsilon = numpy.stack(epsilon, 3)
k_mag, m, n = generate_kmn(k0, G_matrix, shape)
def operator(h: numpy.ndarray) -> field_t:
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
d_xyz = (n * hin_m -
m * hin_n) * k_mag
# divide by epsilon
return [ei for ei in numpy.rollaxis(fftn(d_xyz, axes=range(3)) / epsilon, 3)]
return operator
def hmn_2_hxyz(k0: numpy.ndarray,
G_matrix: numpy.ndarray,
epsilon: field_t
) -> Callable[[numpy.ndarray], field_t]:
"""
Generate an operator which converts a vectorized spatial-frequency-space
h_mn into an H-field distribution, i.e.
ifft(h_mn)
The operator is a function that acts on a vector h_mn of size (2 * epsilon[0].size)
See the module-level docstring for more information.
:param k0: Bloch wavevector, [k0x, k0y, k0z].
:param G_matrix: 3x3 matrix, with reciprocal lattice vectors as columns.
:param epsilon: Dielectric constant distribution for the simulation.
Only epsilon[0].shape is used.
:return: Function for converting h_mn into H_xyz
"""
shape = epsilon[0].shape + (1,)
k_mag, m, n = generate_kmn(k0, G_matrix, shape)
def operator(h: numpy.ndarray):
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
h_xyz = (m * hin_m +
n * hin_n)
return [fftn(hi) for hi in numpy.rollaxis(h_xyz, 3)]
return operator
def inverse_maxwell_operator_approx(k0: numpy.ndarray,
G_matrix: numpy.ndarray,
epsilon: field_t,
mu: field_t = None
) -> Callable[[numpy.ndarray], numpy.ndarray]:
"""
Generate an approximate inverse of the Maxwell operator,
ik x conv(eps_k, ik x conv(mu_k, ___))
which can be used to improve the speed of ARPACK in shift-invert mode.
See the module-level docstring for more information.
:param k0: Bloch wavevector, [k0x, k0y, k0z].
:param G_matrix: 3x3 matrix, with reciprocal lattice vectors as columns.
:param epsilon: Dielectric constant distribution for the simulation.
All fields are sampled at cell centers (i.e., NOT Yee-gridded)
:param mu: Magnetic permability distribution for the simulation.
Default None (1 everywhere).
:return: Function which applies the approximate inverse of the maxwell operator to h_mn.
"""
shape = epsilon[0].shape + (1,)
epsilon = numpy.stack(epsilon, 3)
k_mag, m, n = generate_kmn(k0, G_matrix, shape)
if mu is not None:
mu = numpy.stack(mu, 3)
def operator(h: numpy.ndarray):
"""
Approximate inverse Maxwell operator for Bloch eigenmode simulation.
h is complex 2-field in (m, n) basis, vectorized
:param h: Raveled h_mn; size (2 * epsilon[0].size).
:return: Raveled ik x conv(eps_k, ik x conv(mu_k, h_mn))
"""
hin_m, hin_n = [hi.reshape(shape) for hi in numpy.split(h, 2)]
#{d,e,h}_xyz fields are complex 3-fields in (1/x, 1/y, 1/z) basis
if mu is None:
b_m, b_n = hin_m, hin_n
else:
# transform from mn to xyz
h_xyz = (m * hin_m[:, :, :, None] +
n * hin_n[:, :, :, None])
# multiply by mu
b_xyz = ifftn(fftn(h_xyz, axes=range(3)) * mu, axes=range(3))
# transform back to mn
b_m = numpy.sum(b_xyz * m, axis=3)
b_n = numpy.sum(b_xyz * n, axis=3)
# cross product and transform into xyz basis
e_xyz = (n * b_m -
m * b_n) / k_mag
# multiply by epsilon
d_xyz = ifftn(fftn(e_xyz, axes=range(3)) * epsilon, axes=range(3))
# cross product and transform into mn basis crossinv_t2c
h_m = numpy.sum(e_xyz * n, axis=3)[:, :, :, None] / +k_mag
h_n = numpy.sum(e_xyz * m, axis=3)[:, :, :, None] / -k_mag
return numpy.hstack((h_m.ravel(), h_n.ravel()))
return operator
def eigsolve(num_modes: int,
k0: numpy.ndarray,
G_matrix: numpy.ndarray,
epsilon: field_t,
mu: field_t = None
) -> Tuple[numpy.ndarray, numpy.ndarray]:
"""
Find the first (lowest-frequency) num_modes eigenmodes with Bloch wavevector
k0 of the specified structure.
:param k0: Bloch wavevector, [k0x, k0y, k0z].
:param G_matrix: 3x3 matrix, with reciprocal lattice vectors as columns.
:param epsilon: Dielectric constant distribution for the simulation.
All fields are sampled at cell centers (i.e., NOT Yee-gridded)
:param mu: Magnetic permability distribution for the simulation.
Default None (1 everywhere).
:return: (eigenvalues, eigenvectors) where eigenvalues[i] corresponds to the
vector eigenvectors[i, :]
"""
h_size = 2 * epsilon[0].size
mop = maxwell_operator(k0=k0, G_matrix=G_matrix, epsilon=epsilon, mu=mu)
imop = inverse_maxwell_operator_approx(k0=k0, G_matrix=G_matrix, epsilon=epsilon, mu=mu)
scipy_op = spalg.LinearOperator(dtype=complex, shape=(h_size, h_size), matvec=mop)
scipy_iop = spalg.LinearOperator(dtype=complex, shape=(h_size, h_size), matvec=imop)
_eigvals, eigvecs = spalg.eigs(scipy_op, num_modes, sigma=0, OPinv=scipy_iop, which='LM')
eigvals = numpy.sum(eigvecs * (scipy_op @ eigvecs), axis=0) / numpy.sum(eigvecs * eigvecs, axis=0)
order = numpy.argsort(-eigvals)
return eigvals[order], eigvecs.T[order]
def find_k(frequency: float,
tolerance: float,
search_direction: numpy.ndarray,
G_matrix: numpy.ndarray,
epsilon: field_t,
mu: field_t = None,
band: int = 0
) -> Tuple[numpy.ndarray, float]:
"""
Search for a bloch vector that has a given frequency.
:param frequency: Target frequency.
:param tolerance: Target frequency tolerance.
:param search_direction: k-vector direction to search along.
:param G_matrix: 3x3 matrix, with reciprocal lattice vectors as columns.
:param epsilon: Dielectric constant distribution for the simulation.
All fields are sampled at cell centers (i.e., NOT Yee-gridded)
:param mu: Magnetic permability distribution for the simulation.
Default None (1 everywhere).
:param band: Which band to search in. Default 0 (lowest frequency).
return: (k, actual_frequency) The found k-vector and its frequency
"""
search_direction = numpy.array(search_direction) / norm(search_direction)
def get_f(k0_mag: float, band: int = 0):
k0 = search_direction * k0_mag
n, _v = eigsolve(band + 1, k0, G_matrix=G_matrix, epsilon=epsilon)
f = numpy.sqrt(numpy.abs(numpy.real(n[band])))
return f
res = scipy.optimize.minimize_scalar(lambda x: abs(get_f(x, band) - frequency), 0.25,
method='Bounded', bounds=(0, 0.5),
options={'xatol': abs(tolerance)})
return res.x * search_direction, res.fun + frequency

View File

@ -29,7 +29,7 @@ def power_iteration(operator: sparse.spmatrix,
v = operator @ v v = operator @ v
v /= norm(v) v /= norm(v)
lm_eigval = v.conj() @ operator @ v lm_eigval = v.conj() @ (operator @ v)
return lm_eigval, v return lm_eigval, v
@ -59,7 +59,7 @@ def rayleigh_quotient_iteration(operator: sparse.spmatrix,
return eigval, v return eigval, v
def signed_eigensolve(operator: sparse.spmatrix, def signed_eigensolve(operator: sparse.spmatrix or spalg.LinearOperator,
how_many: int, how_many: int,
negative: bool = False, negative: bool = False,
) -> Tuple[numpy.ndarray, numpy.ndarray]: ) -> Tuple[numpy.ndarray, numpy.ndarray]:
@ -83,12 +83,19 @@ def signed_eigensolve(operator: sparse.spmatrix,
largest _positive_ eigenvalues, since any negative eigenvalues will be shifted to the largest _positive_ eigenvalues, since any negative eigenvalues will be shifted to the
range 0 >= neg_eigval + abs(lm_eigval) > abs(lm_eigval) range 0 >= neg_eigval + abs(lm_eigval) > abs(lm_eigval)
''' '''
shift = numpy.abs(lm_eigval)
if negative: if negative:
shifted_operator = operator - abs(lm_eigval) * sparse.eye(operator.shape[0]) shift *= -1
else:
shifted_operator = operator + abs(lm_eigval) * sparse.eye(operator.shape[0])
eigenvalues, eigenvectors = spalg.eigs(shifted_operator, which='LM', k=how_many, ncv=50) # Try to combine, use general LinearOperator if we fail
try:
shifted_operator = operator + shift * sparse.eye(operator.shape[0])
except TypeError:
shifted_operator = operator + spalg.LinearOperator(shape=operator.shape,
matvec=lambda v: shift * v)
shifted_eigenvalues, eigenvectors = spalg.eigs(shifted_operator, which='LM', k=how_many, ncv=50)
eigenvalues = shifted_eigenvalues - shift
k = eigenvalues.argsort() k = eigenvalues.argsort()
return eigenvalues[k], eigenvectors[:, k] return eigenvalues[k], eigenvectors[:, k]

View File

@ -1,7 +1,7 @@
""" """
Functions for moving between a vector field (list of 3 ndarrays, [f_x, f_y, f_z]) Functions for moving between a vector field (list of 3 ndarrays, [f_x, f_y, f_z])
and a 1D array representation of that field [f_x0, f_x1, f_x2,... f_y0,... f_z0,...]. and a 1D array representation of that field [f_x0, f_x1, f_x2,... f_y0,... f_z0,...].
Vectorized versions of the field use column-major (ie., Fortran, Matlab) ordering. Vectorized versions of the field use row-major (ie., C-style) ordering.
""" """