Source code for MoleCool.spectra

# -*- coding: utf-8 -*-
"""
Module for calculating the eigenenergies and eigenstates of diatomic molecules
exposed to external fields.
Therefore molecular constants which are measured and fitted in spectroscopic
experiments must be provided to build up the effective Hamiltonian terms.
Finally, the transition probabilities between two given electronic
state manifolds can be determined to simulate a complete spetrum.

Example
-------

Example for calculating and plotting a spectrum of 138BaF::
    
   from MoleCool.spectra import ElectronicStateConstants, Molecule, plt
   
   # defining spectroscopic constants
   const_gr = ElectronicStateConstants(const={
       'B_e' : 0.2159,   'D_e' : 1.85e-7,  'gamma' : 0.0027,
       'b_F' : 0.0022,   'c'   : 0.00027,
   })
   const_ex = ElectronicStateConstants(const={
       'B_e' : 0.2117,   'D_e' : 2.0e-7,   'A_e' : 632.2818,
       'p'   : -0.089545,'q'  : -0.0840,
       "g'_l": -0.536,   "g'_L":0.980,
       'T_e' : 11946.31676963,
   })

   # initiating empty Molecule instance and adding electronic states
   # with all quantum states up to a certain quantum number F
   BaF = Molecule(I1 = 0.5, mass = 157, temp = 4)
   BaF.add_electronicstate('X', 2, 'Sigma', const=const_gr)
   BaF.add_electronicstate('A', 2, 'Pi', Gamma=2.84, const=const_ex)
   BaF.build_states(Fmax=8)

   # calculating branching ratios and molecular spectrum
   BaF.calc_branratios()
   E, I = BaF.calc_spectrum(limits=(11627.0, 11632.8))
   
   # plotting
   plt.figure()
   plt.plot(E, I)
   plt.xlabel('Frequency (cm$^{-1}$)')
   plt.ylabel('Intensity (arb. u.)')
    
Tip
---
The instances of all classes :class:`Molecule`,
:class:`ElectronicState`, :class:`Hcasea` and :class:`ElectronicStateConstants`
can be printed::
    
    print(BaF)
    print(BaF.X)
    print(BaF.X.states[0])
    print(const_gr_138) # is the same as: print(BaF.X.const)
"""
from numba import jit
import numpy as np
import pandas as pd
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
import json

from collections.abc import Iterable
from copy import deepcopy
from tqdm import tqdm
import warnings
import matplotlib.pyplot as plt
#: Constant for converting a unit in wavenumbers (cm^-1) into MHz.
cm2MHz = 299792458.0*100*1e-6 #using scipys value for the speed of light
cm2THz = cm2MHz*1e-6

try:
    import pywigxjpf as wig
    def w3j(j_1, j_2, j_3, m_1, m_2, m_3):
        """returns Wigner 3j-symbol with arguments (j_1, j_2, j_3, m_1, m_2, m_3)"""
        return wig.wig3jj(int(2*j_1), int(2*j_2), int(2*j_3), int(2*m_1), int(2*m_2), int(2*m_3))
    def w6j(j_1, j_2, j_3, j_4, j_5, j_6):
        """returns Wigner 6j-symbol with arguments (j_1, j_2, j_3, j_4, j_5, j_6)"""
        return wig.wig6jj(int(2*j_1), int(2*j_2), int(2*j_3), int(2*j_4), int(2*j_5), int(2*j_6))
    max_two_j = 2*120
    # wig.wig_table_init(max_two_j,3)
    wig.wig_table_init(max_two_j,6)
    wig.wig_temp_init(max_two_j)
except ModuleNotFoundError:
[docs] def w3j(j_1, j_2, j_3, m_1, m_2, m_3): """returns Wigner 3j-symbol with arguments (j_1, j_2, j_3, m_1, m_2, m_3)""" return float(wigner_3j(j_1, j_2, j_3, m_1, m_2, m_3))
[docs] def w6j(j_1, j_2, j_3, j_4, j_5, j_6): """returns Wigner 6j-symbol with arguments (j_1, j_2, j_3, j_4, j_5, j_6)""" return float(wigner_6j(j_1, j_2, j_3, j_4, j_5, j_6))
#%% classes
[docs] class Molecule: def __init__(self,I1=0,I2=0,Bfield=0.0,mass=0,load_constants=None, temp=5.0,naturalabund=1.0,label='BaF',verbose=True,mF_states=False): """This class represents a molecule containing all electronic and hyperfine states in order to calculate branching ratios and thus to plot the spectrum. Parameters ---------- I1 : float, optional nuclear spin of one atom of the molecule. The default is 0. I2 : float, optional If the first nuclear spin `I1` is non-zero, a second smaller nuclear spin can be provided via `I2`. The default is 0. Bfield : float, optional strength of a external magnetic field in T. The default is 0.0. mass : float, optional mass of the molecule in atomic mass units. The default is None. load_constants : string, optional if provided the molecular constants are loaded from an external file. The default is None. temp : float, optional temperatur of the molecule in K. The default is 5.0. naturalabund : float, optional natural abundance in the range from 0.0 to 1.0 of different isotopes of a molecule in order to weight the spectra for different isotopes. The default is 1.0. label : string, optional label or name of the molecule. The default is 'BaF'. verbose : bool, optional specifies if additional information and warning should be printed during the calculations. The default is True. """ self.I1 = I1 self.I2 = I2 self.Bfield = Bfield self.mass = mass self.temp = temp self.naturalabund = naturalabund self.label = label self.verbose = verbose self.mF_states = bool(mF_states) self.grstates = [] #empty lists to hold labels of electronic states self.exstates = [] #to be appended later if load_constants: #or better in Electronic state??? #maybe call other function to import molecular constants from a file pass #elements, isotopes, natural abundance, nuclear spin magn moment, .. try: #only works if module could be imported max_two_j = 2*80 # wig.wig_table_init(max_two_j,3) wig.wig_table_init(max_two_j,6) wig.wig_temp_init(max_two_j) except NameError: pass
[docs] def add_electronicstate(self,*args,**kwargs): """adds an electronic state (ground or excited state) as instance of the class :class:`ElectronicState` to this :class:`~Molecule.Molecule`. Parameters ---------- args, kwargs arguments and keyword arguments for the eletronic state (see :class:`ElectronicState` for the required arguments) """ self.__dict__[args[0]] = ElectronicState(*args,**kwargs) self.__dict__[args[0]].I1 = self.I1 self.__dict__[args[0]].I2 = self.I2 self.__dict__[args[0]].Bfield = self.Bfield self.__dict__[args[0]].mF_states = self.mF_states self.__dict__[args[0]].verbose = self.verbose if self.__dict__[args[0]].grex == 'ground state': self.grstates.append(args[0]) else: self.exstates.append(args[0])
[docs] def build_states(self,Fmax,Fmin=None): """Builds the individual Quantum states within all electronic states defined within the Molecule's instance in the range from Fmin to Fmax in units of the total angular momentum quantum number. See :meth:`ElectronicState.build_states` for details. Parameters ---------- Fmax : float maximum angular momentum quantum number. Fmin : float, optional minimum angular momentum quantum number. The default is None. """ for ElSt in [*self.grstates, *self.exstates]: self.__dict__[ElSt].build_states(Fmax=Fmax, Fmin=Fmin)
[docs] def calc_branratios(self,threshold=0.0,include_Boltzmann=True, grstate=None,exstate=None): """ calculates the linestrengths (by evaluating the electric dipole matrix) and energies of all transitions between a ground and excited electronic state in order to obtain the branching ratios weighted by a Boltzmann factor. Note ---- This method creates the attributes (as `numpy.ndarrays`): * ``dipmat`` : electric dipole matrix of the eigenstates in the same order as the eigenstates are stored in the respective electronic states :class:`ElectronicState` which can be printed via the method :meth:`~ElectronicState.get_eigenstates`. * ``E`` : transition energies between the eigenstates in the same order * ``branratios`` : respective branching ratios Parameters ---------- threshold : float, optional all branching ratios below the threshold in the range from 0.0 to 1.0 are set to zero. The default is 0.0. include_Boltzmann : bool, optional determines if the Boltzmann factor is included weighting ground state levels with different energy dependent on the temperature. The default is True. grstate : string, optional label of the electronic excited state which should be used for the calculation of the branching ratios. By default the last added ground state is used. The default is None. exstate : string, optional label of the electronic excited state which should be used for the calculation of the branching ratios. By default the last added excited state is used. The default is None. """ # if grstate is provided use this string as label of the ground state # otherwise use the last added ground state within the Molecule class # -> same for the excited state whose variable name is A here if grstate == None: grstate = self.grstates[-1] if exstate == None: exstate = self.exstates[-1] X, A = self.__dict__[grstate], self.__dict__[exstate] self.branratios_labels = (grstate,exstate) #hamiltonian matrix elements (of the el. dipole Operator) of the pure basis states if self.verbose: print('Calculating linestrengths in pure basis') H_dpure = np.zeros((X.N,A.N)) for ii,pure_l in enumerate(X.states): for jj,pure_u in enumerate(A.states): H_dpure[ii,jj] = H_d(pure_l,pure_u) # if eigenstates are not calculated so far, do this if not X.eigenst_already_calc: X.calc_eigenstates() if not A.eigenst_already_calc: A.calc_eigenstates() # eigenstate dipole matrix via matrix multiplication of eigenstates and pure basis dipole matrix self.dipmat = np.matmul(np.matmul(X.Ev.T,H_dpure),A.Ev) # transition frequency as energy offset E_offset = A.const.electrovibr_energy - X.const.electrovibr_energy self.E = A.Ew[None,:] - X.Ew[:,None] + E_offset if include_Boltzmann and (self.temp>0.0): fac = 1*cm2MHz*1e6*h/k_B E_lowest = np.min(X.Ew) Boltzmannfac= np.exp(-(X.Ew-E_lowest)[:,None]/fac/self.temp) Boltzmannfac/= Boltzmannfac[:,0].sum() #normalizing constant in Boltzmann statistic else: Boltzmannfac = 1.0 self.branratios = self.dipmat**2 * Boltzmannfac # additional degeneracy factor here? #set all branching ratios smaller than the threshold to zero self.branratios = np.where(self.branratios<=threshold*np.max(self.branratios),0.0,self.branratios)
[docs] def calc_spectrum(self,limits=[],sigma=None,plotpoints=40000): """ calculates the spectrum in a certain frequency range using the branching ratios previously calculated in the method :func:`calc_branratios`. The resulting frequency and intensity arrays are not only returned but also stored as variables ``Eplt`` and ``I`` in the :class:`~Molecule.Molecule` instance. The widths of the single transitions are determined by the natural linewidth ``Gamma`` of the respective :class:`~Molecule.ElectronicState` instance (Lorentzian profile) and the temperature (Gaussian profile). The convolution of both profiles is then given by the Voigt profile. Parameters ---------- limits : list, optional defines the frequency limits for the plotting the spectrum as list or tuple of size 2 in units of wavenumbers 1/cm. By default the complete range containing all transitions is chosen. The default is None. sigma : float, optional if desired, one can manually define the width of the Doppler-broadening, which would actually arise due to a non-zero temperature (by default), to a specific value (in cm^-1). The default is None. plotpoints : int, optional integer number specifying the number of intervals for the plotting frequency range, i.e. the plot resolution. The default is 40000. Returns ------- numpy.ndarray frequency array of the spectrum to be plotted. numpy.ndarray intensity array of the spectrum belonging to the frequency array. """ grstate, exstate = self.branratios_labels X, A = self.__dict__[grstate], self.__dict__[exstate] Gamma = A.Gamma if len(limits) == 0: Emin,Emax = np.min(self.E)-0.1 , np.max(self.E)+0.1 else: Emin,Emax = limits Eplt = np.linspace(Emin,Emax,plotpoints) I = np.zeros(Eplt.size) from scipy.special import voigt_profile if sigma == None: sigma = (Emax+Emin)/2 *np.sqrt(8*k_B*self.temp*np.log(2)/(self.mass*u_mass*c**2)) for i in range(X.N): for j in range(A.N): branratio = self.branratios[i,j] if branratio == 0.0: continue I += branratio * voigt_profile(self.E[i,j]-Eplt,sigma,Gamma/2) I *= self.naturalabund self.Eplt,self.I = Eplt,I return self.Eplt, self.I
[docs] def which_eigenstates(self,Emin,Emax): """ searches all eigenstates which are part of the transitions within the specified frequency range. These eigenstates are printed with their branching ratios and transitionenergies. Parameters ---------- Emin : float lower limit of the frequency range. Emax : float upper limit of the frequency range. """ st_l_arr, st_u_arr = np.where( (self.E > Emin) & (self.E < Emax) & (self.branratios > 0.0)) for i in range(st_l_arr.size): st_l,st_u = st_l_arr[i], st_u_arr[i] print('lower eigenstate {:3} & upper eigenstate {:3}, branratio{:5.1f}%, energy {:8f} THz'.format( st_l,st_u,self.branratios[st_l,st_u]*100,self.E[st_l,st_u]*cm2MHz*1e-6))
# for st_l in np.unique(st_l_arr): # print(self.X.Ev[:,st_l])
[docs] def get_dMat_red(self,recalc_branratios=True, Hcasebasis=True, onlygoodQuNrs=True, index_filter=None, **kwargs): """Outputs the reduced electric dipole matrix in a nice readable format. Parameters ---------- recalc_branratios : bool, optional Whether the branching ratios should be calculated again. The default is True. Hcasebasis : bool, optional See :meth:`ElectronicState.get_eigenstates`. The default is True. onlygoodQuNrs : bool, optional See :meth:`ElectronicState.get_eigenstates`. The default is True. index_filter : tuple of dict Tuple or list including two dictionaries, i.e. see two arguments of :func:`multiindex_filter`. Default is no filtering. **kwargs : kwargs Additional keyword arguments (see :meth:`calc_branratios`). By default these keyword arguments are ``dict(threshold=0.0, include_Boltzmann=False)``. Returns ------- pandas.DataFrame reduced electric dipole matrix. """ kwargs_calc_branratios = dict(dict(threshold=0.0,include_Boltzmann=False), **kwargs) if ('dipmat' not in self.__dict__.keys()) or recalc_branratios: self.calc_branratios(**kwargs_calc_branratios) GrSt, ExSt = [self.__dict__[lab] for lab in self.branratios_labels] Eigenbasis = [ElSt.get_eigenstates(Hcasebasis=Hcasebasis, onlygoodQuNrs=onlygoodQuNrs, mixed_states=False) for ElSt in [GrSt,ExSt]] DF = pd.DataFrame(self.dipmat, index=Eigenbasis[0].index, columns=Eigenbasis[1].index) if index_filter: return multiindex_filter(DF, rows=index_filter[0], cols=index_filter[1], drop_level=False) else: return DF
[docs] def get_E(self, index_filter=None, **kwargs): """Similar method as :meth:`get_dMat_red` but with the eigenenergies. Parameters ---------- index_filter : tuple of dict Tuple or list including two dictionaries, i.e. see two arguments of :func:`multiindex_filter`. Default is no filtering. **kwargs : kwargs keyword arguments from :meth:`get_dMat_red`. Returns ------- pandas.DataFrame eigen energies. """ DF = self.get_dMat_red(**kwargs) DF.iloc[:,:] = self.E if index_filter: return multiindex_filter(DF, rows=index_filter[0], cols=index_filter[1], drop_level=False) else: return DF
[docs] def get_branratios(self,normalize=True,include_Boltzmann=False,threshold=0.0, index_filter=None,**kwargs): """Similar method as :meth:`get_dMat_red` but with the branching ratios. Parameters ---------- normalize : bool, optional Whether the columns of the branching ratios should be normalized to 1. The default is True. include_Boltzmann : bool, optional See :meth:`get_dMat_red`. The default is False. threshold : float, optional See :meth:`get_dMat_red`. The default is 0.0. index_filter : tuple of dict Tuple or list including two dictionaries, i.e. see two arguments of :func:`multiindex_filter`. Default is no filtering. **kwargs : kwargs Additional keyword arguments (see :meth:`get_dMat_red`). Returns ------- pandas.DataFrame branching ratios. """ DF = self.get_dMat_red(include_Boltzmann=include_Boltzmann, threshold=threshold,**kwargs) DF.iloc[:,:] = self.branratios if normalize: DF /= DF.sum(axis=0) if index_filter: return multiindex_filter(DF, rows=index_filter[0], cols=index_filter[1], drop_level=False) else: return DF
[docs] def export_OBE_properties(self, gs=None, exs=None, index_filter=({},{}), fname = '', HFfreq_offsets=[0,0], Bmaxs=[1e-4,1e-4], QuNrs_const = ([],[]), QuNrs_var = ([],[]), include_mF=False, vibr_values={}, rounded=None): """Export all the important properties connected to two electronic states to a dictionary in a proper format for the OBE simulation code to import the properties. This dictionary can also directly be saved as a .json file. This method uses the similar function :meth:`ElectronicState.export_OBE_properties`. Parameters ---------- gs : str, optional label of the ground ElectronicState. The default is None meaning that it is automatically chosen. exs : str, optional label of the excited ElectronicState. The default is None meaning that it is automatically chosen. index_filter : tuple(dict), optional to filter the states of interest. The tuple with two dictionaries is used for ground and excited state see :func:`multiindex_filter`. The default is ({},{}). fname : str, optional filename of the constants file. Should end with '.json'. The default is '' meaning that no file will be saved. HFfreq_offsets : list(float), optional offsets for the hyperfine frequencies of the two electronic states. See `HFfreq_offset` in :meth:`ElectronicState.export_OBE_properties`. The default is [0,0]. Bmaxs : list(float), optional Determines how the gfactors of the two ElectronicStates are calculated (see :meth:`ElectronicState.get_gfactors`). The default is [1e-4,1e-4]. QuNrs_const : tuple(list), optional constant Quantum numbers (see :func:`get_QuNr_keyval_pairs`). The default is ([],[]). QuNrs_var : TYPE, optional Variable Quantum numbers (see :meth:`ElectronicState.export_OBE_properties`). The default is ([],[]). include_mF : bool, optional Whether to include mF Quantum numbers on top of the ones from `QuNrs_var`. The default is False. vibr_values : dict, optional Dictionary to include the vibrational properties manually. Includes keys 'vibrbranch', 'vibrfreq', 'QuNrs_rows', 'QuNrs_cols' where the last two keys are optional as they will be generated automatically. The default is {}. rounded : int, optional digit to round the frequencies and gfactors. The default is None meaning that no rounding is applied. Returns ------- dict Dictionary with the properly formatted level and transition properties. """ # checking QuNrs_const and QuNrs_var if len(QuNrs_const[0]) != len(QuNrs_const[1]): raise Exception((f"Length of both QuNrs_const list items {QuNrs_const[0]}" f" and {QuNrs_const[1]} must be the same.")) for QuNrs_var_i in QuNrs_var: if 'mF' in QuNrs_var_i: QuNrs_var_i.remove('mF') # checking vibrational values if ('vibrbranch' in vibr_values) and ('vibrfreq' in vibr_values): for i, key in enumerate(['QuNrs_rows','QuNrs_cols']): if key not in vibr_values: vibr_values.update({key : dict( v=list(range(np.array(vibr_values['vibrfreq']).shape[i])))}) else: vibr_values = {} # checking ground and excited electronic state if not gs: gs = self.grstates[0] if not exs: exs = self.exstates[0] GrState = self.__dict__[gs] ExState = self.__dict__[exs] ##### creating very large dictionary that is saved as json in the end ##### dic0 = NestedDict() dic0.update({'mass' : self.mass, 'level-specific' : dict()}) ##### transition-specific ##### for GrExState in [GrState, ExState]: GrExState.mF_states = bool(include_mF) GrExState.Bfield = 0 GrExState.build_states(GrExState.Fmax) # calculate and round dipole matrix DF = self.get_dMat_red(recalc_branratios=True, Hcasebasis=True, index_filter=index_filter) if rounded != None: DF = DF.round(rounded) # putting the data into the large dictionary: # dic is a sub dict with only the dMat data and the respective rows and cols QuNrs dic = {['dMat_red','dMat'][int(bool(include_mF))] : DF.to_numpy().tolist()} for key, MI, QuNrs_var_i in zip(['QuNrs_rows','QuNrs_cols'], [DF.index, DF.columns], QuNrs_var): if not QuNrs_var_i: QuNrs_var_ = get_unique_multiindex_names(MI) else: QuNrs_var_ = QuNrs_var_i.copy() if include_mF: QuNrs_var_.append('mF') dic[key] = get_QuNrvals_from_multiindex(MI, QuNrs_var_) key_list = ['transition-specific', *get_QuNr_keyval_pairs([self.X,self.A], [DF.index, DF.columns], QuNrs_const)] dic0[key_list] = dic dic0[key_list[:2]].update(vibr_values) ##### level-specific ##### for i, ElState in enumerate([GrState,ExState]): dic0['level-specific'].update( ElState.export_OBE_properties( index_filter=index_filter[i], rounded=rounded, nested_dict=True, QuNrs=QuNrs_var[i].copy(), HFfreq_offset=HFfreq_offsets[i], Bmax=Bmaxs[i], get_QuNr_keyval_pairs_kwargs=dict(include_v=True,QuNrs_names=QuNrs_const[i])) ) # saving as json file if fname and isinstance(fname,str): with open(fname, 'w') as file: json.dump(dic0.copy(), file, sort_keys=False, indent=4) return dic0.copy()
[docs] def plot_fortrat(self, QuNrs, ax=None, limits=None, branratio_TH=1e-2, limits_unit='THz', markers = ['+','x','.','1','2','3','4'], legend=True, xaxis_func=lambda x: x): """Plotting Quantum number values over transition frequencies. It makes sense to combine this plot with the actual spectrum. Parameters ---------- QuNrs : list Quantum number names for which the values are plotted. ax : matplotlib.axis, optional axis where to put the plot. The default is None meaning to use ``plt.gca()``. limits : tuple or list, optional frequency limits which determine the minimum and maximum of the frequency axis. The default is None meaning the whole range is used. branratio_TH : float, optional branching ratio threshold. Transitions with smaller branching ratios are ignored. The default is 1e-2. limits_unit : str, optional Unit of the limits that are provided (['THz','cm-1','MHz']). The default is 'THz'. markers : list(str), optional markers to be used for the plotted points for each Quantum number. The default is ['+','x','.','1','2','3','4']. legend : bool, optional Whether to use a legend. The default is True. xaxis_func : func, optional function to convert the x axis. The default is lambda x: x. """ if not ax: ax = plt.gca() E = self.get_E() brans = self.get_branratios().to_numpy() E_np = E.to_numpy() if not np.all(limits): limits = (self.E.min(), self.E.max()) else: limits = np.array(limits) * {'THz':1/cm2THz, 'cm-1':1, 'MHz':1/cm2MHz}[limits_unit] inds = np.argwhere((E_np>limits[0]) & (E_np<limits[1]) & (brans>branratio_TH)) E_index = E.index.to_frame(index=False) # ground state QuNrs E_cols = E.columns.to_frame(index=False) # excited state QuNrs for i,QuNr in enumerate(QuNrs): if QuNr[-1] == "'": QuNrs_vals = E_cols[QuNr[:-1]].iloc[inds[:,1]] # excited state else: QuNrs_vals = E_index[QuNr].iloc[inds[:,0]] # ground state E_arr = np.array([E_np[ixy[0],ixy[1]] for ixy in inds]) # eigenenergies ax.plot(xaxis_func(E_arr), QuNrs_vals, marker=markers[i], ls='', label=QuNr) ax.set_ylabel('Quant. Nrs.') if legend: ax.legend()
def __str__(self): """prints all general information of the Molecule with its electronic states""" str1 = 'Molecule {}: with the nuclear spins I1 = {}, I2 = {} and mass {} u'.format( self.label,self.I1,self.I2,self.mass) str1+= '\n magnetic field strength: {:.2e}G, temperature: {:.2f}K'.format( self.Bfield*1e4,self.temp) str1+= '\nIncluding the following defined electronic states:' for state in [*self.grstates,*self.exstates]: str1+='\n\n* {}'.format(self.__dict__[state]) return str1
#%%
[docs] class ElectronicStateConstants: #: electronic energy offset constant (this constant equals the transition frequency if no vibrational constants are given. (see :meth:`ElectronicStateConstants.electrovibr_energy`).) const_elec = ['T_e'] #: vibrational constants const_vib = ['w_e','w_e x_e','w_e y_e'] #: rotation constants const_rot = ['B_e','D_e','H_e','alpha_e','gamma_e','beta_e'] #: spin-rotation constants const_sr = ['gamma','gamma_D'] #: spin-orbit constants const_so = ['A_e','alpha_A','A_D'] #: hyperfine constants const_HFS = ['a','b_F','c','d','c_I', 'a_2','b_F_2','c_2','d_2'] #for the second nuclear spin if I2 is non-zero #: electric quadrupol interaction const_eq0Q = ['eq0Q'] #: Lambda-doubling constants const_LD = ['o','p','q']#,'p_D','q_D'] can maybe extracted out of Fortran code #: (magnetic) Zeeman constants. `g_S` and `g'_L` are initially set to 2.002 and 1. respectively. const_Zeeman = ['g_l',"g'_l",'g_S',"g'_L"] #: sum of all constant names const_all = const_elec + const_vib + const_rot + const_sr + const_so \ + const_HFS + const_eq0Q + const_LD + const_Zeeman #: dictionary of all predefined constants which are set to zero initially constants_zero = dict.fromkeys(const_all, 0.0) # standard Zeeman Hamiltonian constants constants_zero['g_S'] = 2.002#3 in Fortran code without the last digit? constants_zero["g'_L"] = 1.0 def __init__(self,const={},unit='1/cm',nu=0,**kwargs): """An instance of this class represents an object which includes all molecular constants for evaluating the effective Hamiltonian yielding the molecular eigenstates and respective eigenenergies. After the provided constants are loaded into the instance they can simply be modified or returned via:: const = ElectronicStateConstants() const['B_e'] = 0.21 print(const['g_S']) Parameters ---------- const : dict, optional dictionary of all constants in wave numbers (1/cm) required for the effective Hamiltonian. See the predefined constant attributes of this class, e.g. :attr:`const_vib` or :attr:`const_rot`, containing all possible names of the constants which are set to zero initially. The values of the provided dictionary `const` are then loaded into the class' new instance. The default is {}. unit : str, optional unit of the provided constants. Can either be '1/cm' for wave numbers or 'MHz' for frequency. The default is '1/cm'. nu : int, optional vibrational quantum number for the vibrational levels. The default is 0. **kwargs : optional the values of the provided dictionary `const` can also be given as normal keyword arguments, e.g. B_e = 0.21, which will overwrite the ones from the dictionary. Tip --- Such instance can nicely export the defined constants as a HTML file (see :meth:`show`) or can be saved with all its properties via the function :func:`~.tools.save_object` and can be loaded later using the function :func:`~.tools.open_object`. Raises ------ KeyError if the dictionary `const` or the keyword arguments `kwargs` contains some values for which the respective key is not defined. """ units = ['1/cm','MHz'] # load all constants from constants_zero into the class' instance # & update non-zero values from the const dictionary self.__dict__.update(self.constants_zero.copy()) const.update(kwargs) # merge provided **kwargs with the const dictionary if not (unit in units): raise ValueError('{} is not a valid unit. Use instead one of: {}'.format(unit,units)) for key,value in const.items(): if not (key in self.const_all): raise KeyError("key '{}' of the input parameter 'const' does not exist".format(key)) if (unit == 'MHz') and (not (key in self.const_Zeeman)): const[key] = value/cm2MHz self.__dict__.update(const) self.nu = nu # not really a constant but it is needed in this class for calculation # of some vibrationally dependent constants
[docs] def show(self,formatting='all',createHTML=False): """returns a `pandas.DataFrame` object which shows the defined constants in a nice format. This table can then be saved as a `.html` file. Parameters ---------- formatting : str, optional Can either be 'all' for printing all constants or 'non-zero' for printing only the non-zero constants. The default is 'all'. createHTML : bool or str, optional if True the returned table with the specific formatting is saved as a HTML file `ElectronicStateConstants.html`. If str the HTML file is saved with this filename. The default is False. Returns ------- DF : pandas.DataFrame table of all constants with further explanatory comments. """ names = ['electronic energy offset','vibration','rotation','spin-rotation','spin-orbit', 'hyperfine','electric quadrupol','Lambda-doubling','Zeeman (no unit)'] const_vars = ['const_elec','const_vib','const_rot','const_sr','const_so', 'const_HFS','const_eq0Q','const_LD','const_Zeeman'] index, values, values2 = [],[],[] precision = 9 precision_old = pd.get_option('display.precision') pd.set_option("display.precision", precision) for arr,name in zip(const_vars,names): for var in self.__class__.__dict__[arr]: index.append([name,var]) values.append(self.__dict__[var]) if arr == 'const_Zeeman': values2.append(self.__dict__[var]) else: values2.append(self.__dict__[var]*cm2MHz) value_arr = np.array([values,values2]) DF = pd.DataFrame(value_arr.T, index=pd.MultiIndex.from_arrays(np.array(index).transpose()), columns=['value (1/cm)','value (MHz)']) if formatting == 'all': pass elif formatting == 'non-zero': indices = np.where(np.array(values) != 0.0)[0] DF = DF.iloc[indices] if createHTML: #render dataframe as html html = DF.to_html(formatters=('{:.6f}'.format,'{:.2f}'.format),justify='right') #write html to file if type(createHTML) == str: text_file = open(createHTML+'.html', "w") else: text_file = open("ElectronicStateConstants.html", "w") text_file.write(html) text_file.close() pd.set_option("display.precision", precision_old) return DF
[docs] def get_isotope_shifted_constants(self,masses,masses_isotope, g_N=0,g_N_isotope=0,inplace=False): """calculates new isotope-shifted constant set. --> see https://www.lfd.uci.edu/~gohlke/molmass/ for precise masses. Parameters ---------- masses : list or ndarray masses of the two atoms of the molecule for which the constants are given, e.g. [137.9052, 18.9984] for 138BaF. masses_isotope : list or ndarray masses of the two atoms of the molecule for which the constants should be calculated inplace : bool determines if the current constants are replaced inplace. Returns ------- ElectronicStateConstants Copy of the current ElectronicStateConstants instance with isotope-shifted constants. """ masses,masses_isotope = np.array(masses), np.array(masses_isotope) if (len(masses) != 2) or (len(masses_isotope) != 2): raise ValueError('masses parameters must be of length 2') # reduced masses ratios rho and rho_el m_e = 1/1836.15267343 def m_mol(masses): return masses[0]*masses[1]/masses.sum() def m_el(masses): return m_e*(masses.sum()-m_e) / masses.sum() rho = np.sqrt( m_mol(masses)/m_mol(masses_isotope) ) rho_el = m_el(masses) / m_el(masses_isotope) # nuclear magnetic moment ratio (nuclear g-factor ratio with spin numbers) if g_N != 0: g_N_ratio = g_N_isotope / g_N else: g_N_ratio = 0 if inplace: const_new = self else: const_new = deepcopy(self) # others const_new['gamma'] = self['gamma']*rho**2 const_new['p'] = self['p']*rho**2 const_new['q'] = self['q']*rho**4 # spin-orbit # A_e has no isotopic shift (doi:10.1006/jmsp.2000.8252) const_new['alpha_A'] = self['alpha_A']*rho const_new['A_D'] = self['A_D']*rho**2 # rotation const_new['B_e'] = self['B_e']*rho**2 # Y_01 Dunham coeffs. const_new['alpha_e'] = self['alpha_e']*rho**3 #-Y_11 const_new['gamma_e'] = self['gamma_e']*rho**4 #-Y_21 const_new['D_e'] = self['D_e']*rho**4 # Y_02 const_new['beta_e'] = self['beta_e']*rho**5 # Y_12 # vibration const_new['w_e'] = self['w_e'] * rho const_new['w_e x_e'] = self['w_e x_e'] * rho**2 const_new['w_e y_e'] = self['w_e y_e'] * rho**3 const_new['T_e'] = self['T_e'] / rho_el #nuclear spin --> these constants must belong to the correct atom in the molecule!? # maybe define atom1 and atom2 in the Molecule instance with respective g_N factors const_new['a'] = self['a'] * g_N_ratio const_new['b_F'] = self['b_F'] * g_N_ratio const_new['c'] = self['c'] * g_N_ratio const_new['d'] = self['d'] * g_N_ratio return const_new
[docs] def to_dict(self,include_vdep_consts=True,exclude_default=False): """Converts the defined constants to a dictionary which can also include calculated values, e.g. :meth:`B_v` and :meth:`D_v`. Parameters ---------- include_vdep_consts : bool, optional whether the dictionary includes also the calculated values, e.g. :meth:`B_v`, :meth:`D_v`, and :meth:`A_v`. The default is True. exclude_default : bool, optional whether the dictionary includes all possible constants (False) or only the constants which differ from the default values (True). The default is False. Returns ------- dic : dict dictionary with all defined constants. """ dic = {key : self.__dict__[key] for key in self.const_all} if exclude_default: for key,value in dic.copy().items(): if value == self.constants_zero[key]: dic.pop(key) if include_vdep_consts: dic['A_v'] = self.A_v dic['B_v'] = self.B_v dic['D_v'] = self.D_v return dic
[docs] def update(self,const,exclude_default=True,use_consts=[]): """Update the constants defined in the current constants instance (:class:`ElectronicStateConstants`) with the ones from another one. Parameters ---------- const : :class:`ElectronicStateConstants` the instance including the constants values for updating the current constants instance. exclude_default : bool, optional Same keyword argument as in :meth:`to_dict`. The default is True. use_consts : list, optional If it is desired to only update a certain set of constants, one can specify these constants in a list of strings, e.g. ['b_F','c']. The default is []. """ # convert constants to dictionary dic dic = const.to_dict(include_vdep_consts=False,exclude_default=exclude_default) # if use_consts does not contain any elements, use all constants in dic if len(use_consts) == 0: use_consts = list(dic.keys()) for key in use_consts: self[key] = dic[key] #update constants from dic
[docs] def DunhamCoeffs(self): """Handling the Dunham coefficients. Under construction..""" pass
def __setitem__(self, index, value): if not (index in self.const_all): raise KeyError('Only the keys specified in <const_all> can be set!') self.__dict__[index] = value def __getitem__(self, index): if not (index in self.const_all): raise KeyError('Only the values of the keys specified in <const_all> can be called!') return self.__dict__[index] def __str__(self): return self.show(formatting='non-zero').to_string() def __repr__(self): return repr(self.show(formatting='non-zero')) def _repr_html_(self): return self.show(formatting='non-zero')._repr_html_() @property def b(self): return self['b_F'] - self['c']/3 @property def b_2(self): return self['b_F_2'] - self['c_2']/3 @property def A_v(self): """returns the vibrational-state-dependent spin-orbit constant `A_v`. :math:`A_v = A_e + \\alpha_A (v + 1/2)`. """ return self.A_e + self.alpha_A*(self.nu+0.5) @property def B_v(self): """returns the vibrational-state-dependent rotational constant `B_v`. :math:`B_v = B_e - \\alpha_e (v + 1/2) + \gamma_e (v + 1/2)^2`. """ return self.B_e - self.alpha_e*(self.nu+0.5) + self.gamma_e*(self.nu+0.5)**2 @property def D_v(self): """returns the vibrational-state-dependent rotational constant `D_v`. :math:`D_v = D_e + \\beta_e (v + 1/2)`. """ return self.D_e + self.beta_e*(self.nu+0.5) @property def electrovibr_energy(self): """returns the sum of the electronic and vibrational energy. :math:`E = T_e + \\omega_e (v + 1/2) - \\omega_e \\chi_e (v+1/2)^2 + \\omega_e y_e (v+1/2)^3`. """ nu = self.nu w1,w2,w3 = self.w_e, self.__dict__['w_e x_e'], self.__dict__['w_e y_e'] return self.T_e + w1*(nu+0.5) - w2*(nu+0.5)**2 + w3*(nu+0.5)**3
#%%
[docs] class ElectronicState: def __init__(self,label,Smultipl,L,Hcase='a',nu=0,const={},Gamma=None): """This class represents an electronic ground or excited state manifold which are part of the molecular level structure. After an electronic state is created with certain constants of the effective Hamiltonian all the single hyperfine states can be added in order to calculate the eigenstates and eigenenergies (see :meth:`calc_eigenstates` and :meth:`get_eigenstates`). Parameters ---------- label : str label of the electronic state: the first character of this string has to be specified as 'X' for a ground state or as 'A', 'B', 'C', ... for an excited state. Smultipl : int spin mulitplicity, i.e. :math:`2S+1`. L : int orbital angular momentum which defines the type of the electronic state as well as the absolute value of the quantum number Lambda. Can either be provided as integer :math:`0,1,2,3,...` or as the respective Greek symbol :math:`\Sigma,\Pi,\Delta,\Phi,...`. Hcase : str, optional Hund's case describing the states within the electronic state manifold. Possible values: 'a' for pure Hund's case a, 'a_p' for parity conserved case a, and 'b' for case b. The default is 'a'. nu : int, optional vibrational quantum number for the vibrational levels. The default is 0. const : dict or :class:`ElectronicStateConstants`, optional dictionary of all constants in wave numbers (1/cm) required for the effective Hamiltonian or directly an instance of the class :class:`ElectronicStateConstants` (see for further documentation). During initialization of the class :class:`ElectronicState` an attribute ``const`` as an instance of :class:`ElectronicStateConstants` is created. The default is {}. Gamma : float, optional if the electronic state has the function of an excited state, the natural linewidth :math:`\Gamma` must be given for generating a spectrum. The natural linewidth Gamma must be given in MHz as an non-angular frequency (i.e. Gamma = 1/(2*pi*lifetime)*1e-6. The default is None. """ #determine from label X,A,B,.. if state is electronic ground/ excited state if label[0] == 'X': self.grex = 'ground state' elif 'ABCDEFGHIJKLMN'.find(label[0]) >= 0: self.grex = 'excited state' else: raise ValueError('Please provide X,A,B,C,D,.. as first character of `label` for the electronic ground or excited states') self.label = label # spin multiplicity and spin self.Smultipl = int(Smultipl) self.S = (self.Smultipl - 1)/2 # spin-orbital quantum number. Either specified as integer or as Greek name. if isinstance(L,(float,int)): self.L = int(L) else: self.L = {'Sigma':0, 'Pi':1, 'Delta':2, 'Phi':3, 'Gamma':4}[L] # Hund's case if not Hcase in ['a','a_p','b','b_betaS']: raise Exception('Provided Hunds case {} is not valid!'.format(Hcase)) self.Hcase = Hcase #constants if type(const) == ElectronicStateConstants: self.const = const self.const.nu = nu else: self.const = ElectronicStateConstants(const=const,nu=nu) if Gamma: self.Gamma = Gamma/cm2MHz #in MHz (without 2pi) and then to cm^-1 else: self.Gamma = None # vibrational level self.__nu = nu #have to be called after init of self.const #self.parity or symmetry for Sigma states --> + or - if self.S > 0: self.shell = 'open' else: self.shell = 'closed' #: list for storing the pure states which can be added after class initialization self.states = [] self.states_Hcase = [] # boolean variable determining if eigenstates are already calculated self.eigenst_already_calc = False
[docs] def get_energy_casea(self,J,Omega,p): """calculate the energy of the electronic state as Hund's case (a). The energy is evaluated with an approximate analytic expression and returned in units of wave numbers (1/cm). Parameters ---------- J : float total angular momentum quantum number without nuclear spin. Omega : float absolute value of the quantum number :math:`\Omega = \Lambda + \Sigma`. p : int parity of the excited state. Either +1 or -1. Returns ------- E : float energy of the state in wave numbers (1/cm). """ cs = self.const nu = self.nu A_v, B_v, D_v = cs.A_v, cs.B_v, cs.D_v if Omega > self.L: pm = +1 Ldoubl = 0#cs['q'] * (J+0.5)**2 warnings.warn("Lambda doubling not implemented for Omega > Lambda \ and is set to zero for now") else: pm = -1 Ldoubl = (cs['p']+2*cs['q']) * (J+0.5) E = cs.electrovibr_energy + pm*A_v*self.L*self.S \ + (B_v *(J*(J+1) + self.S*(self.S+1) - Omega**2 - self.S**2) - D_v *(J*(J+1))**2) \ + p*phs(J+0.5) * Ldoubl/2 return E
[docs] def get_energy_caseb(self,N,sr): """calculate the energy of the electronic state as Hund's case (b). The energy is evaluated with an approximate analytic expression and returned in units of wave numbers (1/cm). Parameters ---------- N : int rotational quantum number N. sr : int Can be either +1 or -1 for the two energy states which are shifted up or down in energy respectively due to the spin-rotation interaction. Returns ------- E : float energy of the state in wave numbers (1/cm). """ cs = self.const nu = self.nu B_v, D_v = cs.B_v, cs.D_v if sr == +1: sr = 0.5*cs['gamma']*N elif sr == -1: sr = 0.5*cs['gamma']*(-1*(N+1)) else: raise ValueError('variable <sr> can only take the values +1 or -1') E = cs.electrovibr_energy \ + B_v *N*(N+1) - D_v *(N*(N+1))**2 + sr return E
[docs] def build_states(self,Fmax,Fmin=None): """ builds all the states within an electronic state manifold in the range from Fmin to Fmax in units of the total angular momentum quantum number. These states are stored in the variable `states` in the instance of this class :class:`ElectronicState`. Every time this method is called potentially already included states are deleted. Parameters ---------- Fmax : float upper limit of the total angular momentum quantum number to which all states are added into this instance of :class:`ElectronicState`. Fmin : float, optional respective lower limit of the total ang. mom. quantum number. By default this number is set to the smallest possible number which is either 0 or 0.5. The default is None. Note ---- If `Fmax` or `Fmin` is not properly specified (e.g. when F can only take integer values and Fmax=3.5 is provided), it is adjusted to good values instead. """ #for fermions Fmin should be 1/2 due to the second spin --> only 1/2,3/2,5/2,.. possible QNrsum = self.S + self.I1 + self.I2 # Lambda and rotational number are leaved out since they are only integers if isint(QNrsum): Fmin0 = 0 else: Fmin0 = 0.5 if (Fmin==None) or (Fmin < Fmin0): Fmin = Fmin0 if (int(2*Fmin+0.1)%2 != int(2*Fmin0+0.1)%2): Fmin += 0.5 self.Fmin,self.Fmax = Fmin, np.arange(Fmin,Fmax+1e-3,1)[-1] self.states = [] #reset states self.eigenst_already_calc = False if 'Ew' in self.__dict__: del self.Ew del self.Ev #___for hunds case a: first case: one nuclear spin; second case: two nuclear spins if (self.I1 > 0) and (self.I2 == 0): for F in np.arange(Fmin,Fmax+1e-3,1): for Si in np.unique([-self.S,self.S]): for L in np.unique([-self.L,self.L]): Om = L + Si for J in addJ(F, self.I1): if J < (abs(Om)-1e-3): continue if (self.Bfield != 0.0) or self.mF_states: for mF in np.arange(-F,F+1e-3,1): self.states.append(Hcasea(L=L,Si=Si,Om=Om,J=J,F=F,mF=mF, S=self.S,I1=self.I1,I2=self.I2)) else: self.states.append(Hcasea(L=L,Si=Si,Om=Om,J=J,F=F, S=self.S,I1=self.I1,I2=self.I2)) elif (self.I1 > 0) and (self.I2 > 0): for F in np.arange(Fmin,Fmax+1e-3,1): for F1 in addJ(F,self.I2): for Si in np.unique([-self.S,self.S]): for L in np.unique([-self.L,self.L]): Om = L + Si for J in addJ(F1, self.I1): if J < (abs(Om)-1e-3): continue if (self.Bfield != 0.0) or self.mF_states: for mF in np.arange(-F,F+1e-3,1): self.states.append(Hcasea(L=L,Si=Si,Om=Om,J=J,F1=F1,F=F,mF=mF, S=self.S,I1=self.I1,I2=self.I2)) else: self.states.append(Hcasea(L=L,Si=Si,Om=Om,J=J,F1=F1,F=F, S=self.S,I1=self.I1,I2=self.I2))
[docs] def calc_eigenstates(self): """ calculates the matrix elements of the various terms of the total Hamiltonian and determines the eigenvalues and eigenstates which are sorted by energy and stored in the variables ``Ew`` and ``Ev`` in the current instace of the class :class:`ElectronicState`. The eigenstates can be nicely printed via :meth:`get_eigenstates`. Warning ------- The total diagonalized Hamiltonian excludes the electronic and vibrational constants since the vibrational motion can be decoupled completely from the smaller interactions like rotation, hyperfine, spin-orbit,... . So, the electronic and vibrational part of the molecular eigenenergies are not included in the obtained eigenenergies of this function ``Ew`` but they can simply be added as an energy offset (what is done in the method :meth:`Molecule.calc_branratios`). """ if self.verbose: print('Calc Hamiltonian for {} electronic state'.format(self.label),end=' ') H = np.zeros((self.N,self.N)) const = self.const.to_dict() for i,st_i in enumerate(self.states): for j in range(i,len(self.states)): st_j = self.states[j] H[i,j] = H_tot(st_i,st_j,const) if self.Bfield != 0.0: H[i,j] += H_Zeeman(st_i,st_j,const,Bfield=self.Bfield) #next line can be commented out since H is symmetric and therefore #only the upper/lower triangular part of the matrix has to be #used to the calculation of the eigenstates & eigenvalues. H[j,i] = H[i,j] self.Ham = H #only temporal variable if self.verbose: print('..diagonalize it'.format(self.label)) if 'mF' in self.states[0].__dict__: # store indices of pure states in dictionary ordered by the mF number mF_indices = {key : [] for key in np.arange(-self.Fmax,self.Fmax+0.1,1)} for i,st in enumerate(self.states): mF_indices[st.mF].append(i) #test if all states are included somewhere in the dictionary count = 0 for key,value in mF_indices.items(): count +=len(value) if count != self.N: print('WARNING: Not all mF states are included in the dictionary') #diagonalize only the mF block matrices Ew, Ev = np.zeros(self.N),np.zeros((self.N,self.N)) for mF,indices in mF_indices.items(): Ew[indices], Ev[np.ix_(indices,indices)] = np.linalg.eigh(H[np.ix_(indices,indices)]) else: try: Ew, Ev = np.linalg.eigh(H) #do not use np.linalg.eigh since it yields wrong eigenstates! ?? except np.linalg.LinAlgError as error: print('eigenstate/value calculation did not converge for the first time!!!') Ew, Ev = np.linalg.eig(H) # sort eigenvalues and eigenstates by energy indices = np.argsort(Ew) self.Ew = Ew[indices] self.Ev = Ev[:,indices] self.eigenst_already_calc = True
[docs] def get_eigenstates(self,rounded=None,onlygoodQuNrs=True,createHTML=False, Hcasebasis=True,mixed_states=False): """ returns the sorted eigenenergies and respective eigenstates determined by the method :func:`calc_eigenstates` in a nice format via the datatype `pandas.DataFrame` in order to be printed. Parameters ---------- rounded : int, optional rounded to which the values of the eigenstates are rounded. The default is 4. onlygoodQuNrs : bool, optional specifies if only the good Quantum numbers are included for getting a better overview of the printed DataFrame. The default is True. createHTML : bool or str, optional if True a Html file `eigenstates.html` with the DataFrame is generated for a better view of the eigenstates. If a string is given, the file will be saved as the respective filename. The default is False. Hcasebasis : bool, optional If the basis of the eigenenergies is given in pure Hund's case a states (False) or in the specified Hund's case basis. The default is True. Returns ------- pandas.DataFrame the rounded DataFrame comprising the eigenvalues and eigenstates to be nicely printed """ if not self.eigenst_already_calc: self.calc_eigenstates() col_arr = [np.arange(len(self.Ew)),self.Ew] if Hcasebasis and (self.Hcase != 'a'): DF1 = pd.DataFrame(np.matmul(self.calc_basis_change(),self.Ev), index=pd.MultiIndex.from_frame( self.get_states_as_DF(onlygoodQuNrs=onlygoodQuNrs,Hcasebasis=True)), columns=pd.MultiIndex.from_arrays(col_arr,names=('eigenvector i','eigenvalue'))) else: DF1 = pd.DataFrame(self.Ev, index=pd.MultiIndex.from_frame( self.get_states_as_DF(onlygoodQuNrs=onlygoodQuNrs)), columns=pd.MultiIndex.from_arrays(col_arr,names=('eigenvector i','eigenvalue'))) if Hcasebasis and (DF1**2).max(axis=0).min() < 0.75: warnings.warn("Hcasebasis doesn't seem to be a good choice as the eigenstates are not diagonal (<75%).") if not mixed_states: if not Hcasebasis: DF1 = pd.DataFrame(self.Ew, columns=['eigenvalue'], index=np.arange(len(DF1.index)))#DF1.index.to_frame().iloc[argmax_arr].index) else: argmax_arr = [] for (i,Ew),Ev in DF1.items(): for argsort_ind in Ev.abs().argsort()[::-1]: if argsort_ind not in argmax_arr: argmax_arr.append( argsort_ind ) break DF1 = pd.DataFrame(self.Ew, columns=['eigenvalue'], index=DF1.index.to_frame().iloc[argmax_arr].index) else: DF1 = DF1.sort_index() if rounded: DF1 = DF1.round(rounded) if createHTML: #render dataframe as html html = DF1.to_html() #write html to file if isinstance(createHTML,str): filename = createHTML else: filename = "eigenstates" text_file = open(filename+".html", "w") text_file.write(html) text_file.close() else: return DF1
[docs] def get_states_as_DF(self,onlygoodQuNrs=False,Hcasebasis=False): """returns the states included in the instance :class:`ElectronicState` in a nice format via the datatype `pandas.DataFrame` in order to be printed. But at first any states have to be added via :func:`build_states`. Parameters ---------- onlygoodQuNrs : bool, optional specifies if only the good Quantum numbers are included for getting a better overview of the printed DataFrame. The default is False. Hcasebasis : bool, optional specifies whether the pure states or the states in the respective Hund's case basis are shown. Returns ------- pandas.DataFrame the rounded DataFrame comprising the pure states to be nicely printed """ if Hcasebasis: if len(self.states_Hcase) == 0: self.calc_basis_change() states = self.states_Hcase else: states = self.states return pd.concat([st.DF(onlygoodQuNrs) for st in states], ignore_index=True)
[docs] def calc_basis_change(self): '''Calculates the Hund's case states from the pure case a states and determines the transformation matrix from the pure state basis to another Hund's case basis. Returns ------- np.ndarray Transformation matrix from pure to Hund's case basis. ''' self.states_Hcase = [] self.HcaseBasis = np.zeros((self.N,self.N)) for i_p,st_p in enumerate(self.states): lincom = st_p.to_Hcase(Hcase=self.Hcase) for prefac,st_H in zip(lincom['prefacs'],lincom['states']): if not st_H in self.states_Hcase: i_H = len(self.states_Hcase) self.states_Hcase.append(st_H) else: i_H = self.states_Hcase.index(st_H) self.HcaseBasis[i_H,i_p] = prefac return self.HcaseBasis
[docs] def get_gfactors(self, Bmax=1e-4): """calculates the mixed g-factors for every hyperfine level eigenstate. These g-factors are returned as an array with the same order as the eigenstates for zero magnetic field which can be printed via :meth:`get_eigenstates`. The gfactor pd.DataFrame is also stored in the attribute ``gfactors``. Parameters ---------- Bmax : float, optional maximum magnetic field strength in T to which the mixed g-factors are calculated. This value should be in the small region where the Zeeman shifts possess only linear behavior. The default is 1e-4. Returns ------- pandas.DataFrame array containing the mixed g-factors ordered by energy of the eigenstates. """ mu_B = physical_constants['Bohr magneton'][0] oldBfield = self.Bfield mF_states_old = self.mF_states # calculate unperturbed eigenvalues without Bfield: self.mF_states = False self.Bfield = 0.0 self.build_states(self.Fmax, self.Fmin) # DataFrames: Ew = self.get_eigenstates() # eigenvalues without mF states gfactors = Ew.copy()*0.0 # gfactors without mF states MI = Ew.index.to_frame() # multiindex without mF states # calculate new eigenvalues with non-zero Bfield self.Bfield = Bmax self.build_states(self.Fmax, self.Fmin) # DataFrame: eigenvalues with mFs Ew_B = self.get_eigenstates() for i, (tmp, row) in enumerate(MI.iterrows()): # subgroup DataFrame with all mF state eigenvalues beloning to certain F state. Ew_mF = Ew_B.xs(tuple(row.values), level=tuple(row.keys()), axis=0) # a/b := ( eigenvals(B=Bmax) - eigenvals(B=0) ) / (mF_values * mu_B * Bfield) a = Ew_mF.to_numpy()[:,0] - Ew.iloc[i].mean() b = Ew_mF.index.to_frame().to_numpy()[:,0] * mu_B * Bmax / (cm2MHz * 1e6 * h) gfac_all= np.divide(a, b, out=np.empty(a.shape)*np.nan, where=b!=0) # ignore dividing by 0 if not np.isnan(gfac_all).all(): # only for F != 0 gfactors.iloc[i] = np.nanmean(gfac_all) self.gfactors = gfactors # set previous values for Bfield and mF_states and build new states self.Bfield = oldBfield self.mF_states = mF_states_old self.build_states(Fmax=self.Fmax, Fmin=self.Fmin) return self.gfactors
[docs] def plot_Zeeman(self,Bfield): """plots the Zeeman-splitted levels versus a magnetic field. In the plot the eigenvalues are sorted such that the energy crossings between different magnetic hyperfine levels are assigned to the right curves using the function :func:`eigensort`. Parameters ---------- Bfield : array-type or float When `Bfield` is of array-type the eigenenergies are calculated for every single value. Otherwise, if `Bfield` is a float, the Zeeman Hamiltonian is evaluated for 20 values from 0.0 to `Bfield`. This input parameter has to be provided in units of Tesla. """ if not isinstance(Bfield, Iterable): Bfield = np.linspace(0,Bfield,20) #create simple array with maximum value Bfield if Bfield[0] == 0.0: # prevent the first Bfield value to be zero Bfield[0] = Bfield[1]*1e-4 # don't change states and magnetic field settings in self ElSt = deepcopy(self) ElSt.mF_states = True ElSt.build_states(ElSt.Fmax, ElSt.Fmin) Ew_B_arr = np.zeros((len(Bfield),ElSt.N)) for k,B in enumerate(Bfield): ElSt.Bfield = B ElSt.calc_eigenstates() Ew_B_arr[k,:] = ElSt.Ew # plotting Ew_B_arr = eigensort(Bfield, Ew_B_arr) plt.figure() for i in range(Ew_B_arr.shape[1]): plt.plot(Bfield*1e4, Ew_B_arr[:,i], '-') plt.xlabel('Magnetic field (G)') plt.ylabel('Energy (cm$^{-1}$)') return plt.gca()
[docs] def export_OBE_properties(self, index_filter={}, rounded=None, QuNrs=[], HFfreq_offset=0, Bmax=1e-4, nested_dict=False, get_QuNr_keyval_pairs_kwargs={}): """Export all the important properties connected to a single electronic state to a dictionary in a proper format for the OBE simulation code to import the properties from the corresponding json file. This method is e.g. used in the similar function :meth:`Molecule.export_OBE_properties` from the :class:`Molecule.Molecule` class to also save the whole json file with all properties. Parameters ---------- index_filter : dict, optional to filter the states of interest. See `rows` argument of :func:`multiindex_filter`. The default is {}. rounded : int, optional digit to round the frequencies and gfactors. The default is None meaning that no rounding is applied. QuNrs : list(str), optional Which Quantum numbers should be exported into the output dictionary. The default is [] meaning that only the necessary unique Quantum numbers are used (see :func:`get_unique_multiindex_names`). HFfreq_offset : float, optional applying an offset to the whole array of hyperfine frequencies (MHz) whose lowest eigenvalue is always normalized to 0. The default is 0. Bmax : float, optional Determines how the gfactors are calculated (see :meth:`get_gfactors`). The default is 1e-4. nested_dict : bool, optional Whether the raw dictionary with the data or properties should be nested into other dictionaries required by the json file. See :func:`get_QuNr_keyval_pairs`. The default is False. get_QuNr_keyval_pairs_kwargs : kwargs, optional keyword arguments for :func:`get_QuNr_keyval_pairs`. The default is {}. Returns ------- dict Dictionary with the properly formatted ElectronicState level properties. """ def DF2list(df): # round and convert DataFrame to list for saving in json dict if rounded != None: df = df.round(rounded) return list(df.to_numpy()[:,0]) old_values = {key:self.__dict__[key] for key in ['mF_states', 'Bfield']} self.mF_states = False self.Bfield = 0 self.build_states(self.Fmax) Ew = multiindex_filter(self.get_eigenstates(), rows=index_filter, drop_level=False) Ew = (Ew-Ew.min())*cm2MHz + HFfreq_offset gfac = multiindex_filter(self.get_gfactors(Bmax), rows=index_filter, drop_level=False) if not QuNrs: QuNrs = get_unique_multiindex_names(Ew.index) OBE_props = dict(gfac = DF2list(gfac), HFfreq = DF2list(Ew), QuNrs = get_QuNrvals_from_multiindex(Ew.index, QuNrs)) if nested_dict: key_list = get_QuNr_keyval_pairs(self, Ew.index, **get_QuNr_keyval_pairs_kwargs) OBE_props_ = OBE_props.copy() OBE_props = NestedDict() OBE_props[key_list] = OBE_props_ if self.grex == 'excited state': OBE_props[key_list[0]].update(dict(Gamma=self.Gamma*cm2MHz)) self.__dict__.update(old_values) self.build_states(self.Fmax) return OBE_props
def __str__(self): """prints all general information of the ElectronicState""" Lnames = ['Sigma','Pi','Delta','Phi','Gamma'] linewidth = '' if self.Gamma: linewidth += '\n - linewidth: 2 pi * {:.2f} MHz'.format(self.Gamma*cm2MHz) return '{:13s} {}(^{}){}:\n - Hunds case {}\n - {} shell electronic state'.format( self.grex,self.label,self.Smultipl,Lnames[self.L],self.Hcase,self.shell) \ + linewidth + '\n --> includes {} states'.format(self.N) @property def nu(self): """vibrational quantum number for the vibrational levels. Can be called and changed. """ return self.__nu # maybe check if self.const.nu is different? @nu.setter def nu(self,val): if (val == self.const.nu) & (val == self.__nu): return if not isint(val): raise ValueError('given value {} is no integer!'.format(val)) self.const.nu = val self.__nu = val self.eigenst_already_calc = False @property def N(self): """returns the number of states in the current instance of :class:`ElectronicState`. Returns ------- int number of states """ return len(self.states)
#%%
[docs] class QuState: goodQuNrs = [] description = 'State without certain Hunds case' def __init__(self,**kwargs): """Instance of the class represents a molecular state as Hund's case a. Parameters ---------- **kwargs : float quantum numbers of the Hund's case a. """ # self.L,self.Si,self.Om,self.J,self.F,self.I1 = L,Si,Om,J,F,I1 self.__dict__.update(kwargs) self.QuNrs = list(kwargs.keys()) # convert to list since otherwise an error arises with pickle # check if all quantum numbers except L and Si are positive!!? self.linearcombi = {}
[docs] def DF(self,onlygoodQuNrs=False): """return a DataFrame as nice representation of the state with all quantum numbers. Parameters ---------- onlygoodQuNrs : bool, optional if all quantum numbers or only the good ones are shown. The default is False. Returns ------- pandas.DataFrame DataFrame showing the quantum numbers. """ if onlygoodQuNrs: QuNrs = self.goodQuNrs[:] if self.I2 != 0: QuNrs += ['F1'] QuNrs += ['F'] if 'mF' in self.QuNrs: QuNrs += ['mF'] else: QuNrs = self.QuNrs return pd.DataFrame([[self.__dict__[Nr] for Nr in QuNrs]],columns=QuNrs)
def __str__(self): return self.DF().to_string() + '\n' + self.description def __eq__(self, other): if len(self.QuNrs) != len(other.QuNrs): return False else: for QuNr in self.QuNrs: if self.__dict__[QuNr] != other.__dict__[QuNr]: return False return True
[docs] def QuNrs_default(self): QuNrs_def = {} for QuNr in ['F','F1','mF','S','I1','I2']: if QuNr in self.QuNrs: QuNrs_def[QuNr] = self.__dict__[QuNr] return QuNrs_def
[docs] class Hcasea(QuState): #: good QuNrs for a pure Hund's case a goodQuNrs = ['L','Si','Om','J'] #different for Fermions and bosons? #difference between L and La (Lambda)? description = 'Hunds case a (pure state)'
[docs] def to_Hcase(self,Hcase='b',printing=False): """transforms the pure Hund's case a state into another Hund's case basis. Parameters ---------- Hcase : str, optional Hund's case basis for transformation. The default is 'b'. printing : bool, optional if the linear combination of the states of the new basis is printed. The default is False. Returns ------- dict linear combination of the states of the new Hund's case basis as a dictionary with the keys `prefacs` and `states`. """ QuNrs_def = self.QuNrs_default() if not Hcase in self.linearcombi: states, prefacs = [], [] if Hcase == 'a': states.append(self) prefacs.append(+1) elif Hcase == 'a_p': for P,prefac in zip([+1,-1], np.array([+1,np.sign(self.L)])/np.sqrt(2)): if prefac == 0: continue if np.sign(self.L) < 0: prefac *= (-1)**(self.J-self.S) # maybe additional -1 factor for reflection symmetry +- states.append(Hcasea_p(L=abs(self.L),P=P,Om=abs(self.Om),J=self.J,**QuNrs_def)) prefacs.append(prefac) elif Hcase == 'b' or Hcase == 'b_betaS': for N in addJ(self.S,self.J): prefac = np.sqrt(2*N+1)*phs(self.J+self.Om)*w3j(self.S,N,self.J,self.Si,self.L,-self.Om) if prefac == 0: continue st = Hcaseb(L=self.L,N=N,J=self.J,**QuNrs_def) if Hcase == 'b_betaS': lincom_ = st.to_Hcase(Hcase='b_betaS') for prefac_,st_ in zip(lincom_['prefacs'],lincom_['states']): if st_ in states: prefacs[states.index(st_)] += prefac_*prefac else: states.append(st_) prefacs.append(prefac_*prefac) else: states.append(st) prefacs.append(prefac) else: # Transformation for the fermions is missing?! raise ValueError("Invalid string value '{}' for `Hcase` parameter".format(Hcase)) self.linearcombi[Hcase] = dict(states=states, prefacs=prefacs) if printing: for prefac, state in zip(self.linearcombi[Hcase]['prefacs'], self.linearcombi[Hcase]['states']): print('{:+.4f} *\n{}'.format(prefac,state,end='\n')) return self.linearcombi[Hcase]
[docs] class Hcasea_p(QuState): #: good QuNrs for a Hund's case a (parity conserved) goodQuNrs = ['P','Om','J'] #different for Fermions and bosons? description = 'Hunds case a (parity conserved)'
[docs] class Hcaseb(QuState): #: good QuNrs for a pure Hund's case b goodQuNrs = ['L','N','J'] #different for Fermions and bosons? description = 'Hunds case b_betaJ'
[docs] def to_Hcase(self,Hcase='b_betaS',printing=False): """transforms the pure Hund's case a state into another Hund's case basis. Parameters ---------- Hcase : str, optional Hund's case basis for transformation. The default is 'b_betaS'. printing : bool, optional if the linear combination of the states of the new basis is printed. The default is False. Returns ------- dict linear combination of the states of the new Hund's case basis as a dictionary with the keys `prefacs` and `states`. """ QuNrs_def = self.QuNrs_default() if self.I2 != 0: F = self.F1 else: F = self.F if not Hcase in self.linearcombi: states, prefacs = [], [] if Hcase == 'b': states.append(self) prefacs.append(+1) elif Hcase == 'b_betaS': for G in addJ(self.S,self.I1): prefac = np.sqrt((2*self.J+1)*(2*G+1))*phs(self.N+self.S+F+self.I1)\ * w6j(self.N,self.S,self.J, self.I1, F, G) if prefac == 0: continue states.append(Hcaseb_betaS(L=self.L,N=self.N,G=G,**QuNrs_def)) prefacs.append(prefac) else: # Transformation for the fermions is missing?! raise ValueError("Invalid string value '{}' for `Hcase` parameter".format(Hcase)) self.linearcombi[Hcase] = dict(states=states, prefacs=prefacs) if printing: for prefac, state in zip(self.linearcombi[Hcase]['prefacs'], self.linearcombi[Hcase]['states']): print('{:+.4f} *\n{}'.format(prefac,state,end='\n')) return self.linearcombi[Hcase]
[docs] class Hcaseb_betaS(QuState): goodQuNrs = ['L','N','G'] description = 'Hunds case b_betaS'
#%% Hamiltonians
[docs] def H_tot(x,y,const): """calculates and returns the matrix element of the total Hamiltonian without external fields between two states. Parameters ---------- x : :class:`Hcasea` first state. y : :class:`Hcasea` second state. const : dict dictionary of all constants required for the effective Hamiltonian. When this function is called by the method :meth:`ElectronicState.calc_eigenstates`, the method :meth:`ElectronicStateConstants.to_dict` of the attribute ``const`` within :class:`ElectronicState` is used to create a proper dictionary. """ # x: lower state, y: upper state # prevent mixing of different F values if kd(x.F,y.F) == 0: return 0.0 S = x.S L, Si, Om, J = x.L, x.Si, x.Om, x.J L_,Si_,Om_,J_ = y.L, y.Si, y.Om, y.J if x.I2 > 0: I1 = x.I1 I2 = x.I2 F, F1 = x.F, x.F1 F_,F1_ = y.F, y.F1 #==================== H_hfs2 - hyperfine interaction for both nuclear ang. moments. sum1 = 0.0 for q in [-1,0,+1]: sum1 += phs(J-Om)*w3j(J,1,J_,-Om,q,Om_)*( const['a_2']*kd(Si,Si_)*kd(Om,Om_)*L_ + const['b_F_2']*phs(S-Si)*cb(S)*w3j(S,1,S,-Si,q,Si_) + const['c_2']*np.sqrt(30)/3*phs(q+S-Si)*cb(S)*w3j(S,1,S,-Si,q,Si_)*w3j(1,2,1,-q,0,q) ) # kd(L,L_): Delta Lambda may not be strictly true term1 = kd(L,L_)*sum1 sum2 = 0.0 for q in [-1,+1]: sum2 += kd(L, L_-2*q)*phs(J-Om+q+S-Si)*cb(S) \ *w3j(J,1,J_,-Om,-q,Om_)*w3j(S,1,S,-Si,q,Si_) term2 = -const['d_2']*sum2 H_hfs2 = phs(F1_+I2+F)*phs(J+I1+F1_+1)*cb(I2)*sb(F1)*sb(F1_)*sb(J)*sb(J_) \ *w6j(I2,F1_,F,F1,I2,1)*w6j(J_,F1_,I1,F1,J,1) * (term1 + term2) # exit if Delta F1 != 0 <-- why can one do that? if kd(F1,F1_) == 0: return H_hfs2 #In the following Hailtonians F1 is refered to as F, and I1 as I F = F1 F_ = F1_ I = I1 else: F = x.F F_ = y.F I = x.I1 H_hfs2 = 0.0 #==================== H_hfs - hyperfine structure sum1 = 0.0 for q in [-1,0,+1]: sum1 += phs(J-Om)*w3j(J,1,J_,-Om,q,Om_)*( const['a']*kd(Si,Si_)*kd(Om,Om_)*L_ + const['b_F']*phs(S-Si)*cb(S)*w3j(S,1,S,-Si,q,Si_) + const['c']*np.sqrt(30)/3*phs(q+S-Si)*cb(S)*w3j(S,1,S,-Si,q,Si_)*w3j(1,2,1,-q,0,q) ) term1 = kd(L,L_)*phs(J_+I+F)*cb(I)*sb(J)*sb(J_)*w6j(F,J_,I,1,I,J) * sum1 sum2 = 0.0 for q in [-1,+1]: sum2 += kd(L, L_-2*q)*phs(J_+I+F)*phs(J-Om+q+S-Si)*cb(I)*cb(S)*sb(J)*sb(J_) \ *w6j(F,J_,I,1,I,J)*w3j(J,1,J_,-Om,-q,Om_)*w3j(S,1,S,-Si,q,Si_) term2 = -const['d']*sum2 term3 = const['c_I']/2* (F*(F+1)-I*(I+1)-J*(J+1))* \ kd(Si,Si_)*kd(L,L_)*kd(J,J_) #only diagonal terms considered here H_hfs = term1 + term2 + term3 #==================== H_rot - rotational term H_rot = 0.0 if (const['B_v'] != 0.0) and (kd(J,J_) != 0.0): sum1 = 0.0 for q in [-1,+1]: sum1 += phs(J_-Om+S-Si)*cb(J)*cb(S)*w3j(J,1,J,-Om,q,Om_)*w3j(S,1,S,-Si,q,Si_) H_rot = const['B_v']*kd(J,J_)*( -2*sum1 + kd(Si,Si_)*kd(Om,Om_)*(J*(J+1) + S*(S+1) - Om_**2 - Si_**2) ) #==================== H_sr - spin-rotation coupling H_sr = 0.0 if (const['gamma'] != 0.0) and (kd(J,J_) != 0.0): sum1 = 0.0 for q in [-1,+1]: #same as in H_rot sum1 += phs(J_-Om+S-Si)*cb(J)*cb(S)*w3j(J,1,J,-Om,q,Om_)*w3j(S,1,S,-Si,q,Si_) H_sr = const['gamma']*kd(J,J_)*( sum1 + kd(Si,Si_)*kd(Om,Om_)*(Si**2 - S*(S+1)) ) # no first order contribution for excited states ???? if not ((L==0) and (L_==0)): H_sr = 0.0 #==================== H_so - spin-orbit coupling H_so = 0.0 if (kd(J,J_) != 0.0): H_so = kd(L,L_)*kd(Om,Om_)*kd(J,J_)*kd(Si,Si_)*( const['A_v']*L*Si + const['A_D']*L*Si* (J*(J+1) - Om**2 + S*(S+1) - Si**2) ) #==================== H_LD - Lambda-doubling H_LD = 0.0 if ((const['o']!=0.0) or (const['p']!=0.0) or (const['q']!=0.0)) and (kd(J,J_) != 0.0): sum1 = 0.0 for q in [-1,+1]: sum11 = 0.0 for Si__ in np.unique([-S,S]): #or is np.arange(-S,S+1e-3,1) better in general? sum11 += phs(S-Si+S-Si__)*cb(S)*w3j(S,1,S,-Si,q,Si__)*w3j(S,1,S,-Si__,q,Si_) sum12 = phs(J_-Om+S-Si)*cb(S)*cb(J_)*w3j(J_,1,J_,-Om,-q,Om_)*w3j(S,1,S,-Si,q,Si_) sum13 = 0.0 Om_max = abs(Si)+abs(L) for Om__ in np.arange(-Om_max,Om_max+1e-3,1): sum13 += phs(J_-Om+J_-Om__)*cb(J_)*w3j(J,1,J,-Om,-q,Om__)*w3j(J,1,J,-Om__,-q,Om_) sum1 += kd(L,L_-2*q)*( (const['o']+const['p']+const['q']) * kd(Om,Om_)*sum11 + (const['p']+2*const['q']) * sum12 + const['q'] * kd(Si,Si_)*sum13 ) H_LD = kd(J,J_) * sum1 #==================== H_eq0Q - electric quadrupol H_eq0Q = 0.0 par1 = w3j(I,2,I,-I,0,I) if (par1 != 0.0) and (const['eq0Q'] != 0.0): H_eq0Q = const['eq0Q']/4*kd(L,L_)*kd(Om,Om_)*phs(J_+I+F)*phs(J-Om_)*sb(J)*sb(J_) \ *1/par1*w6j(F,J_,I,2,I,J)*w3j(J,2,J_,-Om,0,Om) return H_hfs + H_rot + H_sr + H_so + H_LD + H_eq0Q + H_hfs2
[docs] def H_Zeeman(x,y,const,Bfield): """calculates and returns the matrix element of the Zeeman interaction between two states. Parameters ---------- x : :class:`Hcasea` first state. y : :class:`Hcasea` second state. const : dict dictionary of all constants required for the effective Hamiltonian. When this function is called by the method :meth:`ElectronicState.calc_eigenstates`, the method :meth:`ElectronicStateConstants.to_dict` of the attribute ``const`` within :class:`ElectronicState` is used to create a proper dictionary. Bfield : float magnetic field strength in T. """ # x: lower state, y: upper state # prevent mixing of different mF values if kd(x.mF,y.mF) == 0: return 0.0 S = x.S L, Si, Om, J = x.L, x.Si, x.Om, x.J L_,Si_,Om_,J_ = y.L, y.Si, y.Om, y.J unit = 0.4668644778272809#mu_B/h*1e-6/cm2MHz #Bfield*mu_B=E, E/h=f, f in MHz -> cm^-1 if x.I2 > 0: F, mF, F1 = x.F,x.mF,x.F1 F_,mF_,F1_ = y.F,y.mF,y.F1 I1,I2 = x.I1,x.I2 sum1 = 0.0 for q in [-1,0,+1]: sum1 += w3j(J,1,J_,-Om,q,Om_)*( const["g'_L"]*L*kd(Si,Si_) #-const['g_l']*kd(Si,Si_)*Si #see Brown&Carrington, but not used in Fortran code + (const['g_S']+const['g_l'])*phs(S-Si)*cb(S)*w3j(S,1,S,-Si,q,Si_) ) H_z1 = Bfield*kd(L,L_)*sum1 sum2 = 0.0 for q in [-1,+1]: sum2 += kd(L,L_-2*q)*w3j(S,1,S,-Si,q,Si_)*w3j(J,1,J_,-Om,-q,Om_) H_z2 = -Bfield*const["g'_l"]* phs(S-Si)*cb(S)*sum2 #common factor common_fac = phs(F-mF+J-Om)*sb(J)*sb(J_)*w3j(F,1,F_,-mF,0,mF)\ *sb(F1)*sb(F1_)*w6j(J,F1,I1,F1_,J_,1)*phs(F1_+J+I1+1)\ *sb(F)*sb(F_)*w6j(F1,F,I2,F_,F1_,1)*phs(F_+F1+I2+1) return (H_z1 + H_z2)*common_fac*unit else: F, mF = x.F,x.mF F_,mF_ = y.F,y.mF I = x.I1 sum1 = 0.0 for q in [-1,0,+1]: sum1 += w3j(J,1,J_,-Om,q,Om_)*( const["g'_L"]*L*kd(Si,Si_) + (const['g_S']+const['g_l'])*phs(S-Si)*cb(S)*w3j(S,1,S,-Si,q,Si_) ) H_z1 = Bfield*kd(L,L_)*sum1 sum2 = 0.0 for q in [-1,+1]: sum2 += kd(L,L_-2*q)*w3j(S,1,S,-Si,q,Si_)*w3j(J,1,J_,-Om,-q,Om_) H_z2 = -Bfield*const["g'_l"]*phs(S-Si)*cb(S)*sum2 #common factor common_fac = phs(F-mF+J-Om)*sb(J)*sb(J_)*w3j(F,1,F_,-mF,0,mF)\ *sb(F)*sb(F_)*w6j(J,F,I,F_,J_,1)*phs(F_+J+I+1) return (H_z1 + H_z2)*common_fac*unit
[docs] def H_d(x,y): """calculates and returns the matrix element of the electric dipole operator between two states. Parameters ---------- x : :class:`Hcasea` first state. y : :class:`Hcasea` second state. """ # x: lower state, y: upper state S = x.S L, Si, Om, J ,F = x.L, x.Si, x.Om, x.J, x.F L_,Si_,Om_,J_,F_ = y.L, y.Si, y.Om, y.J, y.F if kd(Si,Si_) == 0.0: return 0.0 if x.I2 > 0: # for two nuclear spins I1,I2 = x.I1, x.I2 F1,F1_ = x.F1, y.F1 sum1 = 0.0 for q in [-1,0,+1]: sum1 += w3j(J_,1,J,-Om_,q,Om) H = kd(Si,Si_)*phs(F+F1_+I2+1)*sb(F)*sb(F_)*w6j(F1,F,I2,F_,F1_,1) \ *phs(F1+J_+I1+1)*sb(F1)*sb(F1_)*w6j(J,F1,I1,F1_,J_,1) \ *phs(J_-Om_)*sb(J)*sb(J_) * sum1 else: # for one nuclear spin I = x.I1 sum1 = 0.0 for q in [-1,0,+1]: #here also add 0? sum1 += w3j(J_,1,J,-Om_,q,Om) H = kd(Si,Si_)*phs(J_+I+F+1)*sb(F)*sb(F_)*w6j(J_,F_,I,F,J,1)*phs(J_-Om_)*sb(J)*sb(J_)*sum1 if 'mF' in x.QuNrs: mF,mF_ = x.mF, y.mF H *= phs(F_-mF_)*w3j(F_,1,F,-mF_,mF_-mF,mF) return H
#%%% small functions
[docs] @jit(nopython=True,parallel=False) def addJ(J1,J2): """adding two angular momenta. Returns an array of all possible total angular momenta.""" ishalfint(J1,raise_err=True) ishalfint(J2,raise_err=True) return np.arange(np.abs(J1-J2),np.abs(J1+J2)+1e-3,1)
[docs] @jit(nopython=True,parallel=False) def cb(x): """curly brackets expression in the Hamiltonian""" # curly brackets ishalfint(x,raise_err=True) return np.sqrt(x*(x+1)*(2*x+1))
[docs] @jit(nopython=True,parallel=False) def sb(x): """square brackets expression in the Hamiltonian""" ishalfint(x,raise_err=True) return np.sqrt(2*x+1)
[docs] @jit(nopython=True,parallel=False) def phs(x): """phase expression '(-1)^(x) in the Hamiltonian""" if not isint(x): raise ValueError('no real phase (-1)**(x) for the value x')#'={}'.format(x)) return (-1)**int(x+0.1)
[docs] @jit(nopython=True,parallel=False) def kd(x,y): """delta kronecker""" ishalfint(x,raise_err=True) ishalfint(y,raise_err=True) if np.abs(x-y) < 1e-13: return 1 else: return 0
[docs] @jit(nopython=True,parallel=False) def isint(x,raise_err=False): """test if x is an integer value and raise an error if it's intended""" if abs(np.around(x) - x) > 1e-13: if raise_err: raise ValueError('No integer is provided!!') return False else: return True
[docs] @jit(nopython=True,parallel=False) def ishalfint(x,raise_err=False): """test if x is a half-integer value and raise an error if it's intended""" if abs(np.around(2*x) - 2*x) > 1e-13: if raise_err: raise ValueError('No half-integer is provided!!') return False else: return True
[docs] def eigensort(x,y_arr): """sorts an eigenvalue matrix, e.g. eigenvalues as a function of a varying magnetic or eletric field. This is especially useful since crossing curves of eigenvalues are rearanged in the right order. Parameters ---------- x : 1D numpy array and length N x array, e.g. for the varying magnetic of electric field. y_arr : 2D numpy array of shape(N,M) Array containing all eigenvalues for each value of x. Returns ------- y_arr : 2D numpy array of shape(N,M) ordered array. """ # y_arr should have the shape(len(x),N) with N > 2 arbitrary length # first sort all eigenvalues y_arr = np.sort(y_arr,axis=1) for i in range(2,y_arr.shape[0]): slope = (y_arr[i-1,:]-y_arr[i-2,:])/(x[i-1]-x[i-2]) ind_arr = [] # indices array for j in range(y_arr.shape[1]): inds = np.argsort(np.abs( y_arr[i-1,j]+slope[j]*(x[i]-x[i-1]) - y_arr[i,:] )) for ind in inds: if ind not in ind_arr: ind_arr.append(ind) break ind_arr = np.array(ind_arr) y_arr[i,:] = y_arr[i,ind_arr] return y_arr
[docs] def multiindex_filter(DF,rows=dict(),cols=dict(), drop_level=True): """Filter a DataFrame object only for few specific multiindex values. Parameters ---------- DF : pd.DataFrame DataFrame which is going to be filtered. rows : dict, optional rows to be retained, i.e. dict(N=1). The default is dict(). cols : dict, optional columns to be retained, i.e. dict(P=1,Om=0.5,J=0.5). The default is dict(). drop_level : bool, optional If redundant index levels should be dropped. The default is True. Returns ------- DF : pd.DataFrame filtered DataFrame. """ if rows: DF = DF.xs(tuple(rows.values()), level=tuple(rows.keys()), axis=0, drop_level=drop_level) if cols: DF = DF.xs(tuple(cols.values()), level=tuple(cols.keys()), axis=1, drop_level=drop_level) return DF
[docs] def get_QuNrvals_from_multiindex(multiindex, QuNrs): """Extract lists with the values certain Quantum numbers from a pandas.MultiIndex object. Parameters ---------- multiindex : pandas.MultiIndex MultiIndex with one or multiple columns corresponding to Quantum numbers. QuNrs : list(str) names of the Quantum number for which the values should be extracted. Returns ------- d : dict dictionary with Quantum number names as keys and the Quantum number values as lists. """ d = dict() for QuNr in QuNrs[:]: QuNrs_arr = list(multiindex.to_frame().get(QuNr).to_numpy()) if np.all(list(map(isint, QuNrs_arr))): QuNrs_arr = list(map(int, QuNrs_arr)) d[QuNr] = QuNrs_arr return d
[docs] def get_QuNr_keyval_pairs(ElState, multiindex, QuNrs_names=[], include_v=True): """Calculated list of pairs of the name and value of certain Quantum numbers included in a MultiIndex object. Parameters ---------- ElState : :class:`ElectronicState` ElectronicState for which the vibrational and ground or excited data is extracted. multiindex : pandas.MultiIndex MultiIndex object which includes multiple rows of Quantum number values for multiple Quantum number names columns. QuNrs_names : list(str), optional all Quantum number names to be ectraced. The default is []. include_v : bool, optional Whether to include the vibrational Quantum numbers. The default is True. Example ------- :: >>> get_QuNr_keyval_pairs(Mol.A, Mol.A.get_eigenstates().iloc[:10].index, QuNrs_names=['Om']) Out: ['exs=A', 'v=0', 'Om=0.5'] Returns ------- list(str) list of Quantum number key and value pairs, e.g. ['gs=X', 'v=0', 'N=1']. """ if isinstance(ElState, list): if len(ElState) != 2: raise Exception(f"<ElState> list must be of len 2, not {len(ElState)}") if QuNrs_names == []: QuNrs_names = [[],[]] pairs_list = [get_QuNr_keyval_pairs(ElState[i], multiindex[i], QuNrs_names[i]) for i in range(2)] return [f"{str_g} <- {str_e}" for str_g, str_e in zip(*pairs_list)] gsexs = {'ground state':'gs', 'excited state':'exs'}[ElState.grex] # list of QuNrs label and value pairs pairs = [f"{gsexs}={ElState.label}"] if include_v: pairs.append(f"v={ElState.nu}") # add values to the names/ labels of the Quantum numbers for QuNr_name in QuNrs_names[:]: values = multiindex.get_level_values(QuNr_name) if not np.all(values == values[0]): raise Exception(f"not all values {values} are the same for QuNr {QuNr_name}") value = int(values[0]) if isint(values[0]) else values[0] pairs.append(f"{QuNr_name}={value}") return pairs
[docs] def get_unique_multiindex_names(multiindex): """Calculate list of unique column names from a pandas.MultiIndex object.""" multiindex = multiindex.copy() nunique = multiindex.to_frame().nunique() cols_to_drop = nunique[nunique == 1].index multiindex = multiindex.droplevel(list(cols_to_drop.values)) return list(multiindex.names)
[docs] class NestedDict(dict): """Nested dictionary class to handle multiple key with the corresponding ``__getitem__`` and ``__setitem__`` methods. Example ------- :: >>> f1 = NestedDict() >>> f1 {} >>> f1[['a','b','c']] >>> f1 {'a': {'b': {'c': {}}}} >>> f1[['a2','b2','c2']] =1234 >>> f1 {'a': {'b': {'c': {}}}, 'a2': {'b2': {'c2': 1234}}} >>> f1[['a','b']].update(dict(c2=1234)) >>> f1 {'a': {'b': {'c': {}, 'c2': 1234}}, 'a2': {'b2': {'c2': 1234}}} """ def __getitem__(self, item): if isinstance(item, list): if len(item) > 1: return self[item[0]][item[1:]] else: item = item[0] try: return dict.__getitem__(self, item) except KeyError: value = self[item] = type(self)() return value def __setitem__(self, key, value): # optional processing here if isinstance(key, list): if len(key) > 1: self[key[0]][key[1:]] = value else: self[key[0]] = value else: super(NestedDict, self).__setitem__(key, value)
#%% if __name__ == '__main__': # BaF constants for the isotopes 138 and 137 from Steimle paper 2011 const_gr_138 = {'B_e':0.21594802,'D_e':1.85e-7,'gamma':0.00269930, 'b_F':0.002209862,'c':0.000274323} #,'g_l':-0.028}#gl constant?? const_ex_138 = {'A_e':632.28175,'A_D':3.1e-5, 'B_e':0.2117414, 'D_e':2.0e-7,'p':-0.25755-2*(-0.0840),'q':-0.0840, "g'_l":-0.536,"g'_L":0.980,'T_e':11946.31676963} const_gr_137 = {'B_e':0.21613878,'D_e':1.85e-7,'gamma':0.002702703, 'b_F':0.077587, 'c':0.00250173,'eq0Q':-0.00390270*2, 'b_F_2':0.002209873,'c_2':0.000274323} const_ex_137 = {'B_e':0.211937,'D_e':2e-7,'A_e':632.2802,'A_D':3.1e-5, 'p':-0.2581-2*(-0.0840),'q':-0.0840,'d':0.0076, 'T_e':11946.3152748} Gamma = 2.8421 mass138 = 138 + 19 mass137 = 137 + 19 #%% bosonic 138BaF BaF = Molecule(I1=0.5,naturalabund=0.717,label='138 BaF',mass=mass138) BaF.add_electronicstate('X',2,'Sigma', Hcase='b',const=const_gr_138) #for ground state BaF.add_electronicstate('A',2,'Pi',Gamma=Gamma, Hcase='a_p',const=const_ex_138) #for excited state BaF.X.build_states(Fmax=9) BaF.A.build_states(Fmax=9) print(BaF) BaF.calc_branratios(threshold=0.0) #%% plotting spectra plt.figure('Spectra of two BaF isotopes') BaF.calc_spectrum(limits=(11627.0,11632.8))#11634.15,11634.36)#12260.5,12260.7) plt.plot(BaF.Eplt,BaF.I,label='$^{138}$BaF') plt.xlabel('transition frequency in 1/cm') plt.ylabel('intensity') plt.legend() print(BaF.X.const.show('non-zero')) #%% fermionic 137BaF BaF2 = Molecule(I1=1.5,I2=0.5,naturalabund=0.112,label='137 BaF',mass=mass137) BaF2.add_electronicstate('X',2,'Sigma',const=const_gr_137) BaF2.add_electronicstate('A',2,'Pi',Gamma=Gamma,const=const_ex_137) BaF2.X.build_states(Fmax=9.5) BaF2.A.build_states(Fmax=9.5) BaF2.calc_branratios() #%% plotting spectra with an offset BaF2.calc_spectrum(limits=(11627.0,11632.8))#11634.15,11634.36)#12260.5,12260.7) plt.plot(BaF2.Eplt,BaF2.I-10,label='$^{137}$BaF') plt.legend() #%% ground state Zeeman splitting due to external magnetic field in 138BaF BaF = Molecule(I1=0.5,naturalabund=0.717,label='138 BaF',verbose=False,mass=mass138) BaF.add_electronicstate('X',2,'Sigma', Hcase='b',const=const_gr_138) #for ground state BaF.add_electronicstate('A',2,'Pi',Gamma=Gamma, Hcase='a_p',const=const_ex_138) #for excited state BaF.X.build_states(Fmax=3,Fmin=0) BaF.A.build_states(Fmax=3,Fmin=0) BaF.X.plot_Zeeman(100e-4) #%% getting g-factors BaF = Molecule(I1=0.5,naturalabund=0.717,label='138 BaF',mass=mass138) BaF.add_electronicstate('X',2,'Sigma', Hcase='b',const=const_gr_138) #for ground state BaF.add_electronicstate('A',2,'Pi',Gamma=Gamma, Hcase='a_p',const=const_ex_138) #for excited state BaF.X.build_states(Fmax=4) BaF.X.get_gfactors()