Source code for MoleCool.Levelsystem

# -*- coding: utf-8 -*-
"""
This module contains all classes and methods to define all **states** and their
**properties** belonging to a certain Levelsystem.
"""
import numpy as np
from scipy.constants import c,h,hbar,pi,g
from scipy.constants import u as u_mass
from scipy.special import voigt_profile
from MoleCool.tools import dict2DF, get_constants_dict, auto_subplots
from collections.abc import Iterable
import matplotlib.pyplot as plt
import warnings
import os
import numbers
from copy import deepcopy
import pandas as pd
from sympy.physics.wigner import wigner_3j,wigner_6j
#%%
[docs] class Levelsystem: def __init__(self,load_constants=None,verbose=True): """Levelsystem consisting of :class:`ElectronicState` instances and methods to add them properly. These respective objects can be retrieved and also deleted by using the normal item indexing of a :class:`Levelsystem`'s object:: from MoleCool import Levelsystem levels = Levelsystem() # define as ground state ('gs') levels.add_electronicstate('S12', 'gs') levels.S12.add(J=1/2,F=[1,2]) state1 = levels.S12[0] print(state1) del levels.S12[-1] # delete last added State instance del levels['S12'] # delete complete electronic state Parameters ---------- load_constants : str, optional the constants of the levelsystem can be imported from an .json file. If this is desired provide the respective filename without the .json extension. The default is None. verbose : bool, optional Specifies if additional warnings should be printed during the level construction. The default is True. Tip --- When arbitrary custom level systems want to be defined, first all levels have to be added. Then the default constants and properties can be nicely viewed with the function :meth:`print_properties()`. Afterwards the values in these pandas.DataFrames (here: vibrational branchings, transition wavelength, and g-factor) can be easily modified via `<DataFrame>.iloc[<index>]`. Tip --- Important properties within an instance :class:`Levelsystem` can be accessed with the ``get_<property>()`` methods or directly via the properties without ``get_`` in their names, like: * dMat # electric dipole matrix * dMat_red # reduced electric dipole matrix * vibrbranch # vibrational branching ratios * wavelengths # wavelengths (in nm) between the transitions """ # unsorted list containing the labels of all added ElectronicStates: self.__ElSts_labels = [] self.verbose = verbose # loading the dictionary with the constants from json file self.load_constants = load_constants self.const_dict = get_constants_dict(load_constants) # initialize all property instances self.reset_properties() def __getitem__(self,index): if isinstance(index, str): if index not in self.__ElSts_labels: raise ValueError(f"No ElectronicState '{index}' defined.") return self.__dict__[index] else: if self.N == 0: raise Exception('No states defined within the Levelsystem!') #if indeces are integers or slices (e.g. obj[3] or obj[2:4]) if isinstance(index, (int, slice)): return self.states[index] #if indices are tuples instead (e.g. obj[1,3,2]) return [self.states[i] for i in index] def __delitem__(self,label): """delete electronic states del system.levels[<electronic state label>], or delete all del system.levels[:]""" if type(label) != str: raise ValueError('index for an electronic state to be deleted must be provided as string of the label') if label not in self.__ElSts_labels: raise ValueError(f"No ElectronicState '{label}' defined.") self.__ElSts_labels.remove(label) del self.__dict__[label] self.reset_properties() print(f'ElectronicState {label} deleted and all properties resetted!')
[docs] def add_all_levels(self,v_max): """Add all ground and excited states of a molecule conveniently with a loss state. Parameters ---------- v_max : int all ground states with vibrational levels :math:`\\nu\\le` `v_max` and respectively all excited states up to `(v_max-1)` are added to the subclasses :class:`Groundstates` and :class:`Excitedstates`. """ for key in list(self.const_dict['level-specific'].keys()): gs_exs,label = dict2DF.split_key(key) self.add_electronicstate(label, gs_exs) if gs_exs == 'gs': self[label].load_states(v=np.arange(0,v_max+.5,dtype=int)) self[label].add_lossstate(v=v_max+1) elif gs_exs == 'exs': if v_max == 0: self[label].load_states(v=0) else: self[label].load_states(v=np.arange(0,v_max-.5,dtype=int))
[docs] def add_electronicstate(self,label,gs_exs,load_constants=None,**kwargs): """Add an electronic state (ground or excited state) as instance of the class :class:`ElectronicState` to this instance of :class:`Levelsystem`. Parameters ---------- label : str label or name of the electronic state so that this electronic state will be accessible via levels.<label>. gs_exs : str determines whether an electronic ground or excited state should be added. Therefore, `gs_exs` can be either 'gs' or 'exs'. load_constants : str, optional the constants of the levelsystem can be imported from an .json file. If this is desired provide the respective filename without the .json extension. The default is None. **kwargs : kwargs keyword arguments for the eletronic state (see :class:`ElectronicState` for the specific parameters) Returns ------- :class:`ElectronicState` Initialized electronic state instance. """ if not label.isidentifier(): raise ValueError('Please provide a valid variable/ instance name for `label`!') if (not load_constants) and self.load_constants: load_constants = self.load_constants if not ('verbose' in kwargs): kwargs['verbose'] = self.verbose if label in self.__ElSts_labels: raise Exception('There is already an ElectronicState {label} defined!') if gs_exs == 'gs': ElSt = ElectronicGrState(load_constants=load_constants,label=label,**kwargs) elif gs_exs == 'exs': ElSt = ElectronicExState(load_constants=load_constants,label=label,**kwargs) elif gs_exs == 'ims': ElSt = ElectronicImState(load_constants=load_constants,label=label,**kwargs) else: raise ValueError(("Please provide 'gs', 'exs', or 'ims' as `gs_exs` for " "an electronic ground, excited or intermediate state")) self.__dict__[label] = ElSt self.__ElSts_labels.append(label) return ElSt
#%% get functions # def load_save_DF(func): # def wrapper(*args,**kwargs): # print('warpper') # inst = args[0] # DFsaved, gs, exs = inst._load_qty(name='_dMat',*args,**kwargs) # if len(DFsaved) != 0: return DFsaved # DF = func(inst,gs,exs) # inst._save_qty(DF,'_dMat',gs,exs) # return DF # return wrapper # @load_save_DF
[docs] def get_dMat(self,gs=None,exs=None): """Return the electric dipole matrix. This matrix is either simply loaded from the .json file or constructed with the reduced electric dipole matrix given by the function :meth:`get_dMat_red()`. Parameters ---------- calculated_by : str, optional Additional parameter if multiple different matrices are available for one system. The default is 'YanGroupnew'. Returns ------- pandas.DataFrame Electric dipole matrix. """ #this function has to be resetted after a change of dMat_from which the new dMat should be calculated DFsaved, gs, exs = self._load_qty(name='_dMat',gs=gs,exs=exs) if len(DFsaved) != 0: return DFsaved #gs_exs_label = '-'.join([gs,exs]) #df1.index.to_frame()['gs'].iloc[0]#ß? #df1.index.names.index('gs') #df1.columns.to_frame()['exs'].iloc[0] DF_dMat = dict2DF.get_DataFrame(self.const_dict,'dMat',gs=gs,exs=exs) DF_dMat_red = dict2DF.get_DataFrame(self.const_dict,'dMat_red',gs=gs,exs=exs) DF_branratios = dict2DF.get_DataFrame(self.const_dict,'branratios',gs=gs,exs=exs) if len(DF_dMat) != 0: dMat = DF_dMat elif (len(DF_dMat_red) == 0) and (len(DF_branratios) !=0): self._branratios = DF_branratios if self.verbose: warnings.warn('No dipole matrix or reduced dipole matrix found, ' + 'so the dipole matrix is constructed from the given ' + 'branching ratios only with positive values!') dMat = self._branratios**0.5 else: dMat_red = self.get_dMat_red(gs=gs,exs=exs) dMat = [] index = [] for index1,row1 in dMat_red.iterrows(): F = index1[dMat_red.index.names.index('F')] for mF in np.arange(-F,F+1): dMat_row = [] index.append([*index1,mF]) columns = [] for index2,row2 in row1.items(): F_ = index2[row1.index.names.index('F')] for mF_ in np.arange(-F_,F_+1): dMat_row.append( row2 * (-1)**(F_-mF_) * float(wigner_3j(F_,1,F,-mF_,mF_-mF,mF)) ) columns.append([*index2,mF_]) dMat.append(dMat_row) dMat = pd.DataFrame(dMat, index =pd.MultiIndex.from_arrays(np.array(index,dtype=object).T, names=(*dMat_red.index.names,'mF')), columns=pd.MultiIndex.from_arrays(np.array(columns,dtype=object).T, names=(*dMat_red.columns.names,'mF')) ) dMat /= np.sqrt((dMat**2).sum(axis=0)) dMat = dMat.fillna(0.0) self._save_qty(dMat,name='_dMat',gs=gs,exs=exs) return dMat
[docs] def get_dMat_red(self,gs=None,exs=None): """Return the reduced electric dipole matrix. This matrix is either simply loaded from the .json file or constructed with the electric dipole matrix given by the function :meth:`dMat_red`. If no dipole matrix or a reduced one is available a new reduced matrix is constructed with only ones for each transition which can be adjusted afterwards. Returns ------- pandas.DataFrame Reduced electric dipole matrix. """ DFsaved, gs, exs = self._load_qty(name='_dMat_red',gs=gs,exs=exs) if len(DFsaved) != 0: return DFsaved DF_dMat_red = dict2DF.get_DataFrame(self.const_dict,'dMat_red',gs=gs,exs=exs) if len(DF_dMat_red) != 0: dMat_red = DF_dMat_red # must be updated below! elif len(dict2DF.get_DataFrame(self.const_dict,'dMat',gs=gs,exs=exs)) != 0: dMat = self.get_dMat(gs=gs,exs=exs).sort_index(axis='index') dMat_red = dMat.copy() #,level=[0,1]) for J,F,mF in dMat_red.index: for J_,F_,mF_ in dMat_red.columns: grtoex = True if grtoex: Fa,Ma,Fb,Mb = F_,mF_,F,mF else: #extogr == True: Fa,Ma,Fb,Mb = F,mF,F_,mF_ factor = (-1)**(Fa-Ma)*np.sqrt(2*Fa+1) * float(wigner_3j(Fa,1,Fb,-Ma,Ma-Mb,Mb)) if factor != 0.0: dMat_red.loc[(J,F,mF),(J_,F_,mF_)] /= factor return dMat_red else: # self._dMat[gs][exs] = None #remove this? dMat_red = pd.DataFrame(1.0,index=self[gs].DFzeros_without_mF().index, columns=self[exs].DFzeros_without_mF().index) if self.verbose: warn_txt = 'There is no dipole matrix or reduced dipole matrix available!' + \ 'So a reduced matrix has been created only with ones:\n{}'.format(dMat_red) warnings.warn(warn_txt) self._save_qty(dMat_red,name='_dMat_red',gs=gs,exs=exs) return dMat_red
[docs] def get_vibrbranch(self,gs=None,exs=None): """Return a matrix for the vibrational branching ratios between vibrational excited levels with :math:`\\nu` and ground levels wth :math:`\\nu'`.This matrix is either simply loaded from the .json file or constructed with the same branching ratios for all transitions. Returns ------- pandas.DataFrame vibrational branching ratios matrix. """ DFsaved, gs, exs = self._load_qty(name='_vibrbranch',gs=gs,exs=exs) if len(DFsaved) != 0: return DFsaved DF = dict2DF.get_DataFrame(self.const_dict,'vibrbranch',gs=gs,exs=exs) if len(DF) == 0: DF_gs = self[gs].DFzeros_without_mF() DF_exs = self[exs].DFzeros_without_mF() DF = pd.DataFrame(1.0, index=DF_gs.index.droplevel([QuNr for QuNr in DF_gs.index.names if QuNr not in ['gs','exs','ims','v']]).drop_duplicates(), columns=DF_exs.index.droplevel([QuNr for QuNr in DF_exs.index.names if QuNr not in ['gs','exs','ims','v']]).drop_duplicates()) DF /= DF.sum(axis=0) self._save_qty(DF,name='_vibrbranch',gs=gs,exs=exs) return DF
[docs] def get_transdipmoms(self): """Return transition dipole moments between electronic states. Returns ------- pandas.DataFrame Transition dipole moments. """ if len(self._transdipmoms) == 0: DF = dict2DF.get_DataFrame(self.const_dict,'transdipmoments') if len(DF) == 0: DF = pd.DataFrame(1.0,index=self.grstates_labels, columns=self.exstates_labels) self._transdipmoms = DF ImSt_inds = [(i,j) for j,col in enumerate(self._transdipmoms.columns) for i,ind in enumerate(self._transdipmoms.index) if col==ind] for ImSt_ind in ImSt_inds: self._transdipmoms.iloc[ImSt_ind] = 0.0 return self._transdipmoms
[docs] def get_wavelengths(self,gs=None,exs=None): """Return a list of matrices for nicely displaying the wavelengths between the vibrational transitions and the frequencies between hyperfine transitions to conveniently specifying or modifying all participating transitions. These wavelengths and frequencies are loaded from the .json file if available. Otherwise all wavelengths are set to 860e-9 and all other hyperfine frequencies to zero to be adjusted. Returns ------- list of pandas.DataFrame and pandas.Series entries. list of matrices specifying the frequencies of the participating transitions. """ DFsaved, gs, exs = self._load_qty(name='_wavelengths',gs=gs,exs=exs) if len(DFsaved) != 0: return DFsaved DF = dict2DF.get_DataFrame(self.const_dict,'vibrfreq',gs=gs,exs=exs) if len(DF) == 0: DF = pd.DataFrame(860.0,index=self[gs].DFzeros_without_mF().index, columns=self[exs].DFzeros_without_mF().index) self._save_qty(DF,name='_wavelengths',gs=gs,exs=exs) return DF
def _load_qty(self,name,gs=None,exs=None): if gs == None: gs = self.grstates_labels[0] if exs == None: exs = self.exstates_labels[0] dic = self.__dict__[name] if gs in dic: if exs in dic[gs]: if len(dic[gs][exs]) != 0: return dic[gs][exs], gs, exs return {}, gs, exs def _save_qty(self,value,name,gs,exs): dic = self.__dict__[name] if gs in dic: dic[gs][exs] = value else: dic[gs] = {exs: value} #: electric dipole matrix dMat = property(get_dMat) #: reduced electric dipole matrix dMat_red = property(get_dMat_red) #: vibrational branching ratios vibrbranch = property(get_vibrbranch) #: transition wavelengths and frequencies wavelengths = property(get_wavelengths) #: transition dipole moments transdipmoms= property(get_transdipmoms) #%% calc functions for the rate and optical Bloch equations
[docs] def calc_dMat(self): """ Calculate matrix elements of the electric dipole moment operator. In contrast to the other functions :meth:`get_dMat()` or :meth:`get_dMat_red()`, this method calculates the normalized electric dipole matrix as numpy.ndarray ready to be directly called and used for the methods :meth:`~.System.System.calc_rateeqs` and :meth:`~.System.System.calc_OBEs`. This matrix includes also the vibrational branching ratios and handles the loss state in a correct way and is not meant to be modified. Returns ------- numpy.ndarray fully normalized electric dipole matrix. """ if np.all(self.__dMat_arr) != None: return self.__dMat_arr self.__dMat_arr = np.zeros((self.lNum,self.uNum,3)) DF_tdm = self.get_transdipmoms() DF_tdm /= np.sqrt((DF_tdm**2).sum(axis=0)) #levels._dMat.xs((1.5,2,-1),level=('J','F','mF'),axis=0,drop_level=True).xs((0.5,1,-1),level=("J'","F'","mF'"),axis=1,drop_level=True) N_grstates = 0 for Grs_lab, Grs in zip(self.grstates_labels, self.grstates): N_exstates = 0 for Exs_lab,Exs in zip(self.exstates_labels, self.exstates): if Grs == Exs: continue DF_vb = self.get_vibrbranch(gs=Grs_lab, exs=Exs_lab) val_tdm = DF_tdm.loc[Grs_lab,Exs_lab] if 'v' in Grs[0].QuNrs: DF_vb = DF_vb.iloc[np.argwhere(DF_vb.index.get_level_values('v') < Grs.v_max+0.1)[:,0]] DF_vb /= DF_vb.sum(axis=0) # normalized DF_vb must be multiplied afterwards with a factor due to transition dipole moment DF_dMat = self.get_dMat(gs=Grs_lab, exs=Exs_lab) DF_dMat /= np.sqrt((DF_dMat**2).sum(axis=0)) DF_dMat = DF_dMat.fillna(0.0) for l,gr in enumerate(Grs.states): for u,ex in enumerate(Exs.states): val_vb = self.val_states_in_DF(gr,ex,DF_vb) if gr.is_lossstate: #the q=+-1,0 entries squared of the dMat are summed in the last line of the equations set in the Fokker-Planck paper. # So for the loss state which should not interact with other levels, it doesn't matter which q component of the sum in the last line is contributing. self.__dMat_arr[N_grstates+l, N_exstates + u, :] = np.array([1,0,0])*np.sqrt(val_vb) else: val_dMat = self.val_states_in_DF(gr,ex,DF_dMat) pol = ex.mF-gr.mF if (abs(pol) <= 1) and (val_vb != None): self.__dMat_arr[N_grstates+l, N_exstates+u, int(pol)+1] = val_tdm*val_dMat*np.sqrt(val_vb) N_exstates += Exs.N N_grstates += Grs.N self.__dMat_arr /= np.sqrt((self.__dMat_arr**2).sum(axis=(2,0)))[None,:,None] # np.nan_to_num(self.__dMat_arr,copy=False) return self.__dMat_arr
[docs] def calc_branratios(self): """Calculate fully normalized branching ratios using the dipole matrix calculated in the function :meth:`calc_dMat()` (see for more details). Returns ------- numpy.ndarray fully normalized branching ratios. """ if np.all(self.__branratios_arr) != None: return self.__branratios_arr self.__branratios_arr = (self.calc_dMat()**2).sum(axis=2) return self.__branratios_arr
[docs] def calc_freq(self): """Calculate the angular absolute frequency **differences** between **all** levels included in this class using the wavelengths and frequencies specified by the function :meth:`~ElectronicState.get_freq()`. These values are returned as numpy array ready to be directly called and used for the functions :meth:`~.System.System.calc_rateeqs()` and :meth:`~.System.System.calc_OBEs()`. Returns ------- numpy.ndarray angular frequency array. """ if np.all(self.__freq_arr) != None: return self.__freq_arr self.__freq_arr = np.zeros((self.lNum,self.uNum)) N_grstates = 0 for Grs_lab, Grs in zip(self.grstates_labels, self.grstates): N_exstates = 0 for Exs_lab,Exs in zip(self.exstates_labels, self.exstates): DF = self.get_wavelengths(gs=Grs_lab, exs=Exs_lab) for l,gr in enumerate(Grs.states): for u,ex in enumerate(Exs.states): val = self.val_states_in_DF(gr,ex,DF) if (val != None) and (val != 0): self.__freq_arr[N_grstates+l,N_exstates+u] = c/(val*1e-9) N_exstates += Exs.N N_grstates += Grs.N for i0, ElSts in enumerate([self.grstates,self.exstates]): N_ElSts = 0 for ElSt in ElSts: DF = ElSt.get_freq() for i_st,st in enumerate(ElSt.states): if st.is_lossstate: #leave this restriction here?! continue val = self.val_state_in_DF(st,DF) if val != None: if i0 == 0: self.__freq_arr[N_ElSts+i_st,:] -= val*1e6 elif i0 == 1: self.__freq_arr[:,N_ElSts+i_st] += val*1e6 N_ElSts += ElSt.N self.__freq_arr *= 2*pi #make angular frequencies return self.__freq_arr
[docs] def val_states_in_DF(self,st1,st2,DF): #move this and the other function outside of the class with an kwarg verbose. ind_names = list(DF.index.names) col_names = list(DF.keys().names) for index1,row1 in DF.iterrows(): if len(ind_names) == 1: index1 = (index1,) #index1 must be a iterable tuple even if it containts only 1 element for index2,row2 in row1.items(): if len(col_names) == 1: index2 = (index2,) allTrue = [True] for i,ind_name in enumerate(ind_names): if not np.all(allTrue): break allTrue.append(index1[i] == st1.__dict__.get(ind_name,None)) for j,col_name in enumerate(col_names): if not np.all(allTrue): break allTrue.append(index2[j] == st2.__dict__.get(col_name,None)) if np.all(allTrue): return row2 if st1.is_lossstate or st2.is_lossstate: return None elif (('v' in st1.QuNrs) and (st1.v > 0)) or (('v' in st2.QuNrs) and (st2.v > 0)): st1_, st2_ = st1.copy(), st2.copy() st1_.v, st2_.v = 0, 0 return self.val_states_in_DF(st1_,st2_,DF) elif self.verbose: warnings.warn('No value in DF found for {}, {}'.format(st1,st2)) return None
[docs] def val_state_in_DF(self,st,DF): ind_names = list(DF.index.names) for index1,row1 in DF.items(): allTrue = [True] for i,ind_name in enumerate(ind_names): if not np.all(allTrue): break allTrue.append(index1[i] == st.__dict__.get(ind_name,None)) if np.all(allTrue): return row1 if ('v' in st.QuNrs) and (st.v > 0) and (not st.is_lossstate): st_ = st.copy() st_.v = 0 return self.val_state_in_DF(st_,DF) elif self.verbose: warnings.warn('No value in DF found for {}'.format(st)) return None
[docs] def calc_muMat(self): """Calculate the magnetic dipole moment operator matrix for **all** levels included in this class using the g-factors specified by the function :meth:`~ElectronicState.get_gfac()`. These values are returned as numpy array ready to be directly called and used for the function :meth:`~.System.System.calc_OBEs()`. Returns ------- tuple of numpy.ndarray magnetic moment operator matrix. """ if self._muMat != None: return self._muMat # mu Matrix for magnetic remixing: # this matrix includes so far also off-diagonal non-zero elements (respective to F,F') # which will not be used in the OBEs calculation self._muMat = (np.zeros((self.lNum,self.lNum,3)), np.zeros((self.uNum,self.uNum,3))) for i0, ElSts in enumerate([self.grstates,self.exstates]): N = 0 for ElSt in ElSts: if (i0 == 1) and (ElSt.gs_exs == 'ims'): continue DF = ElSt.get_gfac() for i1, st1 in enumerate(ElSt.states): if st1.is_lossstate: continue # all elements self._muMat[i0][i1,N:ElSt.N,:] remain zero val = self.val_state_in_DF(st1,DF) F,m = st1.F, st1.mF for i2, st2 in enumerate(ElSt.states): if st2.is_lossstate: continue # all elements self._muMat[i0][i1,i2,:] remain zero if not st1.is_equal_without_mF(st2): continue n = st2.mF for q in [-1,0,1]: if val != None: self._muMat[i0][N+i1,N+i2,q+1] = -val* (-1)**(F-m)* \ np.sqrt(F*(F+1)*(2*F+1)) * float(wigner_3j(F,1,F,-m,q,n)) N += ElSt.N return self._muMat
[docs] def calc_M_indices(self): """Calculate the indices determining all hyperfine states within a specific F or F' of the ground or excited state. These values are used in the function :meth:`~.System.System.calc_OBEs()` when looping through all hyperfine states in conjunction with the g-factors for calculation the effect of a magnetic field. Returns ------- tuple of tuple of lists indices of the magnetic sublevels belonging to a certain F or F' of the ground or excited state. """ if self._M_indices != None: return self._M_indices M_indices = [[],[]] for i0, ElSts in enumerate([self.grstates,self.exstates]): N = 0 for ElSt in ElSts: states_list = [st for st in ElSt.states] for l1,st1 in enumerate(states_list): list_M = [] if st1.is_lossstate: M_indices[i0].append(np.array([N+l1])) continue for l2,st2 in enumerate(states_list): if st2.is_lossstate: continue if st1.is_equal_without_mF(st2): list_M.append(N+l2) M_indices[i0].append(np.array(list_M)) N += ElSt.N self._M_indices = (tuple(M_indices[0]),tuple(M_indices[1])) return self._M_indices
[docs] def calc_Gamma(self): """Calculate the natural decay rate Gamma (in angular frequency) for each single excited state. Returns ------- numpy.ndarray Gamma array of length uNum as angular frequency [Hz]. """ if np.all(self._Gamma) != None: return self._Gamma else: self._Gamma = np.array([ElSt.Gamma for ElSt in self.exstates for i in range(ElSt.N)]) * 2*pi * 1e6 return self._Gamma
[docs] def calc_all(self): """Calculate all level properties once in the beginning, so that they are stored and calling the method :meth:`~MoleCool.System.System.calc_OBEs()` multiple times doesn't require additional computation time. """ # initially calculate every property of the level system so that these # arrays can simply be called without calculating them every time: self.calc_dMat() self.calc_branratios() self.calc_freq() self.calc_muMat() self.calc_M_indices() self.calc_Gamma() # mark that properties are calculated for not adding/deleting more states for ElSt in self.electronic_states: ElSt.properties_not_calculated = False
#%% def __str__(self): """__str__ method is called when an object of a class is printed with print(obj)""" for ElSt in self.electronic_states: print(ElSt) return self.description
[docs] def transitions_energies_strengths(self, include_forbidden=False, gs=None, exs=None, use_calc_props=True): """Yield the transition strengths and energies without including all mF levels. This is e.g. used by the method :meth:`plot_transition_spectrum`. The strengths are solely calculated using the property :meth:`dMat_red`. Parameters ---------- include_forbidden : bool, optional Whether to include also the transitions with vanishing branchings. The default is False. gs : str, optional Electronic ground state label. The default is None. exs : str, optional Electronic excited state label. The default is None. use_calc_props : bool, optional If True, the properties like the dipole matrix and all transition energies are calculated and then used to plot the spectrum with exactly the values that are used for the simulation. If False, the reduced dipole matrix property is directly used and the wavelengths property is not taken into account. Returns ------- Es : np.ndarray Energies in MHz. branchings : np.ndarray reduced branching ratios. states : list(tuple) list of tuple pairs containing the ground and excited state for each transition energy and branching. """ if gs == None: gs = self.grstates_labels[0] if exs == None: exs = self.exstates_labels[0] states_sets = dict() inds = dict() for j,ElSt_label in enumerate([gs,exs]): states = [] indices = [] for i,st in enumerate(self[ElSt_label].states): if not np.any([st.is_equal_without_mF(st2) for st2 in states]): states.append(st) indices.append(i) states_sets[ElSt_label] = states inds[ElSt_label] = indices if use_calc_props: inds2 = [] for j, ElSt in enumerate([self[gs], self[exs]]): inds2.append([[i for i,st in enumerate(ElSt) if st0.is_equal_without_mF(st)] for st0 in states_sets[ElSt.label]]) Es = [] branchings = [] states = [] ElGr, ElEx = list(states_sets.keys()) for i,gr in enumerate(states_sets[ElGr]): if gr.is_lossstate: continue for j,ex in enumerate(states_sets[ElEx]): if use_calc_props: branching = self.calc_branratios()[np.ix_(inds2[0][i],inds2[1][j])].sum() E = ((self.calc_freq()/2/pi)*1e-6)[inds[gs][i],inds[exs][j]] else: branching = self.val_states_in_DF(gr, ex, self.get_dMat_red(gs=gs,exs=exs)**2) E = self.val_state_in_DF(ex, self[ElEx].get_freq()) \ - self.val_state_in_DF(gr, self[ElGr].get_freq()) if include_forbidden or branching: branchings.append(branching) Es.append(E) states.append((gr,ex)) return Es, branchings, states
[docs] def plot_transition_spectrum(self, std=0, xaxis=[], xaxis_ext=5, N_points=500, wavelengths=[], relative_to_wavelengths=False, kwargs_single=dict(), kwargs_sum=dict(ls='-', color='k'), plot_single=True, plot_sum=True, ax=None, legend=True, QuNrs=[['F'],['F']], exs=[], subplot_sep=200., E_unit='MHz', E_offset=0, kwargs_trans_ener_stre=dict()): """Plot the transition spectrum with Voigt profiles using the energies and strengths from :meth:`transitions_energies_strengths()`. Parameters ---------- std : float, optional Standard deviation resulting in the Gaussian broadening of the voigt profile in MHz. The Lorentzian broadening is always given by the natural linewidth of the electronic excited state. The default is 0. xaxis : list or np.ndarray, optional xaxis data points. The default is []. xaxis_ext : float, optional if not xaxis is given, the range of the xaxis, given by the lowest and highest transition frequency, is extended by this factor multiplied by the sum of the Gaussian and Lorentzian broadening. The default is 5. N_points : int, optional number of points plotted. The default is 500. wavelengths : list, optional wavelengths that should be plotted within the range ``subplot_sep``. By default all available transition wavelengths are used. relative_to_wavelengths : bool, optional Whether the x-axis should be plotted in absolute frequency units or relative to ``wavelengths``. kwargs_single : dict, optional keyword arguments used in ``plt.plot()`` for the single transition lines. The default is dict(). kwargs_sum : dict, optional same as ``kwargs_single`` but for the sum. The default is dict(ls='-', color='k'). plot_single : bool, optional Whether to plot single transition lines. The default is True. plot_sum : bool, optional Whether to plot the sum of all transition lines. The default is True. ax : ``matplotlib.pyplot.axis`` or list of ``axis``, optional axis or multiple axes to put the plot(s) on. The default is None. legend : bool, optional Whether to show legend. The default is True. QuNrs : list(list), optional QuNrs of ground and excited state used for labelling and legend. The default is [['J','F'],['F']]. exs : list(str), optional List of excited states to be plotted (see :meth:`transitions_energies_strengths`). By default only the first excited electronic state is used. 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 :meth:`ElectronicExState.Gamma`. Default is 200. E_unit : str, optional Unit of the x-axis to be plotted. Can be one of ``['GHz','MHz','kHz','Hz','Gamma']``. Default is 'MHz'. E_offset : float, optional Energy offset on the xaxis. The default is 0. kwargs_trans_ener_stre : dict, optional keyword arguments for :meth:`transitions_energies_strengths`, e.g. gs as label used for the electronic ground state. Returns ------- ax : matplotlib.pyplot.axes or list for multiple plots axis of the plot. data : dict(np.ndarray) or list for multiple plots raw data thats plotted. Includes the single transitions, the sum and the x axis data. """ if not exs: exs = [self.exstates_labels[0]] if len(exs)>1: raise Exception('Multiple excited electronic states are not supported yet.') else: ExSt = exs[0] kwargs_trans_ener_stre['exs'] = ExSt if not 'exs' in kwargs_trans_ener_stre: kwargs_trans_ener_stre['exs'] = self.exstates_labels[0] exs = kwargs_trans_ener_stre['exs'] out = self.transitions_energies_strengths(**kwargs_trans_ener_stre) for i,el in enumerate(out[0]): out[0][i] *= 1e6 # from MHz to Hz Gamma = self[ExSt].Gamma*1e6 # natural linewidth in Hz subplot_sep *= Gamma x_ext = (Gamma+std)*xaxis_ext # extension on the x axis fac_x = {'GHz':1e9, 'MHz':1e6, 'kHz':1e3, 'Hz':1, 'Gamma':Gamma}[E_unit] # get wavelengths of all transition blocks that span subplot_sep*Gamma if not list(wavelengths): fsort = np.sort(out[0]) # sorted inds = [i+1 for i,df in enumerate(np.diff(fsort)) if df > subplot_sep] inds = [0, *inds] fmean = np.array([ fsort[i:j].mean() for i,j in zip(inds, inds[1:]+[None])]) wavelengths = c/fmean # generate subplots axs = auto_subplots(len(wavelengths), axs=[] if ax==None else ax) data_list = [] for j,(ax,wavelength) in enumerate(zip(axs,wavelengths)): xconv = lambda x: (x - c/wavelength*int(bool(relative_to_wavelengths)))/fac_x inds = np.argwhere((out[0]>c/wavelength-subplot_sep) \ & (out[0]<c/wavelength+subplot_sep))[:,0] out_cut = [[out_i[i] for i in inds] for out_i in out] if not out_cut[0]: continue if len(xaxis) == 0 or j>0: xaxis = np.linspace(min(out_cut[0])-x_ext, max(out_cut[0])+x_ext, N_points) spectrum = np.zeros(len(xaxis)) y_arr = [] norm = voigt_profile(np.linspace(-(std+Gamma),+(std+Gamma),500), std, Gamma/2).max() for E,bran,(gr,ex) in zip(*out_cut): # Gaussian and Lorentzian (note factor of 2) broadening in Voigt profile voigt = voigt_profile(E-xaxis, std, Gamma/2) y = bran*voigt/norm y_arr.append(y) label = None if legend: strings = [[str(st.__dict__[QuNr]) for QuNr in QuNrs_] for st,QuNrs_ in zip([gr,ex],QuNrs)] label = ', '.join(strings[0]) + '$\\rightarrow$' + ', '.join(strings[1]) spectrum += y if plot_single: ax.plot(xconv(xaxis)+E_offset, y, label=label, **kwargs_single) if plot_sum: ax.plot(xconv(xaxis)+E_offset, spectrum, **kwargs_sum) xlabel = f'Frequency ({E_unit})' if relative_to_wavelengths: xlabel += f" at {wavelength*1e9:f} nm" ax.set_xlabel(xlabel) ax.set_ylabel('Line strength') if legend: ax.legend(title=', '.join(QuNrs[0]) + '$\\rightarrow$' + "', ".join(QuNrs[1]) + "'") data_list.append(dict(x=xaxis+E_offset, sum=spectrum, single=np.array(y_arr))) if len(wavelengths) == 1: return axs[0], data_list[0] else: return axs, data_list
[docs] def print_properties(self): """Print all relevant constants and properties of the composed levelsystem in a convenient way to modify them if needed afterwards. """ n=40 print('{s:{c}^{n}}'.format(s='',n=n,c='*')) print('{s:{c}^{n}}'.format(s=' Levelsystem ',n=n,c='*')) print('{s:{c}^{n}}'.format(s='',n=n,c='*')) print('\nmass (in u):', self.mass/u_mass) print('\n{s:{c}^{n}}'.format(s=' level-specific ',n=n,c='+')) for ElSt in self.electronic_states: ElSt.print_properties() print('\n{s:{c}^{n}}'.format(s=' transition-specific ',n=n,c='+')) print('\ntransition dipole moments:', self.transdipmoms, sep='\n') for Grs_lab, Grs in zip(self.grstates_labels, self.grstates): for Exs_lab,Exs in zip(self.exstates_labels, self.exstates): if Grs == Exs: continue # skip for intermediate states print('\n{s:{c}^{n}}'.format(s=Grs_lab + ' <- ' + Exs_lab,n=n,c='-')) print('\ndipole matrix:', self.get_dMat(gs=Grs_lab,exs=Exs_lab), '\nvibrational branching:', self.get_vibrbranch(gs=Grs_lab,exs=Exs_lab), '\nwavelengths (in nm):', self.get_wavelengths(gs=Grs_lab,exs=Exs_lab), sep='\n')
[docs] def check_config(self,raise_Error=False): """Check the configuration of the Levelsystem to be used in calculating laser cooling dynamics. E.g. involves to check whether the states are correctly defined. Parameters ---------- raise_Error : bool, optional If the configuration is not perfect, this method raises an error message or only prints a warning depending on `raise_Error`. The default is False. """ Err_str = 'Electronic state {} contains no levels!' for ElSt in self.electronic_states: if ElSt.N == 0: if raise_Error: raise Exception(Err_str.format(ElSt.label)) else: warnings.warn(Err_str.format(ElSt.label))
[docs] def reset_properties(self): """Reset and initialize all property objects (e.g. dMat, dMat_red, vibrbranch, wavelengths, muMat, Mindices, Gamma). """ self.mass = self.const_dict.get('mass',0.0)*u_mass #if no value is defined, mass will be set to 0 # The following variables with one underscore are meant to not directly accessed # outside of this class. They can be called and and their values can be modified # by using the respective methods "get_<variable>" self._dMat = {} #dict with first key as gs label and second key (of the nested dict) as exs label self._dMat_red = {} self._vibrbranch = {} self._wavelengths = {} self._transdipmoms = [] # The following variables with two underscores are only for internal use inside the class self.__dMat_arr = None self.__branratios_arr = None self.__freq_arr = None self._muMat = None self._M_indices = None self._Gamma = None
@property def description(self): """str: Display a short description with the number of included state objects.""" return "{:d}+{:d} - Levelsystem".format(self.lNum,self.uNum) @property def states(self): """Return a combined list of all state objects defined in the individual electronic states. """ return [st for ElSt in self.electronic_states for st in ElSt.states] @property def lNum(self): '''Return the total number of states defined in the ground electronic states as an integer.''' return sum([ElSt.N for ElSt in self.grstates]) @property def uNum(self): '''Return the total number of states defined in the excited electronic states as an integer.''' return sum([ElSt.N for ElSt in self.exstates]) @property def iNum(self): '''Return the total number of states defined in all intermediate electronic states as an integer.''' return sum([ElSt.N for ElSt in self.grstates if ElSt.gs_exs == 'ims']) @property def N(self): '''Return the total number of unique states defined in all electronic states as an integer, i.e. N = :meth:`lNum` + :meth:`uNum` - :meth:`iNum`.''' return self.lNum + self.uNum -self.iNum @property def grstates(self): '''Return a list containing all defined instances of ground electronic states (:class:`ElectronicGrState`).''' ElSts = [self[label] for label in self.__ElSts_labels] # unsorted return [*[ElSt for ElSt in ElSts if ElSt.gs_exs == 'gs'], *[ElSt for ElSt in ElSts if ElSt.gs_exs == 'ims']] @property def exstates(self): '''Return a list containing all defined instances of excited electronic states (:class:`ElectronicExState`).''' ElSts = [self[label] for label in self.__ElSts_labels] # unsorted return [*[ElSt for ElSt in ElSts if ElSt.gs_exs == 'exs'], *[ElSt for ElSt in ElSts if ElSt.gs_exs == 'ims']] @property def electronic_states(self): '''Return a list containing all defined instances of electronic states, i.e. stacking list of :meth:`grstates` and :meth:`exstates`.''' return [*self.grstates,*[ExSt for ExSt in self.exstates if ExSt not in self.grstates]] @property def grstates_labels(self): '''Return a list containing the labels of all ground electronic states''' return [ElSt.label for ElSt in self.grstates] @property def exstates_labels(self): '''Return a list containing the labels of all excited electronic states''' return [ElSt.label for ElSt in self.exstates] @property def Isat(self): """Calculate the two-level saturation intensity in W/m^2 for all transitions. """ return pi*c*h*self.calc_Gamma()[None,:]/(3*( 2*pi*c/self.calc_freq() )**3)
[docs] def Isat_eff(self,GrSt,ExSt): """Calculate the effective multi-level saturation intensity in W/m^2 between two electronic states whose states are all coupled together. This quantity can be derived by rearranging the rate equations into a general approximate expression for the scattering rate. Here, the assumptions that all detunings are equal, all intensities are equal, and all excited states are equally populated are used. """ lNum = self[GrSt].N uNum = self[ExSt].N i0,i1 = self.index_ElSt(GrSt,'gs'), self.index_ElSt(ExSt,'exs') return 2*lNum**2/(lNum+uNum)*self.Isat[i0:i0+lNum, i1:i1+uNum]
[docs] def index_ElSt(self,ElSt,gs_exs=None,include_Ngrs_for_exs=False): """Return the total index of the first state belonging to a certain electronic state. For example for two electronic excited states with each 3 single states defined, the method gives 0 and 3 for the first and second electronic excited state, respectively. Parameters ---------- ElSt : str or :class:`ElectronicState` Electronic state. gs_exs : str, optional specify whether ElSt is a ground or excited state. The default is None so that its type is automatically inferred. include_Ngrs_for_exs : bool, optional specifies whether the total number of ground state levels is added for an excited state. Default is False. Returns ------- int total index of first level state defined in the ElectronicState `ElSt` """ if isinstance(ElSt, str): ElSt = self[ElSt] if gs_exs == None: gs_exs = {True: 'gs', False: 'exs'}[ElSt in self.grstates] ElSts = {'gs':self.grstates, 'exs':self.exstates}[gs_exs] index = sum([ElSts[i].N for i in range(ElSts.index(ElSt))]) if include_Ngrs_for_exs and gs_exs=='exs':#ElSt in self.exstates: index += self.lNum return index
#%% #########################################################################
[docs] class ElectronicState(): def __init__(self,label='',load_constants=None,verbose=True): #: list for storing the pure states which can be added after class initialization self.states = [] #determine the class instance's name from label # X,A,B,.. if state is electronic ground/ excited state self.gs_exs = '' self.label = label self.verbose = verbose # load_constants parameter can be specified but by default it is automatically # imported from the same variable in the Levelsystem class self.load_constants = load_constants self.const_dict = get_constants_dict(load_constants) self.properties_not_calculated = True self._freq = [] self._gfac = [] self.N0 = [] # initial population
[docs] def add(self,**QuNrs): """Add an instance of :class:`State` to this electronic state. Using this method arbitrary quantum states with their respective quantum numbers can be added to construct a certain levelsystem. Calculating all properties of the Levelsystem class does only work if all levels are added first, and then the calculations are done afterwards. Parameters ---------- **QuNrs : kwargs of int or float Quantum numbers of the state, e.g. J=1/2, F=2. Providing the quantum number F is mandatory however. Note ---- The Quantum numbers can be arbitrarily provided (e.g. J,S,N,I,p,..). However, there are requirements for the following quantum numbers: F : float or iterable Total angular momentum typically including the nuclear spin. **This quantum number F must be given.** v : int, optional vibrational state manifold quantum number. This quantum number is mandatory if one want to simulate branchings without selection rules, like for the vibrational states in molecules. mF : float, optional magnetic sublevel quantum number. If it is not provided, then all possible magnetic sublevels (mF=-F,-F+1,...,F) will be added automatically. The absolute value of mF must fulfill the relation :math:`|m_F| \le F`. """ def isnumber(x): return isinstance(x,numbers.Number) if not ('F' in QuNrs): raise KeyError("Key `F` is not provided !") F = QuNrs['F'] if 'mF' in QuNrs: mF = QuNrs['mF'] if np.any(np.abs(mF) > F): raise ValueError('The absolute value of mF must be equal or lower than F') else: mF = None if isnumber(F): if isinstance(mF,(list,np.ndarray)): pass elif mF == None: mF = np.arange(-F,F+1) elif isnumber(mF): mF = [mF] else: raise Exception('Wrong datatype of mF') for mF_i in mF: QuNrs['mF'] = mF_i if self.gs_exs not in QuNrs: QuNrs = {self.gs_exs:self.label,**QuNrs} state = State(is_lossstate=False,**QuNrs) if self.state_exists(state): raise Exception('{} already exists!'.format(state)) if self.properties_not_calculated: self.states.append(state) else: raise Exception('After any property is initialized, one can not add more states') elif isinstance(F, Iterable): for F_i in F: self.add(**{**QuNrs,'F':F_i}) else: raise ValueError('Wrong datatype of parameter F')
[docs] def state_exists(self,state): """Check if a state exists in this electronic state.""" #test if a given state already exists in the ElectronicState and returns boolean for st in self.states: if st == state: return True return False
[docs] def load_states(self,**QuNrs): """Load all states from the corresponding ``.json`` file that match the given ``QuNrs`` as keyword arguments. """ if len(self.const_dict) == 0: raise Exception('There is no constants dictionary available to load states from!') if isinstance(QuNrs.get('v',None),Iterable): for v_i in QuNrs['v']: QuNrs['v'] = v_i self.load_states(**QuNrs) #recursively calling this method with no 'v' Iterable else: list_of_dicts = dict2DF.get_levels(dic=self.const_dict,gs_exs=self.label,**QuNrs) if not list_of_dicts: text = 'No pre-defined states found for electronic state {} with: {}'.format( self.label, ', '.join(['{}={}'.format(key,QuNrs[key]) for key in QuNrs])) if 'v' in QuNrs: # set vibrational quantum number to 0 and try to import constants v_notfound = QuNrs['v'] QuNrs['v'] = 0 list2_of_dicts = dict2DF.get_levels(dic=self.const_dict,gs_exs=self.label,**QuNrs) if list2_of_dicts: for dict_QuNrs in list2_of_dicts: dict_QuNrs_v = {**dict_QuNrs} dict_QuNrs_v['v'] = v_notfound self.add(**dict_QuNrs_v) if self.verbose: warnings.warn(text+'\n...instead the same states as for v=0 were imported!') else: if self.verbose: warnings.warn(text) else: for dict_QuNrs in list_of_dicts: self.add(**dict_QuNrs)
[docs] def draw_levels(self, fig=None, QuNrs_sep=[], level_length=0.8, xlabel_pos='bottom',ylabel=True,yaxis_unit='MHz'): """Draw all levels of the Electronic state sorted by certain Quantum numbers. Parameters ---------- fig : Matplotlib.figure object, optional Figure object into which the axes are drawn. The default is None which corresponds to a default figure. QuNrs_sep : list 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']. level_length : float, optional The length of each level line. 1.0 corresponds to no space between neighboring level lines. The default is 0.8. xlabel_pos : str, optional Position of the xticks and their labels. Can be 'top' or 'bottom'. The default is 'bottom'. ylabel : bool, optional Wheter the ylabel should be drawn onto the y-axis. 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'. Returns ------- coords : dict Dictionary with the coordinates of the single levels in the respective subplots. Two keys: 'axes' objects for every level index, and 'xy' np.array of size 2 for the level coordinates within each subplot. """ # check and verify the Quantum numbers for separation of the subplots QuNrs_sep QuNrs = self[0].QuNrs # the first states Quantum numbers (same as for all others) if len(QuNrs_sep) != 0: for QuNr_sep in QuNrs_sep: if not (QuNr_sep in QuNrs): raise Exception('wrong input parameter') else: QuNrs_sep = [QuNrs[0]] # assign state indices to certain Quantum number tuples, i.e. certain sublpots QuNrs_sets = {} for l,st in enumerate(self.states): QuNr_set = tuple(st.__dict__[QuNr_sep] for QuNr_sep in QuNrs_sep) #what happens with loss state here? if QuNr_set in QuNrs_sets: QuNrs_sets[QuNr_set].append(l) else: QuNrs_sets[QuNr_set] = [l] # calculate frequency shifts of each state #these lines should be an extra method to be used in calc_freq() method!?! self._freq_arr = np.zeros(self.N) DF = self.get_freq() for l,st in enumerate(self.states): if st.is_lossstate: #leave this restriction here?! continue val = Levelsystem.val_state_in_DF(Levelsystem(),st,DF) if val != None: self._freq_arr[l] = val*1e6 self._freq_arr *= 2*pi #make angular frequencies freq_arr = self._freq_arr/2/pi*1e-6 # in MHz # frequency unit for y-axis if isinstance(yaxis_unit,str): if yaxis_unit == 'Gamma' and self.gs_exs == 'gs': warnings.warn("Gamma (natural decay rate) is not defined for an\ electronic ground state. So, 'MHz' is set instead.") ylabel_unit, yaxis_unit = 'MHz', 1.0 elif yaxis_unit == 'Gamma' and self.gs_exs == 'exs': ylabel_unit, yaxis_unit = '$\Gamma$', self.Gamma else: ylabel_unit = yaxis_unit cm2MHz = 299792458.0*100*1e-6 yaxis_unit = {'MHz':1.0, '1/cm':cm2MHz}[yaxis_unit] else: ylabel_unit = '{:.2f} MHz'.format(yaxis_unit) # create figure and subplot axes if fig == None: fig = plt.figure('Levels of {}'.format(self.label)) gs_kw = dict(width_ratios=[len(inds) for inds in QuNrs_sets.values()])#,right=0.9,left=0.1,top=1.0) axs = fig.subplots(1, len(QuNrs_sets), gridspec_kw=gs_kw) if not isinstance(axs,Iterable): axs = [axs] # coordinates: axes objects for every level index, and xy level coords within each subplot coords = dict(axes=[None]*self.N, xy=np.zeros((self.N,2)), yaxis_unit=yaxis_unit) # draw levels and xticks if ylabel: axs[0].set_ylabel('Freq. [{}]'.format(ylabel_unit)) for ax,QuNrs_set,inds in zip(axs,QuNrs_sets.keys(),QuNrs_sets.values()): if QuNrs_sep == [QuNrs[0]]: title = self.label else: title_bracket = ','.join(['{}={}'.format(QuNr_sep,QuNrs_set[i]) for i,QuNr_sep in enumerate(QuNrs_sep)]) title = '{}$({})$'.format(self.label,title_bracket) ax.set_xlabel(title) ax.xaxis.set_label_position(xlabel_pos) ax.xaxis.set_ticks_position(xlabel_pos) for i,ind in enumerate(inds): coords['xy'][ind,:] = i, freq_arr[ind]/yaxis_unit coords['axes'][ind] = ax ax.plot([i-level_length/2,i+level_length/2], [freq_arr[ind]/yaxis_unit]*2, color='k',linestyle='-',linewidth=1.) ax.set_xticks(np.arange(len(inds))) ax.set_xticklabels([str(ind) for ind in inds]) return coords
[docs] def set_init_pops(self, QuNrpops): """Set initial population of the levels as a starting point for the simulations. Parameters ---------- QuNrpops : dict Specifies the population of the levels with certain Quantum numbers. E.g. `QuNrpos={'v=0':0.8, 'v=1,J=0.5':0.1}` implies that all levels that have the Quantum number `v=0` share a total population of 0.80 and the levels with `v=1` and `J=0.5` share 0.10 whereas the populations of the remaining levels are set to zero. So, the sum of all populations specified can't be larger than 1.0. """ N0 = np.zeros(self.N) if sum(QuNrpops.values()) > 1: raise ValueError('Sum of QuNrpops values can not be larger than 1.') inds_used = [] # level indices that are already used for population distribution for QuNrvals, pop in QuNrpops.items(): QuNrvals_ = dict( ( pair.split('=')[0], float(pair.split('=')[1]) ) for pair in QuNrvals.split(',')) inds = [i for i,st in enumerate(self) if st.check_QuNrvals(**QuNrvals_) and i not in inds_used] if len(inds) != 0: N0[inds] = pop/len(inds) inds_used += inds else: warnings.warn(f'No states found for QuNrs {QuNrvals}') self.N0 = N0
#%%
[docs] def get_freq(self): """Return a list of matrices for nicely displaying the wavelengths between the vibrational transitions and the frequencies between hyperfine transitions to conveniently specifying or modifying all participating transitions. These wavelengths and frequencies are loaded from the .json file if available. Otherwise all wavelengths are set to 860e-9 and all other hyperfine frequencies to zero to be adjusted. Returns ------- list of pandas.DataFrame and pandas.Series entries. list of matrices specifying the frequencies of the participating transitions. """ if len(self._freq) != 0: return self._freq out = dict2DF.get_DataFrame(self.const_dict,'HFfreq',gs_exs=self.label) if len(out) != 0: self._freq = out else: self._freq = self.DFzeros_without_mF() return self._freq
[docs] def get_gfac(self): """Return a list of matrices for nicely displaying the g-factors of the ground states and the excited states respectively to conveniently specifying or modifying them. These g-factors are loaded from the .json file if available. Otherwise g-factors are set to 0 to be adjusted. Returns ------- list of pandas.Series entries. list of two matrices for the g-factors of the ground and excited states. """ if len(self._gfac) != 0: return self._gfac out = dict2DF.get_DataFrame(self.const_dict,'gfac',gs_exs=self.label) if len(out) != 0: self._gfac = out else: self._gfac = self.DFzeros_without_mF() return self._gfac
#: hyperfine frequencies freq = property(get_freq) #: g-factors gfac = property(get_gfac) def __getitem__(self,index): #if indeces are integers or slices (e.g. obj[3] or obj[2:4]) if self.N == 0: raise Exception('No states are defined/ included within Electronic State {}!'.format(self.label)) if isinstance(index, (int, slice)): return self.states[index] #if indices are tuples instead (e.g. obj[1,3,2]) return [self.states[i] for i in index] def __delitem__(self,index): """delete states using del system.levels[<normal indexing>], or delete all del system.levels[:]""" if self.properties_not_calculated: print('{} deleted!'.format(self.states[index])) del self.states[index] else: raise Exception('After any property is initialized, one can not delete states anymore') def __str__(self): """__str__ method is called when an object of a class is printed with print(obj)""" for i,st in enumerate(self.states): print('{:2d} {}'.format(i,st)) Name = {'gs':'ground','exs':'excited','ims':'intermediate','':''}[self.gs_exs] return "==> Electronic {} state {} with {:d} states in total".format(Name,self.label,self.N)
[docs] def print_properties(self): """Print all relevant constants and properties of the composed levelsystem in a convenient way to modify them if needed afterwards. """ n=40 print('\n{s:{c}^{n}}'.format(s=self.label,n=n,c='-')) print('\ng-factors:', self.gfac, sep='\n') print('\nfrequencies (in MHz):', self.freq, sep='\n')
[docs] def DFzeros_without_mF(self): #is this important anymore? is required for get functions when no const_dict is available """Return two arrays containing the labels of all levels without the hyperfine magnetic sublevels quantum numbers mF. Returns ------- numpy.ndarray Quantum numbers of all ground levels without the magnetic sublevels mF. numpy.ndarray Quantum numbers of all excited levels without the magnetic sublevels mF. """ self.properties_not_calculated = False QuNrs = self[0].QuNrs_without_mF rows = [] for i,st in enumerate(self.states): if not st.is_lossstate: rows.append(tuple(st.__dict__[QuNr] for QuNr in QuNrs)) Index= pd.MultiIndex.from_frame(pd.DataFrame(set(rows), columns=QuNrs)) return pd.Series(0.0,index=Index)
@property def N(self): '''Return integer as number of defined :class:`State` instances.''' return len(self.states) @property def v_max(self): """Return the maximum quantum number ``v`` available in all state.""" if len(self.states)==0: raise Exception('There are no levels defined! First, levels have to be added to',self.label) if not ('v' in self.states[0].QuNrs): raise Exception("There is no Quantum number 'v' defined for the states of {}".format(self.label)) return max([st.v for st in self.states])
#%%
[docs] class ElectronicGrState(ElectronicState): def __init__(self,*args,**kwargs): super().__init__(*args,**kwargs) self.gs_exs = 'gs'
[docs] def add_lossstate(self,v=None): """Add a :class:`Lossstate` object to the self.entries list of this class only when no loss state is already included. Parameters ---------- v : int, optional all ground state levels with the vibrational quantum number `v` and higher vibrational numbers are represented by a single loss state. Provided the default value None a loss state is added which is lying in the next higher vibrational manifold than the existing one in the already included ground levels. """ if self.has_lossstate == False: if v == None: v = self.v_max+1 QuNrs = { self.gs_exs : self.label, 'v' : v } self.states.append(State(is_lossstate=True,**QuNrs)) else: print('loss state is already included')
[docs] def del_lossstate(self): """Delete a loss state if one is existing in the defined level system.""" if self.has_lossstate == True: index = None for i,st in enumerate(self.states): if st.is_lossstate: index = i break del self.states[index] else: print('There is no loss state included to be deleted')
[docs] def print_remix_matrix(self): #old function! either move to Bfield or delete?? """Print out the magnetic remixing matrix of the ground states by the usage of function :meth:`~.Bfield.magn_remix`. """ from System import Bfield mat = Bfield().get_remix_matrix(self,0) for l1 in range(self.lNum): for l2 in range(self.lNum): if l2 == (self.lNum-1): end = '\n' else: end = '' if (l2+1) % 12 == 0: sep= '|' else: sep = '' print(int(mat[l1,l2]),sep,end=end) if (l1 +1) % 12 == 0: print(self.lNum*2*'_')
@property def has_lossstate(self): """Return True or False depending if a loss state is included in the ground levels.""" for st in self.states: if st.is_lossstate: return True return False
#%%
[docs] class ElectronicExState(ElectronicState): def __init__(self,*args,Gamma=None,**kwargs): # Gamma is additional kwarg here! super().__init__(*args,**kwargs) self.gs_exs = 'exs' #: decay rate :math:`\Gamma` if Gamma: self.Gamma = Gamma else: Gamma = dict2DF.get_DataFrame(self.const_dict,'Gamma',self.label) if len(Gamma) == 0: if self.verbose: text = (f'Gamma must be defined for ElectronicState {self.label}!' ' By default, it is now set to 1 MHz!') warnings.warn(text) self.Gamma = 1.0 #1MHz else: self.Gamma = Gamma.iloc[0]
[docs] def print_properties(self): """Print all relevant constants and properties of the composed levelsystem in a convenient way to modify them if needed afterwards. """ super().print_properties() n=40 print('\nGamma (in MHz):\n{} {}'.format(self.gs_exs, self.label), self.Gamma)
class ElectronicImState(ElectronicGrState,ElectronicExState): def __init__(self,*args,**kwargs): super().__init__(*args,**kwargs) self.gs_exs = 'ims' #%% #########################################################################
[docs] class State: def __init__(self,is_lossstate=False,**QuNrs): """Quantum state which contains all its quantum numbers. An electronic state instance :class:`ElectronicState` includes particularly such states of the same basis Parameters ---------- is_lossstate : bool, optional determines whether the state has the function of a loss state which does not interact with any lasers but the populations can decay into this state. The branching into this state is given by the vibrational branching ratios, so it must contain the vibrational quantum number v. The default is False. **QuNrs : kwargs of int or float Quantum numbers of the state. Tip --- Like for the other classes, you can simply print the state instance:: mystate = State(J=0.5,F=1,mF=0) print(mystate) """ #: boolean variable determining if the state is a loss state self.is_lossstate = is_lossstate #: list of the all quantum numbers self.QuNrs = list(QuNrs.keys()) for QuNr,value in QuNrs.items(): if isinstance(value,float) and value.is_integer(): QuNrs[QuNr] = int(value) self.__dict__.update(QuNrs)
[docs] def copy(self): """Return a deepcopy of this state's instance.""" return deepcopy(self)
def __eq__(self, other): if self.is_lossstate != other.is_lossstate: return False if len(self.QuNrs) != len(other.QuNrs): # return False two_sets = '\n--> state 1: {} <-> state 2: {}'.format(self.QuNrs,other.QuNrs) raise Exception('The two states have different sets of Quantum numbers!'+two_sets) else: for QuNr in self.QuNrs: if self.__dict__[QuNr] != other.__dict__[QuNr]: return False return True
[docs] def is_equal_without_mF(self, other): """Return `True` if a state is equal to an other state neglecting different mF sublevels. Parameters ---------- other : :class:`State` Other state to compare with. """ if self.is_lossstate != other.is_lossstate: return False if len(self.QuNrs) != len(other.QuNrs): # return False raise Exception('The two states have different sets of Quantum numbers!') else: for QuNr in self.QuNrs: if QuNr == 'mF': continue if self.__dict__[QuNr] != other.__dict__[QuNr]: return False return True
[docs] def check_QuNrvals(self,**QuNrvals): """Check if the State object possesses specific Quantum numbers with certain values. Parameters ---------- **QuNrvals : kwargs Keyword arguments, e.g. v=0, F=1. Returns ------- return_bool : bool True or False dependent on whether all Quantum numbers are included in the state. """ return_bool = True for QuNr,val in QuNrvals.items(): if QuNr not in self.QuNrs: return_bool = False break if self.__dict__[QuNr] != val: return_bool = False break return return_bool
def __str__(self): #__str__ method is called when an object of a class is printed with print(obj) if self.is_lossstate == True: str_lossstate = ' (loss state)' else: str_lossstate = '' return 'State : {}{}'.format( ', '.join(['{}={}'.format(QuNr,self.__dict__[QuNr]) for QuNr in self.QuNrs]), str_lossstate) @property def QuNrs_without_mF(self): '''Return all the quantum numbers without mF''' return [QuNr for QuNr in self.QuNrs if QuNr != 'mF']