"""
Tools for solving the OBE for laser cooling
author: spe
"""
import numpy as np
import copy
import time
import numba
import scipy.sparse as sparse
from scipy.integrate import solve_ivp
from scipy.interpolate import interp1d
from .rateeq import rateeq
from .fields import laserBeams, magField
from .integration_tools import solve_ivp_random
from .common import (progressBar, random_vector, spherical_dot,
cart2spherical, spherical2cart, base_force_profile)
from .governingeq import governingeq
@numba.vectorize([numba.float64(numba.complex128),numba.float32(numba.complex64)])
def abs2(x):
return x.real**2 + x.imag**2
@numba.jit(nopython=True)
def dot(A, x):
return A @ x
@numba.jit(nopython=True)
def dot_and_add(A, x, b):
b += A @ x
def cartesian_vector_tensor_dot(a, B):
if B.ndim == 2 and a.ndim == 1:
# Single point:
return np.dot(B, a)
elif B.ndim == 2:
# Constant B, variable a:
return np.sum(a[np.newaxis, ...]*B[..., np.newaxis], axis=1)
else:
# Varaible a and variable B. Will throw an error if a.shape[1:] != B.shape[2:]:
return np.sum(a[np.newaxis, ...]*B[...], axis=1)
[docs]class force_profile(base_force_profile):
"""
Optical Bloch equation force profile
The force profile object stores all of the calculated quantities created by
the rateeq.generate_force_profile() method. It has the following
attributes:
Attributes
----------
R : array_like, shape (3, ...)
Positions at which the force profile was calculated.
V : array_like, shape (3, ...)
Velocities at which the force profile was calculated.
F : array_like, shape (3, ...)
Total equilibrium force at position R and velocity V.
f_mag : array_like, shape (3, ...)
Magnetic force at position R and velocity V.
f : dictionary of array_like
The forces due to each laser, indexed by the
manifold the laser addresses. The dictionary is keyed by the transition
driven, and individual lasers are in the same order as in the
pylcp.laserBeams object used to create the governing equation.
f_q : dictionary of array_like
The force due to each laser and its :math:`q` component, indexed by the
manifold the laser addresses. The dictionary is keyed by the transition
driven, and individual lasers are in the same order as in the
pylcp.laserBeams object used to create the governing equation.
Neq : array_like
Equilibrium population found.
"""
def __init__(self, R, V, laserBeams, hamiltonian):
super().__init__(R, V, laserBeams, hamiltonian)
self.iterations = np.zeros(self.R[0].shape, dtype='int64')
self.fq = {}
for key in laserBeams:
self.fq[key] = np.zeros(self.R.shape + (3, len(laserBeams[key].beam_vector)))
def store_data(self, ind, Neq, F, F_laser, F_mag, iterations, F_laser_q):
super().store_data(ind, Neq, F, F_laser, F_mag)
for jj in range(3):
for key in F_laser_q:
self.fq[key][(jj,) + ind] = F_laser_q[key][jj]
self.iterations[ind] = iterations
[docs]class obe(governingeq):
"""
The optical Bloch equations
This class constructs the optical Bloch equations from the given laser
beams, magnetic field, and hamiltonian.
Parameters
----------
laserBeams : dictionary of pylcp.laserBeams, pylcp.laserBeams, or list of pylcp.laserBeam
The laserBeams that will be used in constructing the optical Bloch
equations. which transitions in the block diagonal hamiltonian. It can
be any of the following:
* A dictionary of pylcp.laserBeams: if this is the case, the keys of
the dictionary should match available :math:`d^{nm}` matrices
in the pylcp.hamiltonian object. The key structure should be
`n->m`.
* pylcp.laserBeams: a single set of laser beams is assumed to
address the transition `g->e`.
* a list of pylcp.laserBeam: automatically promoted to a
pylcp.laserBeams object assumed to address the transtion `g->e`.
magField : pylcp.magField or callable
The function or object that defines the magnetic field.
hamiltonian : pylcp.hamiltonian
The internal hamiltonian of the particle.
a : array_like, shape (3,), optional
A default acceleraiton to apply to the particle's motion, usually
gravity. Default: [0., 0., 0.]
transform_into_re_im : boolean
Optional flag to transform the optical Bloch equations into real and
imaginary components. This helps to decrease computaiton time as it
uses the symmetry :math:`\\rho_{ji}=\\rho_{ij}^*` to cut the number
of equations nearly in half. Default: True
use_sparse_matrices : boolean or None
Optional flag to use sparse matrices. If none, it will use sparse
matrices only if the number of internal states > 10, which would result
in the evolution matrix for the density operators being a 100x100
matrix. At that size, there may be some speed up with sparse matrices.
Default: None
include_mag_forces : boolean
Optional flag to inculde magnetic forces in the force calculation.
Default: True
r0 : array_like, shape (3,), optional
Initial position. Default: [0., 0., 0.]
v0 : array_like, shape (3,), optional
Initial velocity. Default: [0., 0., 0.]
Methods
-------
"""
def __init__(self, laserBeams, magField, hamitlonian,
a=np.array([0., 0., 0.]), transform_into_re_im=True,
use_sparse_matrices=None, include_mag_forces=True,
r0=np.array([0., 0., 0.]), v0=np.array([0., 0., 0.])):
super().__init__(laserBeams, magField, hamitlonian, a=a,
r0=r0, v0=v0)
# Save the optional arguments:
self.transform_into_re_im = transform_into_re_im
self.include_mag_forces = include_mag_forces
# Should we use sparse matrices?
if use_sparse_matrices is None:
if self.hamiltonian.n>10: # Generally offers a performance increase
self.use_sparse_matrices = True
else:
self.use_sparse_matrices = False
else:
self.use_sparse_matrices = use_sparse_matrices
# Set up a dictionary to store any resulting force profiles.
self.profile = {}
# Reset the current solution to None
self.sol = None
# There will be time-dependent and time-independent components of the optical
# Bloch equations. The time-independent parts are related to spontaneous
# emission, applied magnetic field, and the zero-field Hamiltonian. We
# compute the latter-two directly from the commuatator.
# Build the matricies that control evolution:
self.ev_mat = {}
self.__build_decay_ev()
self.__build_coherent_ev()
# If necessary, transform the evolution matrices:
if self.transform_into_re_im:
self.__transform_ev_matrices()
if self.use_sparse_matrices:
self.__convert_to_sparse()
def __density_index(self, ii, jj):
"""
This function returns the index in the rho vector that corresponds to element rho_{ij}. If
"""
return ii + jj*self.hamiltonian.n
def __build_coherent_ev_submatrix(self, H):
"""
This method builds the coherent evolution based on a submatrix of the
Hamiltonian H. In practice, one must be careful about commutators if
one breaks up the Hamiltonian.
"""
ev_mat = np.zeros((self.hamiltonian.n**2, self.hamiltonian.n**2),
dtype='complex128')
for ii in range(self.hamiltonian.n):
for jj in range(self.hamiltonian.n):
for kk in range(self.hamiltonian.n):
ev_mat[self.__density_index(ii, jj),
self.__density_index(ii, kk)] += 1j*H[kk, jj]
ev_mat[self.__density_index(ii, jj),
self.__density_index(kk, jj)] -= 1j*H[ii, kk]
return ev_mat
def __build_coherent_ev(self):
self.ev_mat['H0'] = self.__build_coherent_ev_submatrix(
self.hamiltonian.H_0
)
self.ev_mat['B'] = [None]*3
for q in range(3):
self.ev_mat['B'][q] = self.__build_coherent_ev_submatrix(
self.hamiltonian.mu_q[q]
)
self.ev_mat['B'] = np.array(self.ev_mat['B'])
self.ev_mat['d_q'] = {}
self.ev_mat['d_q*'] = {}
for key in self.laserBeams.keys():
gamma = self.hamiltonian.blocks[self.hamiltonian.laser_keys[key]].parameters['gamma']
self.ev_mat['d_q'][key] = [None]*3
self.ev_mat['d_q*'][key] = [None]*3
for q in range(3):
self.ev_mat['d_q'][key][q] = self.__build_coherent_ev_submatrix(
gamma*self.hamiltonian.d_q_bare[key][q]/4.
)
self.ev_mat['d_q*'][key][q] = self.__build_coherent_ev_submatrix(
gamma*self.hamiltonian.d_q_star[key][q]/4.
)
self.ev_mat['d_q'][key] = np.array(self.ev_mat['d_q'][key])
self.ev_mat['d_q*'][key] = np.array(self.ev_mat['d_q*'][key])
def __build_decay_ev(self):
"""
This method constructs the decay portion of the OBE using the radiation
reaction approximation.
"""
d_q_bare = self.hamiltonian.d_q_bare
d_q_star = self.hamiltonian.d_q_star
self.decay_rates = {}
self.decay_rates_truncated = {}
self.decay_rho_indices = {}
self.recoil_velocity = {}
self.ev_mat['decay'] = np.zeros((self.hamiltonian.n**2,
self.hamiltonian.n**2),
dtype='complex128')
# Go through each dipole moment and calculate:
for key in d_q_bare:
ev_mat = np.zeros((self.hamiltonian.n**2, self.hamiltonian.n**2),
dtype='complex128')
gamma = self.hamiltonian.blocks[self.hamiltonian.laser_keys[key]].parameters['gamma']
# The first index we want to capture:
for ii in range(self.hamiltonian.n):
# The second index we want to capture:
for jj in range(self.hamiltonian.n):
# The first sum index:
for kk in range(self.hamiltonian.n):
# The second sum index:
for ll in range(self.hamiltonian.n):
for mm, q in enumerate(np.arange(-1., 2., 1)):
# first term in the commutator, first part:
ev_mat[self.__density_index(ii, jj),
self.__density_index(ll, jj)] -= \
d_q_star[key][mm, ll, kk]*d_q_bare[key][mm, kk, ii]
# first term in the commutator, second part:
ev_mat[self.__density_index(ii, jj),
self.__density_index(kk, ll)] += \
d_q_star[key][mm, kk, ii]*d_q_bare[key][mm, jj, ll]
# second term in the commutator, first part:
ev_mat[self.__density_index(ii, jj),
self.__density_index(ll, kk)] += \
d_q_star[key][mm, ll, ii]*d_q_bare[key][mm, jj, kk]
# second term in the commutator, second part:
ev_mat[self.__density_index(ii, jj),
self.__density_index(ii, ll)] -= \
d_q_star[key][mm, jj, kk]*d_q_bare[key][mm, kk, ll]
# Normalize:
ev_mat = 0.5*gamma*ev_mat
# Save the decay rates for the evolve_motion function:
self.decay_rates[key] = -np.real(np.array(
[ev_mat[self.__density_index(ii, ii), self.__density_index(ii, ii)]
for ii in range(self.hamiltonian.n)]
))
# These are useful for the random evolution part:
self.decay_rates_truncated[key] = self.decay_rates[key][self.decay_rates[key]>0]
self.decay_rho_indices[key] = np.array([self.__density_index(ii, ii)
for ii, rate in enumerate(self.decay_rates[key]) if rate>0]
)
self.recoil_velocity[key] = \
self.hamiltonian.blocks[self.hamiltonian.laser_keys[key]].parameters['k']\
/self.hamiltonian.mass
self.ev_mat['decay'] += ev_mat
return self.ev_mat['decay']
def __build_transform_matrices(self):
self.U = np.zeros((self.hamiltonian.n**2, self.hamiltonian.n**2),
dtype='complex128')
self.Uinv = np.zeros((self.hamiltonian.n**2, self.hamiltonian.n**2),
dtype='complex128')
for ii in range(self.hamiltonian.n):
self.U[self.__density_index(ii, ii),
self.__density_index(ii, ii)] = 1.
self.Uinv[self.__density_index(ii, ii),
self.__density_index(ii, ii)] = 1.
for ii in range(self.hamiltonian.n):
for jj in range(ii+1, self.hamiltonian.n):
self.U[self.__density_index(ii, jj),
self.__density_index(ii, jj)] = 1.
self.U[self.__density_index(ii, jj),
self.__density_index(jj, ii)] = 1j
self.U[self.__density_index(jj, ii),
self.__density_index(ii, jj)] = 1.
self.U[self.__density_index(jj, ii),
self.__density_index(jj, ii)] = -1j
for ii in range(self.hamiltonian.n):
for jj in range(ii+1, self.hamiltonian.n):
self.Uinv[self.__density_index(ii, jj),
self.__density_index(ii, jj)] = 0.5
self.Uinv[self.__density_index(ii, jj),
self.__density_index(jj, ii)] = 0.5
self.Uinv[self.__density_index(jj, ii),
self.__density_index(ii, jj)] = -0.5*1j
self.Uinv[self.__density_index(jj, ii),
self.__density_index(jj, ii)] = +0.5*1j
def __transform_ev_matrix(self, ev_mat):
if not hasattr(self, 'U'):
self.__build_transform_matrices()
ev_mat_new = self.Uinv @ ev_mat @ self.U
# This should remove the imaginary component.
if np.allclose(np.imag(ev_mat_new), 0):
return np.real(ev_mat_new)
else:
raise ValueError('Something went dreadfully wrong.')
def __transform_ev_matrices(self):
self.ev_mat['decay'] = self.__transform_ev_matrix(self.ev_mat['decay'])
self.ev_mat['H0'] = self.__transform_ev_matrix(self.ev_mat['H0'])
self.ev_mat['reE'] = {}
self.ev_mat['imE'] = {}
for key in self.ev_mat['d_q'].keys():
self.ev_mat['reE'][key] = np.array([self.__transform_ev_matrix(
self.ev_mat['d_q'][key][jj] + self.ev_mat['d_q*'][key][jj]
) for jj in range(3)])
# Unclear why the following works, I calculate that there should
# be a minus sign out front.
self.ev_mat['imE'][key] = np.array([self.__transform_ev_matrix(
1j*(self.ev_mat['d_q'][key][jj] - self.ev_mat['d_q*'][key][jj])
) for jj in range(3)])
# Transform Bq back into Bx, By, and Bz (making it real):
self.ev_mat['B'] = spherical2cart(self.ev_mat['B'])
for jj in range(3):
self.ev_mat['B'][jj] = self.__transform_ev_matrix(self.ev_mat['B'][jj])
self.ev_mat['B'] = np.real(self.ev_mat['B'])
del self.ev_mat['d_q']
del self.ev_mat['d_q*']
def __convert_to_sparse(self):
def convert_based_on_shape(matrix):
# Vector:
if matrix.shape == (3, self.hamiltonian.n**2, self.hamiltonian.n**2):
new_list = [None]*3
for jj in range(3):
new_list[jj] = sparse.csr_matrix(matrix[jj])
return new_list
# Scalar:
else:
return sparse.csr_matrix(matrix)
for key in self.ev_mat:
if isinstance(self.ev_mat[key], dict):
for subkey in self.ev_mat[key]:
self.ev_mat[key][subkey] = convert_based_on_shape(
self.ev_mat[key][subkey]
)
else:
self.ev_mat[key] = convert_based_on_shape(self.ev_mat[key])
def __reshape_rho(self, rho):
if self.transform_into_re_im:
rho = rho.astype('complex128')
if len(rho.shape) == 1:
rho = self.U @ rho
else:
for jj in range(rho.shape[1]):
rho[:, jj] = self.U @ rho[:, jj]
rho = rho.reshape((self.hamiltonian.n, self.hamiltonian.n) +
rho.shape[1:])
"""# If not:
if self.transform_into_re_im:
new_rho = np.zeros(rho.shape, dtype='complex128')
for jj in range(new_rho.shape[2]):
new_rho[:, :, jj] = (np.diag(np.diagonal(rho[:, :, jj])) +
np.triu(rho[:, :, jj], k=1) +
np.triu(rho[:, :, jj], k=1).T +
1j*np.tril(rho[:, :, jj], k=-1) -
1j*np.tril(rho[:, :, jj], k=-1).T)
rho = new_rho"""
return rho
def __reshape_sol(self):
"""
Reshape the solution to have all the proper parts.
"""
self.sol.rho = self.__reshape_rho(self.sol.y[:-6])
self.sol.r = np.real(self.sol.y[-3:])
self.sol.v = np.real(self.sol.y[-6:-3])
del self.sol.y
[docs] def set_initial_rho(self, rho0):
"""
Sets the initial :math:`\\rho` matrix
Parameters
----------
rho0 : array_like
The initial :math:`\\rho`. It must have :math:`n^2` elements, where :math:`n`
is the total number of states in the system. If a flat array, it
will be reshaped.
"""
if np.any(np.isnan(rho0)) or np.any(np.isinf(rho0)):
raise ValueError('rho0 has NaNs or Infs!')
if rho0.size != self.hamiltonian.n**2:
raise ValueError('rho0 should have n^2 elements.')
if rho0.shape == (self.hamiltonian.n, self.hamiltonian.n):
rho0 = rho0.flatten()
if self.transform_into_re_im and rho0.dtype is np.dtype('complex128'):
self.rho0 = self.Uinv @ rho0
elif (not self.transform_into_re_im and
not rho0.dtype is np.dtype('complex128')):
self.rho0 = rho0.astype('complex128')
else:
self.rho0 = rho0
[docs] def set_initial_rho_equally(self):
"""
Sets the initial :math:`\\rho` matrix such that all states have the same
population.
"""
if self.transform_into_re_im:
self.rho0 = np.zeros((self.hamiltonian.n**2,))
else:
self.rho0 = np.zeros((self.hamiltonian.n**2,), dtype='complex128')
for jj in range(self.hamiltonian.ns[0]):
self.rho0[self.__density_index(jj, jj)] = 1/self.hamiltonian.ns[0]
[docs] def set_initial_rho_from_populations(self, Npop):
"""
Sets the diagonal elements of the initial :math:`\\rho` matrix
Parameters
----------
Npop : array_like
Array of the initial populations of the states in the system. The
length must be :math:`n`, where :math:`n` is the number of states.
"""
if self.transform_into_re_im:
self.rho0 = np.zeros((self.hamiltonian.n**2,))
else:
self.rho0 = np.zeros((self.hamiltonian.n**2,), dtype='complex128')
if len(Npop) != self.hamiltonian.n:
raise ValueError('Npop has only %d entries for %d states.' %
(len(Npop), self.hamiltonian.n))
if np.any(np.isnan(Npop)) or np.any(np.isinf(Npop)):
raise ValueError('Npop has NaNs or Infs!')
Npop = Npop/np.sum(Npop) # Just make sure it is normalized.
for jj in range(self.hamiltonian.n):
self.rho0[self.__density_index(jj, jj)] = Npop[jj]
[docs] def set_initial_rho_from_rateeq(self):
"""
Sets the diagonal elements of the initial :math:`\\rho` matrix using
the equilibrium populations as determined by pylcp.rateeq
"""
if not hasattr(self, 'rateeq'):
self.rateeq = rateeq(self.laserBeams, self.magField, self.hamiltonian)
Neq = self.rateeq.equilibrium_populations(self.r0, self.v0, t=0)
self.set_initial_rho_from_populations(Neq)
[docs] def full_OBE_ev_scratch(self, r, t):
"""
Calculate the evolution for the density matrix
This function calculates the OBE evolution matrix at position t and r
from scratch, first computing the full Hamiltonian, then the
OBE evolution matrix computed via commutators, then adding in the decay
matrix evolution. If `Bq` is `None`, it will compute `Bq`.
Parameters
----------
r : array_like, shape (3,)
Position at which to calculate evolution matrix
t : float
Time at which to calculate evolution matrix
Returns
-------
ev_mat : array_like
Evolution matrix for the densities
"""
Eq = {}
for key in self.laserBeams.keys():
Eq[key] = self.laserBeams[key].total_electric_field(r, t)
B = self.magField.Field(r, t)
Bq = cart2spherical(B)
H = self.hamiltonian.return_full_H(Bq, Eq)
ev_mat = self.__build_coherent_ev_submatrix(H)
if self.transform_into_re_im:
return self.__transform_ev_matrix(ev_mat + self.ev_mat['decay'])
else:
return ev_mat + self.ev_mat['decay']
[docs] def full_OBE_ev(self, r, t):
"""
Calculate the evolution for the density matrix
This function calculates the OBE evolution matrix by assembling
pre-stored versions of the component matries. This should be
significantly faster than full_OBE_ev_scratch, but it may suffer bugs
in the evolution that full_OBE_ev_scratch will not. If Bq is None,
it will compute Bq based on r, t
Parameters
----------
r : array_like, shape (3,)
Position at which to calculate evolution matrix
t : float
Time at which to calculate evolution matrix
Returns
-------
ev_mat : array_like
Evolution matrix for the densities
"""
ev_mat = self.ev_mat['decay'] + self.ev_mat['H0']
# Add in electric fields:
for key in self.laserBeams.keys():
if self.transform_into_re_im:
Eq = self.laserBeams[key].total_electric_field(r, t)
for ii in range(3):
if np.abs(Eq[ii])>1e-10:
ev_mat -= np.real(Eq[ii])*self.ev_mat['reE'][key][ii]
ev_mat -= np.imag(Eq[ii])*self.ev_mat['imE'][key][ii]
else:
Eq = self.laserBeams[key].total_electric_field(np.real(r), t)
for ii in range(3):
if np.abs(Eq[ii])>1e-10:
ev_mat -= np.conjugate(Eq[ii])*self.ev_mat['d_q'][key][ii]
ev_mat -= Eq[ii]*self.ev_mat['d_q*'][key][ii]
# Add in magnetic fields:
B = self.magField.Field(r, t)
for ii, q in enumerate(range(-1, 2)):
if self.transform_into_re_im:
if np.abs(Bq[ii])>1e-10:
drhodt -= self.ev_mat['B'][ii]*B[ii] @ rho
else:
Bq = cart2spherical(B)
if np.abs(Bq[2-ii])>1e-10:
drhodt -= (-1)**np.abs(q)*self.ev_mat['B'][ii]*Bq[2-ii] @ rho
return ev_mat
def __drhodt(self, r, t, rho):
"""
It is MUCH more efficient to do matrix vector products and add the
results together rather than to add the matrices together (as above)
and then do the dot. It is also most efficient to avoid doing useless
math if the applied field is zero.
"""
drhodt = (self.ev_mat['decay'] @ rho) + (self.ev_mat['H0'] @ rho)
# Add in electric fields:
for key in self.laserBeams.keys():
if self.transform_into_re_im:
Eq = self.laserBeams[key].total_electric_field(r, t)
for ii, q in enumerate(np.arange(-1., 2., 1)):
if np.abs(Eq[2-ii])>1e-10:
drhodt -= ((-1.)**q*np.real(Eq[2-ii])*
(self.ev_mat['reE'][key][ii] @ rho))
drhodt -= ((-1.)**q*np.imag(Eq[2-ii])*
(self.ev_mat['imE'][key][ii] @ rho))
else:
Eq = self.laserBeams[key].total_electric_field(np.real(r), t)
for ii, q in enumerate(np.arange(-1., 2., 1)):
if np.abs(Eq[2-ii])>1e-10:
drhodt -= ((-1.)**q*Eq[2-ii]*
(self.ev_mat['d_q'][key][ii] @ rho))
drhodt -= ((-1.)**q*np.conjugate(Eq[2-ii])*
(self.ev_mat['d_q*'][key][ii] @ rho))
# Add in magnetic fields:
B = self.magField.Field(r, t)
for ii, q in enumerate(range(-1, 2)):
if self.transform_into_re_im:
if np.abs(B[ii])>1e-10:
drhodt -= self.ev_mat['B'][ii]*B[ii] @ rho
else:
Bq = cart2spherical(B)
if np.abs(Bq[ii])>1e-10:
drhodt -= self.ev_mat['B'][ii]*np.conjugate(Bq[ii]) @ rho
return drhodt
[docs] def evolve_density(self, t_span, progress_bar=False, **kwargs):
"""
Evolve the density operators :math:`\\rho_{ij}` in time.
This function integrates the optical Bloch equations to determine how
the populations evolve in time. Any initial velocity is kept constant
while the atom potentially moves through the light field. This function
is therefore useful in determining average forces. Any constant
acceleration set when the OBEs were generated is ignored. It is
analogous to rateeq.evolve_populations().
Parameters
----------
t_span : list or array_like
A two element list or array that specify the initial and final time
of integration.
progress_bar : boolean
Show a progress bar as the calculation proceeds. Default:False
**kwargs :
Additional keyword arguments get passed to solve_ivp, which is
what actually does the integration.
Returns
-------
sol : OdeSolution
Bunch object that contains the following fields:
* t: integration times found by solve_ivp
* rho: density matrix
* v: atomic velocity (constant)
* r: atomic position
It contains other important elements, which can be discerned from
scipy's solve_ivp documentation.
"""
a = np.zeros((3,))
if progress_bar:
progress = progressBar()
def dydt(t, y):
if progress_bar and t<=t_span[1]:
progress.update(t/t_span[1])
return np.concatenate((self.__drhodt(y[-3:], t, y[:-6]), a, y[-6:-3]))
self.sol = solve_ivp(dydt, t_span,
np.concatenate((self.rho0, self.v0, self.r0)),
**kwargs)
if progress_bar:
# Just in case the solve_ivp_random terminated due to an event.
progress.update(1.)
# Remake the solution:
self.__reshape_sol()
return self.sol
[docs] def evolve_motion(self, t_span, freeze_axis=[False, False, False],
random_recoil=False, max_scatter_probability=0.1,
progress_bar=False, record_force=False,
rng=np.random.default_rng(), **kwargs):
"""
Evolve :math:`\\rho_{ij}` and the motion of the atom in time.
This function evolves the optical Bloch equations, moving the atom
along given the instantaneous force, for some period of time.
Parameters
----------
t_span : list or array_like
A two element list or array that specify the initial and final time
of integration.
freeze_axis : list of boolean
Freeze atomic motion along the specified axis.
Default: [False, False, False]
random_recoil : boolean
Allow the atom to randomly recoil from scattering events.
Default: False
max_scatter_probability : float
When undergoing random recoils, this sets the maximum time step such
that the maximum scattering probability is less than or equal to
this number during the next time step. Default: 0.1
progress_bar : boolean
If true, show a progress bar as the calculation proceeds.
Default: False
record_force : boolean
If true, record the instantaneous force and store in the solution.
Default: False
rng : numpy.random.Generator()
A properly-seeded random number generator. Default: calls
``numpy.random.default.rng()``
**kwargs :
Additional keyword arguments get passed to solve_ivp_random, which
is what actually does the integration.
Returns
-------
sol : OdeSolution
Bunch object that contains the following fields:
* t: integration times found by solve_ivp
* rho: density matrix
* v: atomic velocity
* r: atomic position
It contains other important elements, which can be discerned from
scipy's solve_ivp documentation.
"""
free_axes = np.bitwise_not(freeze_axis)
random_recoil_flag = random_recoil
if progress_bar:
progress = progressBar()
if record_force:
ts = []
Fs = []
def dydt(t, y):
if progress_bar:
progress.update(t/t_span[1])
if record_force:
F = self.force(y[-3:], t, y[:-6], return_details=True)
ts.append(t)
Fs.append(F)
F = F[0]
else:
F = self.force(y[-3:], t, y[:-6], return_details=False)
return np.concatenate((
self.__drhodt(y[-3:], t, y[:-6]),
F*free_axes/self.hamiltonian.mass + self.constant_accel,
y[-6:-3]
))
def random_recoil(t, y, dt):
num_of_scatters = 0
total_P = 0.
# Go over each block in the Hamiltonian and compute the decay:
for key in self.decay_rates:
P = dt*self.decay_rates_truncated[key]*np.real(y[self.decay_rho_indices[key]])
# Roll the dice N times, where $N=\sum(n_i)
dice = rng.random(len(P))
# For any random number that is lower than P_i, add a
# recoil velocity.
for ii in range(np.sum(dice<P)):
num_of_scatters += 1
y[-6:-3] += self.recoil_velocity[key]*(random_vector(rng, free_axes)+
random_vector(rng, free_axes))
# Save the total probability of a scatter:
total_P += np.sum(P)
# Calculate a new maximum dt to make sure we evolve while not
# exceeding dt max:
new_dt_max = (max_scatter_probability/total_P)*dt
return (num_of_scatters, new_dt_max)
if not random_recoil_flag:
self.sol = solve_ivp(
dydt, t_span, np.concatenate((self.rho0, self.v0, self.r0)),
**kwargs)
else:
self.sol = solve_ivp_random(
dydt, random_recoil, t_span,
np.concatenate((self.rho0, self.v0, self.r0)),
**kwargs
)
if progress_bar:
# Just in case the solve_ivp_random terminated due to an event.
progress.update(1.)
# Remake the solution:
self.__reshape_sol()
# Interpolate the force:
if record_force:
f = interp1d(ts[:-1], np.array([f[0] for f in Fs[:-1]]).T)
self.sol.F = f(self.sol.t)
f = interp1d(ts[:-1], np.array([f[3] for f in Fs[:-1]]).T)
self.sol.fmag = f(self.sol.t)
self.sol.f = {}
for key in Fs[0][1]:
f = interp1d(ts[:-1], np.array([f[1][key] for f in Fs[:-1]]).T)
self.sol.f[key] = f(self.sol.t)
self.sol.f[key] = np.swapaxes(self.sol.f[key], 0, 1)
return self.sol
[docs] def observable(self, O, rho=None):
"""
Observable returns the obervable O given density matrix rho.
Parameters
----------
O : array or array-like
The matrix form of the observable operator. Can have any shape,
representing scalar, vector, or tensor operators, but the last two
axes must correspond to the matrix of the operator and have the
same dimensions of the generating Hamiltonian. For example,
a vector operator might have the shape (3, n, n), where n
is the number of states and the first axis corresponds to x, y,
and z.
rho : [optional] array or array-like
The density matrix. The first two dimensions must have sizes
(n, n), but there may be multiple instances of the density matrix
tiled in the higher dimensions. For example, a rho with (n, n, m)
could have m instances of the density matrix at different times.
If not specified, will get rho from the current solution stored in
memory.
Returns
-------
observable : float or array
observable has shape (O[:-2])+(rho[2:])
"""
if rho is None:
rho = self.sol.rho
if rho.shape[:2]!=(self.hamiltonian.n, self.hamiltonian.n):
raise ValueError('rho must have dimensions (n, n,...), where n '+
'corresponds to the number of states in the '+
'generating Hamiltonian. ' +
'Instead, shape of rho is %s.'%str(rho.shape))
elif O.shape[-2:]!=(self.hamiltonian.n, self.hamiltonian.n):
raise ValueError('O must have dimensions (..., n, n), where n '+
'corresponds to the number of states in the '+
'generating Hamiltonian. ' +
'Instead, shape of O is %s.'%str(O.shape))
else:
avO = np.tensordot(O, rho, axes=[(-2, -1),(0, 1)])
if np.allclose(np.imag(avO), 0):
return np.real(avO)
else:
return avO
[docs] def force(self, r, t, rho, return_details=False):
"""
Calculates the instantaneous force
Parameters
----------
r : array_like
Position at which to calculate the force
t : float
Time at which to calculate the force
rho : array_like
Density matrix with which to calculate the force
return_details : boolean, optional
If true, returns the forces from each laser and the scattering rate
matrix.
Returns
-------
F : array_like
total equilibrium force experienced by the atom
F_laser : array_like
If return_details is True, the forces due to each laser.
F_laser_q : array_like
If return_details is True, the forces due to each laser and it's
q component of the polarization.
F_mag : array_like
If return_details is True, the forces due to the magnetic field.
"""
if rho.shape[0] != self.hamiltonian.n:
rho = self.__reshape_rho(rho)
f = np.zeros((3,) + rho.shape[2:])
if return_details:
f_laser_q = {}
f_laser = {}
for key in self.laserBeams:
# First, determine the average mu_q:
# This returns a (3,) + rho.shape[2:] array
mu_q_av = self.observable(self.hamiltonian.d_q_bare[key], rho)
gamma = self.hamiltonian.blocks[self.hamiltonian.laser_keys[key]].parameters['gamma']
if not return_details:
delE = self.laserBeams[key].total_electric_field_gradient(np.real(r), t)
# We are just looking at the d_q, whereas the full observable
# is \nabla (d_q \cdot E^\dagger) + (d_q^* E)) =
# 2 Re[\nabla (d_q\cdot E^\dagger)]. Putting in the units,
# we see we need a factor of gamma/4, making
# this 2 Re[\nabla (d_q\cdot E^\dagger)]/4 =
# Re[\nabla (d_q\cdot E^\dagger)]/2
for jj, q in enumerate(np.arange(-1., 2., 1.)):
f += np.real((-1)**q*gamma*mu_q_av[jj]*delE[:, 2-jj])/2
else:
f_laser_q[key] = np.zeros((3, 3, self.laserBeams[key].num_of_beams)
+ rho.shape[2:])
f_laser[key] = np.zeros((3, self.laserBeams[key].num_of_beams)
+ rho.shape[2:])
# Now, dot it into each laser beam:
for ii, beam in enumerate(self.laserBeams[key].beam_vector):
if not self.transform_into_re_im:
delE = beam.electric_field_gradient(np.real(r), t)
else:
delE = beam.electric_field_gradient(r, t)
for jj, q in enumerate(np.arange(-1., 2., 1.)):
f_laser_q[key][:, jj, ii] += \
np.real((-1)**q*gamma*mu_q_av[jj]*delE[:, 2-jj])/2
f_laser[key][:, ii] = np.sum(f_laser_q[key][:, :, ii], axis=1)
f+=np.sum(f_laser[key], axis=1)
# Are we including magnetic forces?
if self.include_mag_forces:
# This function returns a matrix that is either (3, 3) (if constant)
# or (3, 3, t.size). The first two dimensions are like
# [dBx/dx, dBy/dx, dBz/dx; dBx/dy, dBy/dy, dBz/dy], and so on.
# We need to dot, and su
delB = self.magField.gradField(np.real(r))
# What's the expectation value of mu? Returns (3,) or (3, t.size)
av_mu = self.observable(self.hamiltonian.mu, rho)
# Now dot it into the gradient:
f_mag = cartesian_vector_tensor_dot(av_mu, delB)
# Add it into the regular force.
f+=f_mag
elif return_details:
f_mag=np.zeros(f.shape)
if return_details:
return f, f_laser, f_laser_q, f_mag
else:
return f
[docs] def find_equilibrium_force(self, deltat=500, itermax=100, Npts=5001,
rel=1e-5, abs=1e-9, debug=False,
initial_rho ='rateeq',
return_details=False, **kwargs):
"""
Finds the equilibrium force at the initial position
This method works by solving the OBEs in a chunk of time
:math:`\\Delta T`, calculating the force during that chunck, continuing
the integration for another chunck, calculating the force during that
subsequent chunck, and comparing the average of the forces of the two
chunks to see if they have converged.
Parameters
----------
deltat : float
Chunk time :math:`\\Delta T`. Default: 500
itermax : int, optional
Maximum number of iterations. Default: 100
Npts : int, optional
Number of points to divide the chunk into. Default: 5001
rel : float, optional
Relative convergence parameter. Default: 1e-5
abs : float, optional
Absolute convergence parameter. Default: 1e-9
debug : boolean, optional
If true, pring out debug information as it goes.
initial_rho : 'rateeq' or 'equally'
Determines how to set the initial rho at the start of the
calculation.
return_details : boolean, optional
If true, returns the forces from each laser and the scattering rate
matrix.
Returns
-------
F : array_like
total equilibrium force experienced by the atom
F_laser : dictionary of array_like
If return_details is True, the forces due to each laser, indexed
by the manifold the laser addresses. The dictionary is keyed by
the transition driven, and individual lasers are in the same order
as in the pylcp.laserBeams object used to create the governing
equation.
F_laser : dictionary of array_like
If return_details is True, the forces due to each laser and its q
component, indexed by the manifold the laser addresses. The
dictionary is keyed by the transition driven, and individual lasers
are in the same order as in the pylcp.laserBeams object used to
create the governing equation.
F_mag : array_like
If return_details is True, the forces due to the magnetic field.
Neq : array_like
If return_details is True, the equilibrium populations.
ii : int
Number of iterations needed to converge.
"""
if initial_rho == 'rateeq':
self.set_initial_rho_from_rateeq()
elif initial_rho == 'equally':
self.set_initial_rho_equally()
elif initial_rho == 'frompops':
Npop = kwargs.pop('init_pop', None)
self.set_initial_rho_from_populations(Npop)
else:
raise ValueError('Argument initial_rho=%s not understood'%initial_rho)
old_f_avg = np.array([np.inf, np.inf, np.inf])
if debug:
print('Finding equilbrium force at '+
'r=(%.2f, %.2f, %.2f) ' % (self.r0[0], self.r0[1], self.r0[2]) +
'v=(%.2f, %.2f, %.2f) ' % (self.v0[0], self.v0[1], self.v0[2]) +
'with deltat = %.2f, itermax = %d, Npts = %d, ' % (deltat, itermax, Npts) +
'rel = %.1e and abs = %.1e' % (rel, abs)
)
self.piecewise_sols = []
ii=0
while True:
if not Npts is None:
kwargs['t_eval'] = np.linspace(ii*deltat, (ii+1)*deltat, int(Npts))
self.evolve_density([ii*deltat, (ii+1)*deltat], **kwargs)
f, f_laser, f_laser_q, f_mag = self.force(self.sol.r, self.sol.t, self.sol.rho,
return_details=True)
f_avg = np.mean(f, axis=1)
if debug:
print(ii, f_avg, np.sum(f_avg**2))
self.piecewise_sols.append(self.sol)
if (np.sum((f_avg)**2)<abs or
np.sum((old_f_avg-f_avg)**2)/np.sum((f_avg)**2)<rel or
np.sum((old_f_avg-f_avg)**2)<abs):
break;
elif ii>=itermax-1:
break;
else:
old_f_avg = copy.copy(f_avg)
self.set_initial_rho(self.sol.rho[:, :, -1])
self.set_initial_position_and_velocity(self.sol.r[:, -1],
self.sol.v[:, -1])
ii+=1
if return_details:
f_mag = np.mean(f_mag, axis=1)
f_laser_avg = {}
f_laser_avg_q = {}
for key in f_laser:
f_laser_avg[key] = np.mean(f_laser[key], axis=2)
f_laser_avg_q[key] = np.mean(f_laser_q[key], axis=3)
Neq = np.real(np.diagonal(np.mean(self.sol.rho, axis=2)))
return (f_avg, f_laser_avg, f_laser_avg_q, f_mag, Neq, ii)
else:
return f_avg
[docs] def generate_force_profile(self, R, V, name=None, progress_bar=False,
**kwargs):
"""
Map out the equilibrium force vs. position and velocity
Parameters
----------
R : array_like, shape(3, ...)
Position vector. First dimension of the array must be length 3, and
corresponds to :math:`x`, :math:`y`, and :math:`z` components,
repsectively.
V : array_like, shape(3, ...)
Velocity vector. First dimension of the array must be length 3, and
corresponds to :math:`v_x`, :math:`v_y`, and :math:`v_z` components,
repsectively.
name : str, optional
Name for the profile. Stored in profile dictionary in this object.
If None, uses the next integer, cast as a string, (i.e., '0') as
the name.
progress_bar : boolean, optional
Displays a progress bar as the proceeds. Default: False
Returns
-------
profile : pylcp.obe.force_profile
Resulting force profile.
"""
def default_deltat(r, v, deltat_v, deltat_r, deltat_tmax):
deltat = None
if deltat_v is not None:
vabs = np.sqrt(np.sum(v**2))
if vabs==0.:
deltat = deltat_tmax
else:
deltat = np.min([2*np.pi*deltat_v/vabs, deltat_tmax])
if deltat_r is not None:
rabs = np.sqrt(np.sum(r**2))
if rabs==0.:
deltat = deltat_tmax
else:
deltat = np.min([2*np.pi*deltat_r/rabs, deltat_tmax])
return deltat
deltat_r = kwargs.pop('deltat_r', None)
deltat_v = kwargs.pop('deltat_v', None)
deltat_tmax = kwargs.pop('deltat_tmax', np.inf)
deltat_func = kwargs.pop(
'deltat_func',
lambda r, v: default_deltat(r, v, deltat_v, deltat_r, deltat_tmax)
)
if not name:
name = '{0:d}'.format(len(self.profile))
self.profile[name] = force_profile(R, V, self.laserBeams, self.hamiltonian)
it = np.nditer([R[0], R[1], R[2], V[0], V[1], V[2]],
flags=['refs_ok', 'multi_index'],
op_flags=[['readonly'], ['readonly'], ['readonly'],
['readonly'], ['readonly'], ['readonly']])
if progress_bar:
progress = progressBar()
for (x, y, z, vx, vy, vz) in it:
# Construct the rate equations:
r = np.array([x, y, z])
v = np.array([vx, vy, vz])
if progress_bar:
tic = time.time()
self.set_initial_position_and_velocity(r, v)
if not deltat_func(r, v) is None:
kwargs['deltat'] = deltat_func(r, v)
kwargs['return_details'] = True
F, F_laser, F_laser_q, F_mag, Neq, iterations = self.find_equilibrium_force(**kwargs)
self.profile[name].store_data(it.multi_index, Neq, F, F_laser, F_mag,
iterations, F_laser_q)
if progress_bar:
progress.update((it.iterindex+1)/it.itersize)
return self.profile[name]