Source code for MoleCool.System

# -*- coding: utf-8 -*-
"""
This module contains the main class :class:`~MoleCool.System.System` which provides all
information about the lasers light fields, the atomic or molecular level structure,
and the magnetic field to carry out simulation calculations, e.g.
via the rate equations or Optical Bloch equations (OBEs).
"""
import numpy as np
from scipy.integrate import solve_ivp, cumulative_trapezoid
from scipy.constants import c,h,hbar,pi,g,physical_constants
from scipy.constants import k as k_B
from scipy.constants import u as u_mass
from sympy.physics.wigner import clebsch_gordan,wigner_3j,wigner_6j
from MoleCool.Lasersystem import *
from MoleCool.Levelsystem import *
from MoleCool import tools
from MoleCool.tools import save_object, open_object, ODEs, return_fun_default
from MoleCool.Bfield import Bfield
import time
import sys, os
from copy import deepcopy
import multiprocessing
from tqdm import tqdm
import warnings

import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.pylab as pl
import matplotlib.ticker as mtick

np.set_printoptions(precision=4,suppress=True)
#%%
[docs] class System: def __init__(self, description=None,load_constants='',verbose=True): """An instance of this class is the starting point for simulating any atomic or molecular dynamics simulations. Specficially, it defines an object to not only store all important information about the system but also to calculate any time evolution. Parameters ---------- description : str, optional A short description of this System can be added. If not provided, the attribute is set to the name of the respective executed main python file. load_constants : str, optional File name of a certain molecule, atom or more general system whose respective level constants to be loaded or imported by the class :class:`~MoleCool.Levelsystem.Levelsystem` via the constants defined in the .json file. The default is ''. Example ------- After initiating a :class:`~MoleCool.System.System` object, the instances of :class:`~MoleCool.Lasersystem.Lasersystem`, :class:`~MoleCool.Levelsystem.Levelsystem`, and :class:`~MoleCool.Bfield.Bfield` can be accessed via:: system = System() print(system.lasers) print(system.levels) print(system.Bfield) """ self.lasers = Lasersystem() self.levels = Levelsystem(load_constants=load_constants,verbose=verbose) self.Bfield = Bfield() #self.particles = Particlesystem() if description == None: self.description = os.path.basename(sys.argv[0])[:-3] else: self.description = description self.reset_N0() # initial population of all levels self.v0 = np.array([0.,0.,0.]) #: initial velocity of the particle self.r0 = np.array([0.,0.,0.]) #: initial position of the particle """dictionary for parameters specifying the steady state conditions.""" self.steadystate = {'t_ini' : None, 'maxiters' : 100, 'condition' : [0.1,50], 'period' : None} """dictionary for parameters specifying the multiprocessing of calculations.""" self.multiprocessing = {'processes' : int(multiprocessing.cpu_count()*0.95),#None 'maxtasksperchild' : None, 'show_progressbar' : True, 'savetofile' : True} if verbose: print("System is created with description: {}".format(self.description))
[docs] def calc_rateeqs(self,t_int=20e-6,t_start=0.,dt=None,t_eval = [], magn_remixing=False, magn_strength=8, position_dep=False, trajectory=False, verbose=True, return_fun=return_fun_default, **kwargs): """Calculate the time evolution of the single level populations with rate equations. Parameters ---------- t_int : float, optional interaction time in which the molecule is exposed to the lasers. The default is 20e-6. t_start : float, optional starting time when the ode_solver starts the calculation. Useful for the situation when e.g. all cooling lasers are shut off at a specific time t1, so that a new calculation with another laser configuration (e.g. including only a probe laser) can be started at t_start=t1 to continue the simulation. The default is 0.0. dt : float, optional time step of the output data. So in this case the ODE solver will decide at which time points to calculate the solution. The default is None. t_eval : list or numpy array, optional If it desired to get the solution of the ode solver only at specific time points, the `t_eval` argument can be used to specify these points. If `_eval` is given, the `dt` argument is ignored. The default is []. magn_remixing : bool, optional if True, the adjacent ground hyperfine levels are perfectly mixed by a magnetic field. The default is False. magn_strength : float, optional measure of the magnetic field strength (i.e. the magnetic remixing matrix is multiplied by 10^magn_strength). Reasonable values are between 6 and 9. The default is 8. position_dep : bool, optional determines whether to take the Gaussian intensity distribution of the laser beams into account. The default is False. trajectory : bool, optional determines whether a trajectory of the molecule is calculated using simple equations of motion. This yields the additional time-dependent parameters ``v`` and ``r`` for the velocity and position. So, the force which is acting on the molecule changes the velocity which in turn can alter the Doppler shift. Further, the `position_dep` parameter determines if either a uniform unitensity or complex intensity distribution due to the Gaussian beam shapes is assumed through which the particle is propagated. The default is False. verbose : bool, optional whether to print additional information like execution time or the scattered photon number. The default is True. return_fun : function-type, optional if `mp` == True, the returned dictionary of this function determines the quantities which are save for every single parameter configuration. The default is None. **kwargs : keyword arguments, optional other options of the `solve_ivp` scipy function can be specified (see homepage of scipy for further information). Note ---- function creates attributes * ``N`` : solution of the time dependent populations N, * ``Nscatt`` : time dependent scattering number, * ``Nscattrate`` : time dependent scattering rate, * ``photons``: totally scattered photons * ``args``: input arguments of the call of this function * ``t`` : times at which the solution was calculated * ``v`` : calculated velocities of the molecule at times ``t`` (only given if `trajectory` == True) * ``r`` : calculated positions of the molecule at times ``t`` (only given if `trajectory` == True) """ self.calcmethod = 'rateeqs' #___input arguments of this called function self.args = locals() self.check_config(raise_Error=True) #___parameters belonging to the levels self.levels.calc_all() #___start multiprocessing if desired only after calculating the levels # properties so that they don't have to be re-calculated every time. if self._identify_iter_params() or self.lasers._identify_iter_params()[0]: return self._start_mp() #___parameters belonging to the lasers (and partially to the levels) # wave vector k self.k = self.lasers.getarr('k')*self.lasers.getarr('kabs')[:,None] #no unit vectors self.delta = self.lasers.getarr('omega')[None,None,:] - self.levels.calc_freq()[:,:,None] # saturation parameter of intensity (lNum,uNum,pNum) self.sp = self.lasers.getarr('I')[None,None,:]/(self.levels.Isat[:,:,None]) #polarization switching time tswitch = 1/self.lasers.freq_pol_switch self.rx1 = np.abs(np.dot(self.levels.calc_dMat(),self.lasers.getarr('f_q').T))**2 # for polarization switching: if np.any([la.pol_switching for la in self.lasers]): self.rx2 = np.abs(np.dot(self.levels.calc_dMat(),self.lasers.getarr('f_q2').T))**2 else: self.rx2 = self.rx1.copy() #___magnetic remixing of the ground states. An empty array is left for no B-field if magn_remixing: self.M = self.Bfield.get_remix_matrix(self.levels.grstates[0],remix_strength=magn_strength) else: self.M = np.array([[],[]]) #___specify the initial (normalized) occupations of the levels self.initialize_N0() #___determine the time points at which the ODE solver should evaluate the equations if len(t_eval) != 0: self.t_eval = np.array(t_eval) else: if dt != None and dt < t_int: self.t_eval = np.linspace(t_start,t_start+t_int,int(t_int/dt)+1) else: self.t_eval = None #___depenending on the position dependence two different ODE evaluation functions are called if trajectory: self.y0 = np.array([*self.N0, *self.v0, *self.r0]) else: # position dependent intensity due to Gaussian shape of Laserbeam: if position_dep: self.sp *= self.lasers.I_tot(self.r0,sum_lasers=False,use_jit=False)[None,None,:] self.R1 = self.levels.calc_Gamma()[None,:,None]/2*self.rx1*self.sp / ( 1+4*(self.delta-np.dot(self.k,self.v0)[None,None,:])**2/self.levels.calc_Gamma()[None,:,None]**2) self.R2 = self.levels.calc_Gamma()[None,:,None]/2*self.rx2*self.sp / ( 1+4*(self.delta-np.dot(self.k,self.v0)[None,None,:])**2/self.levels.calc_Gamma()[None,:,None]**2) #sum R1 & R2 over pNum: self.R1sum, self.R2sum = np.sum(self.R1,axis=2), np.sum(self.R2,axis=2) self.y0 = self.N0 #number of ground, excited states and lasers lNum,uNum,pNum = self.levels.lNum, self.levels.uNum, self.lasers.pNum # ---------------Ordinary Differential Equation solver---------------- #solve initial value problem of the ordinary first order differential equation with scipy if verbose: print('Solving ode with rate equations...', end=' ') start_time = time.perf_counter() if not trajectory: sol = solve_ivp(ODEs.ode0_rateeqs_jit, (t_start,t_start+t_int), self.y0, t_eval=self.t_eval, **self.args['kwargs'], args=(lNum,uNum,pNum,self.levels.calc_Gamma(),self.levels.calc_branratios(), self.R1sum,self.R2sum,tswitch,self.M)) else: sol = solve_ivp(ODEs.ode1_rateeqs_jit, (t_start,t_start+t_int), self.y0, t_eval=self.t_eval, **self.args['kwargs'], args=(lNum,uNum,pNum,np.reshape(self.levels.calc_Gamma(),(1,-1,1)),self.levels.calc_branratios(), self.rx1,self.rx2,self.delta,self.sp, self.lasers.getarr('w'),self.lasers.getarr('_w_cylind'), self.k,self.lasers.getarr('kabs'),self.lasers.getarr('r_k'), self.lasers.getarr('_r_cylind_trunc'),self.lasers.getarr('_dir_cylind'), #unit vectors self.levels.mass,tswitch,self.M,position_dep,self.lasers.getarr('beta'))) #velocity v and position r self.v = sol.y[-6:-3] self.r = sol.y[-3:] #: execution time for the ODE solving self.exectime = time.perf_counter()-start_time #: array of the times at which the solutions are calculated self.t = sol.t #: solution of the time dependent populations N self.N = sol.y[:lNum+uNum] self._verify_calculation() if return_fun == True: return {'system':self} elif return_fun: return return_fun(self)
#%%
[docs] def plot_all(self): self.plot_N(); self.plot_Nsum(); self.plot_dt() self.plot_Nscatt(); self.plot_Nscattrate(); self.plot_F() if ('trajectory' in self.args) and self.args['trajectory']: self.plot_r(); self.plot_v()
[docs] def plot_N(self,figname=None,figsize=(12,5),smallspacing=0): """plot populations of all levels over time.""" if figname == None: plt.figure('N ({}): {}, {}, {}'.format( self.calcmethod,self.description,self.levels.description, self.lasers.description), figsize=figsize) else: plt.figure(figname,figsize=figsize) N_sum = 0 for i,ElSt in enumerate(self.levels.electronic_states): if 'v' in ElSt.states[0].QuNrs: alphas = np.linspace(1,0,ElSt.v_max+2)[:-1] ##### j_v0 = np.array([jj for jj,st in enumerate(ElSt) if st.v == 0]) N_red = len(j_v0) else: alpha = 1.0 N = ElSt.N for j, state in enumerate(ElSt.states): ls = ['solid','dashed','dashdot','dotted','dashdotdotted', 'loosely dotted','loosely dashed','loosely dashdotted'][i] if N > 10: if 'v' in state.QuNrs: if j in j_v0: color = pl.cm.jet(np.argwhere(j_v0==j)[0][0]/(N_red-1)) else: color = 'grey' else: color = pl.cm.jet(j/(N-1)) else: color = 'C' + str(j) if 'v' in state.QuNrs: if state.is_lossstate: color = 'k' alpha = 1.0 else: alpha = alphas[state.v] label = str(state).split(f'{ElSt.gs_exs}=')[-1] plt.plot(self.t*1e6,(self.N[j+N_sum,:]+smallspacing*i)*1e2, label=label,ls=ls,color=color,alpha=alpha) N_sum += N plt.xlabel('time $t$ in $\mu$s') plt.ylabel('Populations $N$ in %') leg = plt.legend(title='States:',loc='center left',labelspacing=-0.0, bbox_to_anchor=(1, 0.5),fontsize='x-small') # set the linewidth of each legend object for legobj in leg.legend_handles: legobj.set_linewidth(1.4)
[docs] def plot_Nscatt(self,sum_over_ElSts=False): """plot the scattered photon number over time (integral of `Nscattrate`).""" plt.figure('Nscatt: {}, {}, {}'.format(self.description,self.levels.description,self.lasers.description)) plt.xlabel('time $t$ in $\mu$s') plt.ylabel('Totally scattered photons') Nscatt = self.get_Nscatt(sum_over_ElSts=sum_over_ElSts) for i,ElSt in enumerate(self.levels.exstates): plt.plot(self.t*1e6, Nscatt[i,:], '-',label=ElSt.label) if Nscatt.shape[0]>1: plt.plot(self.t*1e6, np.sum(Nscatt,axis=0), '-',label='Sum') plt.legend()
[docs] def plot_Nscattrate(self,sum_over_ElSts=False): """plot the photon scattering rate over time (derivative of `Nscatt`).""" plt.figure('Nscattrate: {}, {}, {}'.format(self.description,self.levels.description,self.lasers.description)) plt.xlabel('time $t$ in $\mu$s') plt.ylabel('Photon scattering rate $\gamma\prime$ in MHz') Nscattrate = self.get_Nscattrate(sum_over_ElSts=sum_over_ElSts) for i,ElSt in enumerate(self.levels.exstates): plt.plot(self.t*1e6, Nscattrate[i,:]*1e-6, '-',label=ElSt.label) if Nscattrate.shape[0]>1: plt.plot(self.t*1e6, np.sum(Nscattrate,axis=0)*1e-6, '-',label='Sum') plt.legend()
[docs] def plot_Nsum(self): """plot the population sum of all levels over time to ensure a small numerical deviation of the ODE solver.""" plt.figure('Nsum: {}, {}, {}'.format(self.description,self.levels.description,self.lasers.description)) plt.xlabel('time $t$ in $\mu$s') plt.ylabel('Population sum $\sum N_i$') plt.plot(self.t*1e6, np.sum(self.N,axis=0), '-')
[docs] def plot_dt(self): """plot the time steps at which the populations are calculated. If no `dt` argument is given for the calulations they are chosen from the ODE solver.""" if 'method' in self.args['kwargs']: method = self.args['kwargs']['method'] else: method = 'RK45' plt.figure('dt: {}, {}, {}'.format(self.description,self.levels.description,self.lasers.description)) plt.xlabel('time $t$ in $\mu$s') plt.ylabel('timestep d$t$ in s') plt.plot(self.t[:-1]*1e6,np.diff(self.t),label=method) plt.yscale('log') plt.legend()
[docs] def plot_v(self): """plot the velocity over time for all three axes 'x','y', and'z'.""" plt.figure('v: {}, {}, {}'.format(self.description,self.levels.description,self.lasers.description)) plt.xlabel('time $t$ in $\mu$s') plt.ylabel('velocity $v$ in m/s') ls_arr = ['-','--','-.'] for i,axis in enumerate(['x','y','z']): plt.plot(self.t*1e6,self.v[i,:],label='$v_{}$'.format(axis),ls=ls_arr[i]) plt.legend()
[docs] def plot_r(self): """plot the position over time for all three axes 'x','y', and'z'.""" plt.figure('r: {}, {}, {}'.format(self.description,self.levels.description,self.lasers.description)) plt.xlabel('time $t$ in $\mu$s') plt.ylabel('position $r$ in m') ls_arr = ['-','--','-.'] for i,axis in enumerate(['x','y','z']): plt.plot(self.t*1e6,self.r[i,:],label='$r_{}$'.format(axis),ls=ls_arr[i]) plt.legend()
[docs] def plot_FFT(self,only_sum=True,start_time=0.0): """plot the fast Fourier transform (FFT) of the time-dependent populations. Parameters ---------- only_sum : bool, optional if True the sum of the FFTs of all populations is plottet. Otherwise the distinct FFTs for all levels are shown. The default is True. start_time : float between 0 and 1, optional starting time in units of the interaction time `t_int` at which the FFT is calculated. The default is 0.0. """ FT_sum = 0 t_int = self.args['t_int'] for i,st in enumerate(self.levels): FT = (np.fft.rfft(self.N[i,int(self.t.size*start_time):]).real)**2 mean_zero = FT[int(FT.size/4):].mean() start = np.where(np.diff(FT)>0)[0][0] if start < 3: start = 3 FT[:start] = mean_zero if not only_sum: FT[np.where(FT<2*mean_zero)[0]] = mean_zero plt.plot(np.arange(FT.size)/(t_int*(1-start_time))*1e-6,FT*1.**i) else: FT_sum += FT if only_sum: self.FT_sum = FT_sum plt.plot(np.arange(FT.size)/(t_int*(1-start_time))*1e-6,self.FT_sum) plt.yscale('log') plt.xlabel('Frequency $f$ in MHz') plt.ylabel('Power spectrum of the FFT')
[docs] def calc_Rabi_freqs(self,position_dep=False): """Calculate the (angular) pure Rabi frequencies for each transition and each laser component (with 2*pi included). Parameters ---------- position_dep : bool, optional Whether the intensity for the calculation is evaluated at a certain position within the Gaussian laser beam profiles (True) or at the maximum (False). The default is False. Returns ------- np.ndarray((lNum,uNum,pNum)) Angular Rabi frequencies for each combination of laser, excited states, and ground state. """ # saturation parameter of intensity (lNum,uNum,pNum) self.sp = self.lasers.getarr('I')[None,None,:]/(self.levels.Isat[:,:,None]) self.Rabi_freqs = self.levels.calc_Gamma()[None,:,None]*np.sqrt(self.sp/2) \ * np.dot(self.levels.calc_dMat(), self.lasers.getarr('f_q').T) # position dependent intensity due to Gaussian shape of Laserbeam: if position_dep: self.Rabi_freqs *= np.sqrt(self.lasers.I_tot(self.r0,sum_lasers=False,use_jit=False))[None,:] # for simple levelsystems, the Rabi frequency can be set via the laser instances Rabi_set = self.lasers.getarr('freq_Rabi') #this feature must be tested!? if not np.all(np.isnan(Rabi_set)): Rabi_freqs_max = np.abs(self.Rabi_freqs).max(axis=(0,1)) #shape (pNum) for p,la in enumerate(self.lasers): if not np.isnan(Rabi_set[p]): ratio = Rabi_set[p]/Rabi_freqs_max[p] self.Rabi_freqs[:,:,p] *= ratio la.I *= ratio**2 return self.Rabi_freqs
[docs] def plot_F(self,figname=None,axes=['x','y','z']): """plot the Force over time for all three axes 'x','y', and'z'.""" if figname == None: plt.figure('F ({}): {}, {}, {}'.format( self.calcmethod,self.description,self.levels.description, self.lasers.description)) else: plt.figure(figname) ls_arr = ['-','--','-.'] for axis in axes: i = {'x':0,'y':1,'z':2}[axis] plt.plot(self.t * 1e6, self.F[i,:] / self.hbarkG2, label='$F_{}$'.format(axis),ls=ls_arr[i]) plt.xlabel('time $t$ in $\mu$s') plt.ylabel('Force $F$ in $\hbar k \Gamma_{}/2$'.format(self.levels.exstates_labels[0])) plt.legend()
[docs] def plot_spectrum(self, wavelengths=[], lasers=True, transitions=True, unit = 'MHz', exs = [], relative_to_wavelengths = False, axs = [], subplot_sep = 200, laser_spectrum_kwargs = dict(), transitions_kwargs = dict(), ): """Plot the spectrum of :class:`~.Lasersystem.Laser` objects and transition spectra with their respective intensities. This method cleverly combines :meth:`~.Lasersystem.plot_spectrum` and :meth:`.Levelsystem.plot_transition_spectrum` methods. Parameters ---------- wavelengths : list, optional wavelengths that should be plotted within the range ``subplot_sep``. By default all available laser wavelengths are used. lasers : bool, optional Whether to include the laser spectrum. The default is True. transitions : bool, optional Whether to include the transition spectrum. The default is True. unit : str, optional Unit of the x-axis to be plotted. Can be one of ``['GHz','MHz','kHz','Hz','Gamma']``. Default is 'MHz'. exs : list(str), optional See ``exs`` in :meth:`.Levelsystem.plot_transition_spectrum`. relative_to_wavelengths : bool, optional Whether the x-axis should be plotted in absolute frequency units or relative to ``wavelengths``. axs : list of ``matplotlib.pyplot.axis`` objects, optional axis objects to put the plot(s) on. The default is []. subplot_sep : float, optional Defines the range of the plotted x-axis and the separation for the automatic inclusion of all wavlengths (see parameter ``wavelengths``) in units of ``ElectronicState.Gamma``. Default is 200. laser_spectrum_kwargs : kwargs, optional Additional keyword arguments (see :meth:`.Lasersystem.plot_spectrum`). The default is dict(). transitions_kwargs : kwargs, optional Additional keyword arguments (see :meth:`.Levelsystem.plot_transition_spectrum`). The default is dict(). Returns ------- axs : list of ``matplotlib.pyplot.axis``. Axes of the subplot(s). """ if (not lasers) and (not transitions): raise Exception("Either one of <lasers> or <transitions> must be True!") # exctract Gamma if not 'exs' in transitions_kwargs: transitions_kwargs['exs'] = [self.levels.exstates_labels[0]] ExSt = transitions_kwargs['exs'][0] Gamma = self.levels[ExSt].Gamma*1e6 if lasers and self.lasers.entries: subplot_sep_las = subplot_sep*Gamma if not wavelengths: wavelengths = self.lasers._get_wavelength_regimes(subplot_sep_las) std = laser_spectrum_kwargs.pop('std', Gamma/2) axs_lasers = self.lasers.plot_spectrum( axs = axs, wavelengths = wavelengths, unit = unit, relative_to_wavelengths = relative_to_wavelengths, subplot_sep = subplot_sep_las, std = std, **laser_spectrum_kwargs, ) if transitions: axs = [ax.twinx() for ax in axs_lasers] for ax in axs_lasers: ax.tick_params(axis='y', labelcolor='grey') ax.yaxis.label.set_color('grey') else: axs = axs_lasers if transitions and self.levels.exstates and self.levels.grstates and self.levels.states: kwargs_sum = transitions_kwargs.pop( 'kwargs_sum', dict(color='k',alpha=0.7,ls='--')) self.levels.plot_transition_spectrum( ax = axs, wavelengths = wavelengths, E_unit = unit, relative_to_wavelengths = relative_to_wavelengths, subplot_sep = subplot_sep, kwargs_sum = kwargs_sum, **transitions_kwargs, ) return axs
@property def hbarkG2(self): """Calculate :math:`\hbar k \Gamma /2` using the wave vector defined in the laser system and natural lifetime defined in the first excited electronic state.""" lambs = self.lasers.getarr('lamb') dev = 1e-3 diff = lambs.std()/lambs.mean() Gamma = self.levels.exstates[0].Gamma*2*pi*1e6 if len(self.levels.exstates) > 1: warnings.warn("For calculation of hbar * k * Gamma/2, Gamma of the first ElSt is used") if diff > dev: warnings.warn( ("For calculation of hbar * k * Gamma/2, the wavelengths " f"of the lasers' wavelengths differ by {diff*1e2:.2f} % " f"(which is more than {dev*1e2:.2f} %). The " "returned unit might thus me inappropriate.") ) return hbar*2*pi/lambs.mean()*Gamma/2 @property def F(self): """calculate the force over time. Returns ------- F : np.ndarray, shape(3,ntimes) Force array for all <ntimes> time points and three axes 'x','y','z'. """ if self.calcmethod == 'rateeqs': if not self.args['trajectory']: lNum,uNum = self.levels.lNum, self.levels.uNum N_lu = self.N[:lNum,:][:,None,:] - self.N[lNum:lNum+uNum,:][None,:,:] F = hbar * np.sum( np.dot(self.R1,self.k)[:,:,:,None] * N_lu[:,:,None,:], axis=(0,1)) #+ g else: F = np.zeros((3,self.t.size)) F[:,1:] = np.diff(self.v)/np.diff(self.t)*self.levels.mass if self.calcmethod == 'OBEs': T = self.freq_unit*self.t T_size = T.size size = self.levels.lNum*self.levels.uNum*self.lasers.pNum*3*T_size #total size of the force array below T_step = T_size//(size//int(10e6) + 1) F = np.zeros((3,T_size)) for i,t1 in enumerate(range(0,T_size,T_step)): #ensure that not too much memory is needed for huge np arrays t2 = t1 + T_step if t2 > T_size: t2 = T_size F[:,t1:t2] += np.real(np.transpose(self.ymat[self.levels.lNum:,:self.levels.lNum,t1:t2],axes=(1,0,2))[:,:,None,None,:] \ *self.Gfd[:,:,:,None,None]*np.exp(1j*T[None,None,None,None,t1:t2]*self.om_gek[:,:,:,None,None]) \ *self.k[None,None,:,:,None] ).sum(axis=(0,1,2)) F *= 2*hbar*self.freq_unit return F #%%
[docs] def calc_trajectory(self,F_profile,t_int=20e-6,t_start=0.,dt=None,t_eval=None, verbose=True,force_axis=None, interpol_kind='linear',save_scipy_sols=False,**kwargs): """Calculate Monte Carlo simulations of classical particles which are propagated through a provided pre-calculated force profile to be used as interpolated function""" if 'method' not in kwargs: kwargs['method'] = 'LSODA' # setting default solver method for ODE # Checking F_profile argument for datatype, etc.: if not isinstance(F_profile, dict): if np.all([isinstance(dic,dict) for dic in F_profile]): F_profile = {k:v for dic in F_profile for k,v in dic.items()} else: raise ValueError(f"F_profile must be dictionary or iterable of dictionaries!") if ('a' not in F_profile) and ('F' in F_profile): F_profile['a'] = F_profile['F']/self.levels.mass print(f'Converting force to acceleration with mass {self.levels.mass}') else: raise ValueError("Either 'F' or 'a' must be included in F_profile!") # attribute for storing the solutions of the trajectory simulations: self.trajectory_results = dict(kwargs=locals(), sols=[]) v0_arr = np.atleast_2d(self.v0) r0_arr = np.atleast_2d(self.r0) if isinstance(t_int, float): t_int = np.ones(v0_arr.shape[0])*t_int self.trajectory_results['final_values'] = dict(photons = np.zeros(len(v0_arr)), v = np.zeros((len(v0_arr),3)), r = np.zeros((len(v0_arr),3))) if 'v' in F_profile or 'v0' in F_profile: v = F_profile['v'] if 'v' in F_profile else F_profile['v0'] else: raise ValueError("Either 'v' or 'v0' must be included in F_profile!") if 'I' in F_profile: position_dep = True I = F_profile['I'] I_tot = self.lasers.get_intensity_func() from scipy.interpolate import RegularGridInterpolator as interp a_intp = interp((v,I), F_profile['a'], method=interpol_kind, bounds_error=False,fill_value=None) R_intp = interp((v,I), F_profile['Nscattrate'], method=interpol_kind, bounds_error=False,fill_value=None) def a(v,r): return a_intp(xi=(v,I_tot(r))) def R(v,r): return R_intp(xi=(v,I_tot(r))) if force_axis == '-v': v0_arr2 = v0_arr.copy() v0_arr2[:,0] *= 0 force_axis = -v0_arr2/np.linalg.norm(v0_arr2,axis=-1)[:,None] elif isinstance(force_axis,(list,np.ndarray)): force_axis = np.atleast_2d(np.array(force_axis)/np.linalg.norm(force_axis)) +v0_arr*0 else: raise Exception('input argument <force axis> has to be given!') else: position_dep = False from scipy.interpolate import interp1d a = interp1d(v, F_profile['a'], kind=interpol_kind) R = interp1d(v, F_profile['Nscattrate'], kind=interpol_kind) force_axis = np.array(force_axis)/np.linalg.norm(force_axis) # Differential equation calculation: #__________________________________ def ode_MC1D(t,y,force_axis,position_dep): dy = np.zeros(6+1) v_proj = np.sum(y[:3]*force_axis) if position_dep: dy[:3] = a(v_proj,y[3:6])*force_axis dy[-1] = R(v_proj,y[3:6]) else: dy[:3] = a(v_proj)*force_axis dy[-1] = R(v_proj) dy[3:6] = y[:3] return dy #__________________________________ iterator = tqdm(v0_arr,smoothing=0.0) if verbose else v0_arr for i,v0 in enumerate(iterator): sol = solve_ivp(ode_MC1D, (0.,t_int[i]), np.array([*v0, *r0_arr[i], 0.0]), t_eval=t_eval, args=(force_axis[i],position_dep), **kwargs) if save_scipy_sols: self.trajectory_results['sols'].append(sol) for key, indices in dict(photons=-1, r=[3,4,5], v=[0,1,2]).items(): self.trajectory_results['final_values'][key][i] = sol.y[indices,-1]
#%%
[docs] def calc_OBEs(self, t_int=20e-6, t_start=0., dt=None, t_eval = [], magn_remixing=False, freq_clip_TH=500, steadystate=False, position_dep=False, rounded=False, verbose=True, return_fun=return_fun_default, **kwargs): """Calculate the time evolution of the single level populations with the optical Bloch equations. Parameters ---------- t_int : float, optional interaction time in which the molecule is exposed to the lasers. The default is 20e-6. t_start : float, optional starting time when the ode_solver starts the calculation. Useful for the situation when e.g. all cooling lasers are shut off at a specific time t1, so that a new calculation with another laser configuration (e.g. including only a probe laser) can be started at t_start=t1 to continue the simulation. The default is 0.0. dt : float, optional time step of the output data. So in this case the ODE solver will decide at which time points to calculate the solution. If dt='auto', an appropriate time step is chosen by using the smallest Rabi frequency between a single transition and single laser component. The default is None. t_eval : list or numpy array, optional If it desired to get the solution of the ode solver only at specific time points, the `t_eval` argument can be used to specify these points. If `_eval` is given, the `dt` argument is ignored. The default is []. magn_remixing : bool, optional if True, the magnetic field, which is defined in the instance :class:`~System.Bfield` contained in this class, is considered in the calculation. Otherwise, the magnetic field is set to zero. The default is False. freq_clip_TH : float or string, optional determines the threshold frequency at which the coupling of a single transition detuned by a frequency from a specific laser component is neglected. If a float is provided, only the transitions with detunings smaller than `freq_clip_TH` times Gamma[0] are driven by the light field. If `freq_clip_TH` == 'auto', the threshold frequencies for all transitions are chosen seperately by considering the transition strengths and intensities of each laser component. The default is 500. steadystate : bool, optional determines whether the equations are propagated until a steady state or periodic quasi-steady state is reached. The dictionary ``steadystate`` of this class specifies the steady state conditions. The default is False. position_dep : bool, optional determines whether to take position of the particle in an Gaussian intensity distribution of the laser beams into account. The default is False. rounded : float, optional if specified, all frequencies and velocities are rounded to the frequency `rounded` in units of max(Gamma). The default is False. verbose : bool, optional whether to print additional information like execution time or the scattered photon number. The default is True. return_fun : function-type, optional if `mp` == True, the returned dictionary of this function determines the quantities which are save for every single parameter configuration. The default is None. **kwargs : keyword arguments, optional other options of the `solve_ivp` scipy function can be specified (see homepage of scipy for further information). Note ---- function creates attributes * ``N`` : solution of the time dependent populations N, * ``Nscatt`` : time dependent scattering number, * ``Nscattrate`` : time dependent scattering rate, * ``photons``: totally scattered photons * ``args``: input arguments of the call of this function * ``t`` : times at which the solution was calculated """ self.calcmethod = 'OBEs' #___input arguments of this called function self.args = locals() self.check_config(raise_Error=True) #___parameters belonging to the levels self.levels.calc_all() # for dimensionless time units freq_unit = self.levels.calc_Gamma().max() # choose the linewidth of the first electronic state as unit self.freq_unit = freq_unit #frequency differences between the ground and excited states (delta) self.om_eg = self.levels.calc_freq()/freq_unit if rounded: self.om_eg = np.around(self.om_eg/rounded)*rounded #___start multiprocessing if desired if self._identify_iter_params() or self.lasers._identify_iter_params()[0]: return self._start_mp() #___parameters belonging to the lasers (and partially to the levels) # Rabi frequency in dimensionless units (lNum,uNum,pNum) self.calc_Rabi_freqs(position_dep=position_dep) # wave vectors k (no unit vectors) self.k = self.lasers.getarr('k')*self.lasers.getarr('kabs')[:,None] # laser frequencies omega_k if rounded: self.om_k = np.around(self.lasers.getarr('omega')/freq_unit/rounded)*rounded \ - np.around(np.dot(self.k,self.v0)/freq_unit/rounded)*rounded else: self.om_k = (self.lasers.getarr('omega') - np.dot(self.k,self.v0))/freq_unit #___magnetic remixing of the ground states and excited states if magn_remixing: betaB = self.Bfield.Bvec_sphbasis/(hbar*freq_unit/self.Bfield.mu_B) else: betaB = np.array([0.,0.,0.]) #coefficients h to neglect highly-oscillating terms of the OBEs (with frequency threshold freq_clip_TH) self.om_gek = self.om_eg[:,:,None] - self.om_k[None,None,:] if freq_clip_TH == 'auto': FWHM = np.sqrt(self.levels.calc_Gamma()[None,:,None]**2 + 2*np.abs(self.Rabi_freqs)**2)/freq_unit #in dimensionless units self.h_gek = np.where(np.abs(self.om_gek) < 8*FWHM/2, 1.0, 0.0) self.h_gege = np.where(np.abs(self.om_eg[:,:,None,None]-self.om_eg[None,None,:,:])\ < 8*np.max(FWHM)/2, 1.0, 0.0) else: self.h_gek = np.where(np.abs(self.om_gek) < freq_clip_TH, 1.0, 0.0) self.h_gege = np.where(np.abs(self.om_eg[:,:,None,None]-self.om_eg[None,None,:,:])\ < freq_clip_TH, 1.0, 0.0) #___coefficients for new defined differential equations self.Gfd = 1j/2*np.exp(1j*self.lasers.getarr('phi')[None,None,:])*self.h_gek*self.Rabi_freqs/freq_unit self.betamu = tuple(1j* np.dot(self.levels.calc_muMat()[i], np.flip(betaB*np.array([-1,1,-1]))) for i in range(2) ) self.dd = self.h_gege*(self.levels.calc_dMat()[:,:,None,None,:]\ *self.levels.calc_dMat()[None,None,:,:,:]).sum(axis=-1) self.ck_indices = (tuple(np.where(self.Gfd[i,:,:] != 0.0) for i in range(self.levels.lNum)), tuple(np.where(self.Gfd[:,i,:] != 0.0) for i in range(self.levels.uNum))) self.ck_indices = (tuple( np.array([i[0],i[1]]) for i in self.ck_indices[0] ), tuple( np.array([i[0],i[1]]) for i in self.ck_indices[1] )) #___specify the initial (normalized) occupations of the levels # and transform these values into the density matrix elements N0mat N0mat = self.initialize_N0(return_densitymatrix=True) if verbose: print('Solving ode with OBEs...', end='') start_time = time.perf_counter() #___if steady state is wanted, multiple calculation steps of the OBEs #___have to be performed while the occupations between this steps are compared if not steadystate: self._evaluate(t_start, t_int, dt, N0mat) self.step = 0 else: #___initial propagation of the equations for reaching the equilibrium region if self.steadystate['t_ini']: self.args['t_eval'] = [t_start, self.steadystate['t_ini']] #only the start and end point are important to be calculated for initial period self._evaluate(t_start, self.steadystate['t_ini'], dt, N0mat) self.args['t_eval'] = [] t_start = self.t[-1] N0mat = self.ymat[:,:,-1] #___specifying interaction time for the next multiple iterations to compare # if callable(self.steadystate['period']): if isinstance(self.steadystate['period'],float): t_int = self.steadystate['period'] elif self.args['rounded']: t_int = 2*pi/(freq_unit*self.args['rounded']) elif self.steadystate['period'] == 'standingwave': if np.linalg.norm(self.v0) != 0: #if v0==0, then t_int is not changed and thus used for int time. lambda_mean = (c/(self.om_eg*freq_unit/2/pi)).mean() if np.any(np.abs(c/(self.om_eg*freq_unit/2/pi) /lambda_mean -1)>0.1e-2 ):#percental deviation from mean print('WARNING: averaging over standing wave periods might not be accurate since the wavelengths differ.') period = lambda_mean/np.linalg.norm(self.v0)#/2 t_int = period*(t_int//period+1) # int(t_int - t_int % period) self._evaluate(t_start, t_int, dt, N0mat) t_start = self.t[-1] N0mat = self.ymat[:,:,-1] m1 = self.N.mean(axis=1) # if self.steadystate['period'] == None: t_int *= 0.1 con1, con2 = self.steadystate['condition'] for self.step in range(1,self.steadystate['maxiters']): self._evaluate(t_start, t_int, dt, N0mat) m2 = self.N.mean(axis=1) # print('diff & prop',np.all(np.abs(m1-m2)*1e2<con1),np.all(np.abs(1-m1/m2)*1e2 <con2)) #___check if conditions for steady state are fulfilled if np.all(np.abs(m1-m2)*1e2 < con1) and np.all(np.nan_to_num(np.abs(1-m1/m2)*1e2,posinf=0,neginf=0) < con2): break else: m1 = m2 N0mat = self.ymat[:,:,-1] t_start = self.t[-1] if verbose: print(' calculation steps: ',self.step+1) #: execution time for the ODE solving self.exectime = time.perf_counter() - start_time self._verify_calculation() if return_fun == True: return {'system':self} elif return_fun: return return_fun(self)
def _verify_calculation(self): dev_TH = {'rateeqs':1e-8,'OBEs':1e-6}[self.calcmethod] dev = abs(self.N[:,-1].sum() -1) #: Variable success indicates of the calcualtion could be verified. self.success = True self.message = "" if dev > dev_TH: message = 'Sum of populations not stable! Deviation: {:.2e}.\n'.format(dev) print('WARNING:', message) self.message += message self.success = False if np.any(self.N < -1e-3): message = 'Populations got negative!' print('WARNING:', message) self.message += message self.success = False # printing some information... if self.args['verbose']: print("Execution time: {:2.4f} seconds".format(self.exectime)) for i,Ex_label in enumerate(self.levels.exstates_labels): print("Scattered Photons ({}): {:.6f}".format(Ex_label,self.photons[i])) def _start_mp(self): self.results = tools.multiproc(obj=deepcopy(self),kwargs=self.args) if self.multiprocessing['savetofile']:# multiprocessing.cpu_count() > 16: save_object(self) try: sys.path.append('../') import subprocess, sending_email hostname = subprocess.check_output('hostname').decode("utf-8") sending_email.send_message('Calculation complete!','File {} at Server {}'.format(self.description,hostname)) except: pass return None def _identify_iter_params(self): """Identify which parameters are iterative arrays to loop through including magnetic field strength and direction, initial position and velocity. This function is inevitable to determine whether multiple evaluations of the OBEs and rate equations are efficiently conducted using the multiprocessing package from python (see :meth:`tools.multiproc` and :meth:`Lasersystem._identify_iter_params`). Returns ------- iters_dict : dict Dictionary with all iterative parameters and their number of iterations. """ iters_dict = {} for obj, ndim, label in [ (self.Bfield.strength, 0, 'strength'), (self.Bfield.direction, 1, 'direction'), (self.r0, 1, 'r0'), (self.v0, 1, 'v0'), ]: if np.array(obj).ndim != ndim: iters_dict[label] = len(obj) return iters_dict
[docs] def reset_N0(self): """Reset (last created) initial population self.N0 and initial populations for all electronic states""" self.N0 = np.array([]) #: initial population of all levels for ElSt in self.levels.electronic_states: ElSt.N0 = []
[docs] def initialize_N0(self,return_densitymatrix=False,random=False): """ Initialize initial populations as a starting point for the OBEs or rate equations using pre-defined population attribute ``N0`` of the electronic states :class:`~MoleCool.Levelsystem.ElectronicState`. Parameters ---------- return_densitymatrix : bool, optional Whether the populations should be transformed into density matrix or one-dimensional population array. The default is False. random : bool, optional Whether the popultions are sampled from a random distirbution. The default is False. Returns ------- N0mat : numpy.ndarray density matrix with populations on the diagonal. """ #___specify the initial (normalized) occupations of the levels N,iNum = self.levels.N, self.levels.iNum if random: self.N0 = np.random.rand(N) else: if np.any([len(ElSt.N0) for ElSt in self.levels.electronic_states]): self.N0 = np.zeros(N) for ElSt in self.levels.electronic_states: if len(ElSt.N0) != 0: i_ElSt = self.levels.index_ElSt(ElSt,include_Ngrs_for_exs=True) self.N0[i_ElSt:(i_ElSt+ElSt.N)] = ElSt.N0 else: self.N0 = np.array(self.N0, dtype=float) if len(self.N0) == 0: if 'v' in self.levels.grstates[0][0].QuNrs: N0_indices = [i for i,st in enumerate(self.levels.grstates[0]) if st.v==0] if len(N0_indices) == 0: self.N0 = np.ones(N) else: self.N0 = np.zeros(N) for i in N0_indices: self.N0[i] = 1.0 else: self.N0 = np.ones(N) else: if len(self.N0) != N: if len(self.N0) == N+iNum: self.N0 = self.N0[:N] else: raise ValueError('Wrong size of N0') self.N0 /= self.N0.sum() #initial populations are always normalized if iNum > 0: self.N0 = np.array([*self.N0,*self.N0[self.levels.lNum-iNum:self.levels.lNum]]) if return_densitymatrix: N = N +iNum N0mat = np.zeros((N,N),dtype=np.complex64) if random: print('MUST be modified that the density matrix elements that are twice apparent are equal') N0mat = (np.random.rand(N,N) + 1j*np.random.rand(N,N))/2 N0mat += np.conj(N0mat).T #transform these initial values into the density matrix elements N0mat N0mat[(np.arange(N),np.arange(N))] = self.N0 return N0mat
[docs] def get_N(self, return_sum=True, **QuNrs): """ Retrieve the time-dependent populations as results after calculating the dynamics. Can be used to either obtain the populations of all individual levels or to conveniently combine the populations for only a subset of levels with specific Quantum numbers. Parameters ---------- return_sum : bool, optional Whether to sum up of all levels. The default is True. **QuNrs : kwargs Keyword arguments as Quantum numbers can be provided for only a subset of levels with specific Quantum numbers, e.g. v=0. If empty, all levels are considered. If only a specific ground or excited electronic state should be included, add e.g. `gs='X'` or `exs='A'` as a first QuNr keyword. Returns ------- np.ndarrays Time-dependent population(s). """ if not QuNrs: return self.N states = self.levels.states inds = np.array([i for i,st in enumerate(states) if st.check_QuNrvals(**QuNrs)]) if return_sum: return self.N[inds,:].sum(axis=0) else: return self.N[inds,:]
[docs] def get_Nscattrate(self,sum_over_ElSts=False): """Calculate time dependent scattering rate. Parameters ---------- sum_over_ElSts : bool, optional Whether to sum over multiple electronic excited states. The default is False. Returns ------- numpy.ndarray Time dependent scattering rate. """ Nscattrate_arr = self.levels.calc_Gamma()[:,None]*self.N[self.levels.lNum:,:] if not sum_over_ElSts:# and (len(self.levels.exstates_labels) > 1) Nscattrate_summed = np.zeros((len(self.levels.exstates_labels),self.N.shape[1])) N_states = 0 for i,ElSt in enumerate(self.levels.exstates): N = ElSt.N Nscattrate_summed[i,:] = np.sum(Nscattrate_arr[N_states:N_states+N,:],axis=0) N_states += N return Nscattrate_summed else: return np.sum(Nscattrate_arr,axis=0)
[docs] def get_Nscatt(self,sum_over_ElSts=False): """Calculate time dependent scatterd photon number. Parameters ---------- sum_over_ElSts : bool, optional Whether to sum over multiple electronic excited states. The default is False. Returns ------- numpy.ndarray Time dependent scattered photon number. """ return cumulative_trapezoid(self.get_Nscattrate(sum_over_ElSts=sum_over_ElSts), self.t, initial = 0.0, axis=-1)
[docs] def get_photons(self,sum_over_ElSts=False): """Calculate totally scattered photon number. Parameters ---------- sum_over_ElSts : bool, optional Whether to sum over multiple electronic excited states. The default is False. Returns ------- numpy.ndarray Totally scattered photon number. """ return np.transpose(self.get_Nscatt(sum_over_ElSts=sum_over_ElSts))[-1]
#___compute several physical variables using the solution of the ODE #: time dependent scattering rate Nscattrate = property(get_Nscattrate) #: time dependent scattering number Nscatt = property(get_Nscatt) #: totally scattered photons photons = property(get_photons) def _evaluate(self,t_start,t_int,dt,N0mat): #for OBEs only?! freq_unit = self.freq_unit #___determine the time points at which the ODE solver should evaluate the equations if len(self.args['t_eval']) != 0: self.t_eval = np.array(self.args['t_eval']) else: if dt == 'auto': #must be tested!?! dt = 1/np.max(self.h_gek*np.sqrt(self.om_gek**2*freq_unit**2+np.abs(self.Rabi_freqs)**2)/2/pi)/8.11 #1/8 of one Rabi-oscillation if dt != None and dt < t_int: self.t_eval = np.linspace(t_start,t_start+t_int,int(t_int/dt)+1) else: self.t_eval, T_eval = None, None if np.all(self.t_eval) != None: T_eval = self.t_eval * freq_unit #___transform the initial density matrix N0mat in a vector for the ode self.y0 = self._density_mat2vec(N0mat) # ---------------Ordinary Differential Equation solver---------------- # solve initial value problem of the ordinary first order differential equation with scipy lNum,uNum,iNum,pNum = self.levels.lNum,self.levels.uNum,self.levels.iNum,self.lasers.pNum kwargs = self.args['kwargs'] # sol = solve_ivp(ode0_OBEs, (t_start*freq_unit,(t_start+t_int)*freq_unit), # self.y0, t_eval=T_eval, **kwargs, # args=(lNum,uNum,pNum,self.G,self.f,self.om_eg,self.om_k, # betaB,self.dMat,self.muMat, # self.M_indices,h_gek,h_gege,self.phi)) # delete? # sol = solve_ivp(ode1_OBEs, (t_start*freq_unit,(t_start+t_int)*freq_unit), # self.y0, t_eval=T_eval, **kwargs, # args=(lNum,uNum,pNum, self.M_indices, # self.Gfd,self.om_gek,self.betamu,self.dd)) # sol = solve_ivp(ode1_OBEs_opt2, (t_start*freq_unit,(t_start+t_int)*freq_unit), #<- optimized form for one el. ex. state! # self.y0, t_eval=T_eval, **kwargs, # args=(lNum,uNum,pNum, self.M_indices, # self.Gfd,self.om_gek,self.betamu,self.dd,self.ck_indices)) if iNum == 0: sol = solve_ivp(ODEs.ode1_OBEs_opt3, (t_start*freq_unit,(t_start+t_int)*freq_unit), #<- can also handle two electr. states with diff. Gamma self.y0, t_eval=T_eval, **kwargs, args=(lNum,uNum,pNum, self.levels.calc_M_indices(), self.Gfd,self.om_gek,self.betamu,self.dd,self.ck_indices, self.levels.calc_Gamma()/freq_unit)) else: sol = solve_ivp(ODEs.ode1_OBEs_opt4, (t_start*freq_unit,(t_start+t_int)*freq_unit), #<- can also handle two electr. states with diff. Gamma self.y0, t_eval=T_eval, **kwargs, args=(lNum,uNum,iNum,pNum, self.levels.calc_M_indices(), self.Gfd,self.om_gek,self.betamu,self.dd,self.ck_indices, self.levels.calc_Gamma()/freq_unit)) self.sol = sol self.ymat = self._density_vec2mat(sol.y) #: solution of the time dependent populations N self.N = np.real(self.ymat[(np.arange(self.levels.N),np.arange(self.levels.N))]) #: array of the times at which the solutions are calculated self.t = sol.t/freq_unit def _density_mat2vec(self,mat): """Transform the time dependent density matrix from matrix form to solution vector of the ODEs. Inverse operation to _density_vec2mat. """ if mat.ndim != 2: raise Exception('Matrix must be 2-dimensional') if mat.shape[0] != mat.shape[1]: raise Exception('Matrix must be square matrix') N = mat.shape[0] vec = np.zeros( N*(N+1) ) count = 0 for i in range(N): for j in range(i,N): vec[count] = mat[i,j].real vec[count+1] = mat[i,j].imag count += 2 return vec def _density_vec2mat(self,vec): """Transform the solution vector of the time dependent density matrix into matrix form. Inverse operation to _density_mat2vec. """ N,iNum = self.levels.N, self.levels.iNum if vec.shape[0] != (N+iNum)*(N+iNum+1): raise Exception('Shape[0] of vector must have the length N+iNum') mat = np.zeros((N+iNum,N+iNum,vec.shape[-1]),dtype=np.complex64) count = 0 for i in range(N+iNum): for j in range(i,N+iNum): mat[i,j,:] = vec[count] + 1j* vec[count+1] count += 2 mat = mat[:N,:N] if np.any(np.abs(mat[(np.arange(N),np.arange(N))].imag) > 1e-13): warnings.warn('Populations got an imaginary part > 1e-13') mat += np.conj(np.transpose(mat,axes=(1,0,2))) #is diagonal remaining purely real or complex? mat[(np.arange(N),np.arange(N))] *=0.5 return mat
[docs] def check_config(self,raise_Error=False): """Check System configuration for simulating internal dynamics.""" if self.calcmethod == 'rateeqs': #pre-defined kwargs for solve_ivp function kwargs_default = {'method':'LSODA', 'max_step':10e-6} self.args['kwargs'] = dict(kwargs_default,**self.args['kwargs']) self.lasers.check_config(raise_Error=raise_Error) self.levels.check_config(raise_Error=raise_Error) if 'trajectory' in self.args: if (self.args['trajectory']) and (self.levels.mass==0.0): raise ValueError('No mass is provided for trajectory to be calculated')
# check if some lasers are completely off to some states or if some states are not addressed by any laser!? def __set_v0r0(self, vec=[0,0,0], direction=[], label='v0'): vec = np.array(vec, dtype=np.float64) if isinstance(direction, str): direction = {'x':[1,0,0],'y':[0,1,0],'z':[0,0,1],'':[]}[direction] if np.any(direction): direction = np.array(direction)/np.linalg.norm(direction) if direction.shape != (3,): raise Exception(f"direction vector must be of shape (3,) instead of {direction.shape}") vec = direction[None,:]*vec[:,None] if vec.ndim not in [1,2]: raise Exception(f"{label} must have one or two dimensions instead of {vec.ndim}") if vec.shape[-1] != 3: raise Exception(f"Last dimension of {label} must be of length 3 instead of {vec.shape[-1]}") return vec
[docs] def set_v0(self, v0=[0,0,0], direction=[]): """Set initial velocity of the particle(s). Parameters ---------- v0 : list or np.ndarray, optional Velocity array. Can be either a one-dimensional array with three entries for the x, y and z component, a two dimensional array where the first dimension corresponds to different particles' velocities, or a one dimensional array with absolute velocity values while the argument ``direction`` defines the direction. The default is [0,0,0]. direction : str, list or np.ndarray, optional Direction of the absolute velocity values defined as the argument ``v0``. Can be either a str (x,y,z) or list / array of length 3. The default is []. Returns ------- np.ndarray velocity array (1 or 2-dimensional with the shape (number of particles, 3) in cartesian coordinates. """ self.__v0 = self.__set_v0r0(vec=v0, direction=direction, label='v0') return self.__v0
[docs] def set_r0(self, r0=[0,0,0], direction=[]): """Set initial position of the particle(s). Parameters ---------- r0 : list or numpy.ndarray, optional Position array. Can be either a one-dimensional array with three entries for the x, y and z component, a two dimensional array where the first dimension corresponds to different particles' positions, or a one dimensional array with absolute position values while the argument ``direction`` defines the direction. The default is [0,0,0]. direction : str, list or numpy.ndarray, optional Direction of the absolute position values defined as the argument ``r0``. Can be either a str (x,y,z) or list / array of length 3. The default is []. Returns ------- numpy.ndarray position array (1 or 2-dimensional with the shape (number of particles, 3) in cartesian coordinates. """ self.__r0 = self.__set_v0r0(vec=r0, direction=direction, label='r0') return self.__r0
@property def v0(self): """Initial velocity vector or array of vectors.""" if 'v0' in self.__dict__: val = self.__dict__['v0'] else: val = self.__v0 return val @v0.setter def v0(self,val): self.set_v0(v0=val) @property def r0(self): """Initial position vector or array of vectors.""" if 'r0' in self.__dict__: val = self.__dict__['r0'] else: val = self.__r0 return val @r0.setter def r0(self,val): self.set_r0(r0=val)
[docs] def draw_levels(self,GrSts=None,ExSts=None,branratios=True,lasers=True, QuNrs_sep=[], br_fun='identity', br_TH=0.01, # 1e-16 default freq_clip_TH='auto', cmap='viridis',yaxis_unit='MHz'): """Draw all levels of certain Electronic states sorted by certain Qunatum numbers. Additionally, the branching ratios and the transitions addressed by the lasers can be added. Parameters ---------- GrSts : list of str, optional list of labels of ground electronic states to be displayed. The default is None which corresponds to all ground states. ExSts : list of str, optional list of labels of excited electronic states to be displayed. The default is None which corresponds to all excited states. branratios : bool, optional Whether to show the branching ratios. The default is True. lasers : bool, optional Whether to show the transitions addressed by the lasers. By default it is set to True when lasers are defined. QuNrs_sep : list of str or tuple of two lists of str, optional Quantum numbers for separating all levels into subplots. For example the levels can be grouped into subplots by the vibrational Quantum number, i.e. ['v'] or (['v'],['v']) for ex. and gr. states. br_fun : str or callable, optional Function to be applied onto the branching ratios. Can be either 'identity', 'log10', 'sqrt', or a custom defined function. The default is 'identity'. br_TH : float, optional Threshold for the branching ratios to be shown. The default is 0.01. freq_clip_TH : TYPE, optional Same argument as in OBE's calculation method ':func:`calc_OBEs`. The default is 'auto'. cmap : str, optional Colormap to be applied to the branching ratios. The default is 'viridis'. yaxis_unit : str or float, optional Unit of the y-axis. Can be either 'MHz','1/cm', or 'Gamma' for the natural linewidth. Alternatively, an arbitrary unit (in MHz) can be given as float. Default is 'MHz'. """ # from mpl.patches import ConnectionPatch def draw_line(subfig,axes,xys,arrowstyle='->',dxyB=[0.,0.],**kwargs): con = mpl.patches.ConnectionPatch( xyA=xys[0], coordsA=axes[0].transData, xyB=xys[1]+dxyB, coordsB=axes[1].transData, arrowstyle=arrowstyle, shrinkB=1, **kwargs ) subfig.add_artist(con) axes[1].plot(*(xys[1]+dxyB)) #invisible line for automatically adjusting axis limits levels = self.levels if GrSts == None: GrSts = levels.grstates else: GrSts = [levels.__dict__[label] for label in GrSts] if ExSts == None: ExSts = levels.exstates else: ExSts = [levels.__dict__[label] for label in ExSts] # create big figure, and nested subfigures and subplot axes fig = plt.figure(constrained_layout=True) # subfigures subfigs for dividing main axes content and axes for legends subfigs = fig.subfigures(1, 2, hspace=0.0, width_ratios=[5,1.5]) # Two main subfigures for ground and excited electronic state height_ratios_main = [1, 2] subfigs_main = subfigs[0].subfigures(2, 1, wspace=0.0, height_ratios=height_ratios_main ) subfigs_ExSts= subfigs_main[0].subfigures(1, len(ExSts), hspace=0.0, width_ratios=[Exs.N for Exs in ExSts],squeeze=False )[0] subfigs_GrSts= subfigs_main[1].subfigures(1, len(GrSts), hspace=0.0, width_ratios=[Grs.N for Grs in GrSts],squeeze=False )[0] # Two subfigure instances for legend and colorbar with each a single subplot axis subfigs_leg = subfigs[1].subfigures(2, 1, wspace=0.0, height_ratios=height_ratios_main ) axs_legend = subfigs_leg[1].subplots(1, 1) ax_cbar = subfigs_leg[0].subplots(1, 1) tools.make_axes_invisible(axs_legend) #needed for drawing arrows on top over multiple figures subfig_last = subfigs_GrSts[-1] # draw only levels first if isinstance(QuNrs_sep,tuple): QuNrs_sep_u, QuNrs_sep_l = QuNrs_sep else: QuNrs_sep_u, QuNrs_sep_l = 2*[QuNrs_sep] coords_u = [ElSt.draw_levels(fig=subfigs_ExSts[i], QuNrs_sep=QuNrs_sep_u, yaxis_unit=yaxis_unit, ylabel=not bool(i), xlabel_pos='top') for i, ElSt in enumerate(ExSts)] coords_l = [ElSt.draw_levels(fig=subfigs_GrSts[i], QuNrs_sep=QuNrs_sep_l, yaxis_unit=yaxis_unit) for i, ElSt in enumerate(GrSts)] try: get_cmap = mpl.cm.get_cmap except AttributeError: get_cmap = mpl.colormaps.get_cmap cmap = get_cmap(cmap) # map branching ratios onto a color using a certain function: if branratios: branratios = levels.calc_branratios() from scipy.interpolate import interp1d if not callable(br_fun): br_fun_dict = {'log10':np.log10,'identity':lambda x:x,'sqrt':np.sqrt} if not br_fun in br_fun_dict: raise Exception('Not valid argument given for br_fun!') br_fun = br_fun_dict[br_fun] # isolate all branratios over a certain threshold in a separate flattened array and apply function br_flat = br_fun(branratios[branratios > br_TH]) # map branratio onto interval [0,1] for colormap map_br = interp1d([br_flat.min(),br_flat.max()],[1,0]) # iterate over states and draw branratios for GrSt,coords_l_ in zip(GrSts,coords_l): for ExSt,coords_u_ in zip(ExSts,coords_u): if GrSt == ExSt: continue Ng = self.levels.index_ElSt(GrSt,gs_exs='gs') Ne = self.levels.index_ElSt(ExSt,gs_exs='exs') for l,u in np.argwhere(branratios[Ng:Ng+GrSt.N,Ne:Ne+ExSt.N] > br_TH): #does not work when ExSts are switched, e.g. ['B','A']??! draw_line(subfig_last, axes=(coords_l_['axes'][l], coords_u_['axes'][u]), xys=(coords_l_['xy'][l], coords_u_['xy'][u]), arrowstyle='-',dxyB=[-0.2,0],alpha=0.5, linewidth=0.6,linestyle='--', color= cmap(map_br(br_fun(branratios[Ng+l,Ne+u])))) # draw colorbar norm = mpl.colors.Normalize(vmax=br_flat.max(),vmin=br_flat.min()) bounds = np.array(ax_cbar.get_position().bounds) ax_cbar.set_position(bounds*np.array([1,1,0.2,1])) #left bottom width height ax_cbar.tick_params(labelsize='small') fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap.reversed()), ticks=np.linspace(br_flat.min(),br_flat.max(),5), format='{:.2%}'.format, cax=ax_cbar, orientation='vertical', label='bran. ratio') else: tools.make_axes_invisible(ax_cbar) # draw lasers onto their addressing transitions as arrows: if self.lasers.pNum == 0: lasers = False if lasers: ls_fun = lambda x: ['-','-.',':'][x//10] self.calc_OBEs(t_int=1e-11, t_start=0., dt=None, t_eval = [], magn_remixing=False, freq_clip_TH=freq_clip_TH, steadystate=False, position_dep=False, rounded=False, verbose=False, return_fun=None, method='RK45') for GrSt,coords_l_ in zip(GrSts,coords_l): for ExSt,coords_u_ in zip(ExSts,coords_u): if GrSt == ExSt: continue Ng = self.levels.index_ElSt(GrSt,gs_exs='gs') Ne = self.levels.index_ElSt(ExSt,gs_exs='exs') for l,u,k in np.argwhere(np.abs(self.Gfd[Ng:Ng+GrSt.N,Ne:Ne+ExSt.N,:])>0): det = self.om_gek[Ng+l,Ne+u,k]*self.freq_unit/2/pi*1e-6/coords_u_['yaxis_unit'] draw_line(subfig_last, axes=(coords_l_['axes'][l], coords_u_['axes'][u]), xys =(coords_l_['xy'][l], coords_u_['xy'][u]), arrowstyle='->',linestyle=ls_fun(k),dxyB=[+0.2,det], alpha=0.8,color='C'+str(k)) # legend for lasers import matplotlib.lines as mlines handles = [mlines.Line2D([],[],lw=1,color='C'+str(k),ls=ls_fun(k), label='{:d}: {:.0f}, {:.1f}'.format( k, la.lamb*1e9, la.P*1e3) ) for k,la in enumerate(self.lasers)] legend = axs_legend.legend(handles=handles,loc='upper left', bbox_to_anchor=(0, 1),fontsize='x-small', title='i: $\lambda$ [nm], $P$ [mW]') plt.setp(legend.get_title(),fontsize='x-small')
#%% if __name__ == '__main__': system = System(description='test_System_mod',load_constants='138BaF') # Build level scheme system.levels.add_all_levels(v_max=0) system.levels.X.del_lossstate() # Add laser configuration system.lasers.add_sidebands( lamb = 859.83e-9, P = 20e-3, offset_freq = 19e6, mod_freq = 39.33e6, sidebands = [-2, -1, 1, 2], ratios = [0.8, 1, 1, 0.8] ) # Magnetic field system.Bfield.turnon(strength=5e-4, direction=[1, 1, 1]) # Dynamics simulations system.calc_OBEs(t_int=8e-6, dt=1e-9, magn_remixing=True) system.calc_rateeqs(t_int=8e-6, magn_remixing=True, position_dep=True) system.plot_N()