pythonradex 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Created on Sun Nov 12 16:48:00 2017
4
+
5
+ @author: gianni
6
+ """
7
+ from scipy import constants
8
+ import numpy as np
9
+ from pythonradex import LAMDA_file,atomic_transition
10
+ import numba as nb
11
+
12
+ #Note: the partition function is just for convenience when using the Molecule class
13
+ #directly; it is not used to solve the non-LTE radiative transfer
14
+
15
+ class Molecule():
16
+
17
+ '''Represents an atom or molecule with data read from a LAMDA file
18
+
19
+ Attributes:
20
+ levels (list of pythonradex.atomic_transition.Level): The energy levels
21
+ of the molecule, in the same order as in the LAMDA file.
22
+ rad_transitions (list of pythonradex.atomic_transition.RadiativeTransition):
23
+ The radiative transitions of the molecule, in the same order as in the
24
+ LAMDA file.
25
+ coll_transitions (dict): The collisional transitions of the molecule. The
26
+ dictionary keys correspond to the different colliders. Each entry of
27
+ the dictionary is a list of pythonradex.atomic_transition.CollisionalTransition
28
+ objects, in the same order as in the LAMDA file.
29
+ n_levels (:obj:`int`): the total number of levels
30
+ n_rad_transitions (:obj:`int`): the total number of radiative transitions
31
+
32
+ '''
33
+
34
+ def __init__(self,datafilepath,partition_function=None):
35
+ """Constructs a new instance of the Molecule class using a LAMDA datafile
36
+
37
+ Args:
38
+ datafilepath (:obj:`str`): The filepath to the LAMDA file.
39
+ partition_function (func): A user-supplied partition function of one argument
40
+ (temperature). Defaults to None. If equal to None, the partition function
41
+ will be calculated by using the data from the provided LAMDA file.
42
+ """
43
+ try:
44
+ #reading frequencies can be useful since frequencies are sometimes
45
+ #given with more significant digits than level energies. However,
46
+ #the LAMDA format does not orce a file to contain the frequencies,
47
+ #so they might not be present.
48
+ data = LAMDA_file.read(datafilepath=datafilepath,
49
+ read_frequencies=True)
50
+ except:
51
+ data = LAMDA_file.read(datafilepath=datafilepath,
52
+ read_frequencies=False)
53
+ self.levels = data['levels']
54
+ self.rad_transitions = data['radiative transitions']
55
+ self.coll_transitions = data['collisional transitions']
56
+ self.n_levels = len(self.levels)
57
+ self.n_rad_transitions = len(self.rad_transitions)
58
+ self.set_partition_function(partition_function=partition_function)
59
+
60
+ def set_partition_function(self,partition_function):
61
+ if partition_function is None:
62
+ self.Z = self.Z_from_atomic_data
63
+ else:
64
+ self.Z = partition_function
65
+
66
+ def Z_from_atomic_data(self,T):
67
+ T = np.array(T)
68
+ weights = np.array([l.g for l in self.levels])
69
+ energies = np.array([l.E for l in self.levels])
70
+ if T.ndim > 0:
71
+ shape = [self.n_levels,]+[1 for i in range(T.ndim)] #needs to come before T is modified
72
+ T = np.expand_dims(T,axis=0) #insert new axis at first position (axis=0)
73
+ weights = weights.reshape(shape)
74
+ energies = energies.reshape(shape)
75
+ return np.sum(weights*np.exp(-energies/(constants.k*T)),axis=0)
76
+
77
+ def LTE_level_pop(self,T):
78
+ '''Computes the level populations in LTE for all levels
79
+
80
+ Args:
81
+ T (:obj:`float` or numpy.ndarray): The temperature.
82
+
83
+ Returns:
84
+ numpy.ndarray: The fractional level population. If only one temperature
85
+ was given as input, the output array is one-dimensional, with the level
86
+ populations corresponding to the levels in the order of the LAMDA file.
87
+ If an array of temperatures was given as input, the output array has
88
+ two dimensions, with the second corresponding to the different temperatures.
89
+
90
+ '''
91
+ T = np.array(T)
92
+ Z = self.Z(T)
93
+ pops = [l.LTE_level_pop(T=T,Z=Z) for l in self.levels]
94
+ if T.ndim > 0:
95
+ shape = [1,]+list(T.shape)
96
+ return np.concatenate([p.reshape(shape) for p in pops],axis=0)
97
+ else:
98
+ return np.array(pops)
99
+
100
+ def get_rad_transition_number(self,transition_name):
101
+ '''Returns the transition number for a given transition name'''
102
+ candidate_numbers = [i for i,line in enumerate(self.rad_transitions) if
103
+ line.name==transition_name]
104
+ assert len(candidate_numbers) == 1
105
+ return candidate_numbers[0]
106
+
107
+
108
+ class EmittingMolecule(Molecule):
109
+
110
+ '''Represents a an emitting molecule, i.e. a molecule with a specified line profile
111
+
112
+ Attributes:
113
+ levels (list of pythonradex.atomic_transition.Level): The energy levels
114
+ of the molecule, in the same order as in the LAMDA file.
115
+ rad_transitions (list of pythonradex.atomic_transition.EmissionLine):
116
+ The radiative transitions of the molecule, in the same order as in the
117
+ LAMDA file.
118
+ coll_transitions (dict): The collisional transitions of the molecule. The
119
+ dictionary keys correspond to the different colliders. Each entry of
120
+ the dictionary is a list of pythonradex.atomic_transition.CollisionalTransition
121
+ objects, in the same order as in the LAMDA file.
122
+ n_levels (:obj:`int`): the total number of levels
123
+ n_rad_transitions (:obj:`int`): the total number of radiative transitions
124
+ '''
125
+
126
+ def __init__(self,datafilepath,line_profile_type,width_v,read_frequencies=False,
127
+ partition_function=None):
128
+ """Constructs a new instance of the EmittingMolecule class using a LAMDA datafile
129
+
130
+ Args:
131
+ datafilepath (:obj:`str`): The filepath to the LAMDA file.
132
+ line_profile_type (:obj:`str`): The type of the line profile. Either
133
+ 'rectangular' or 'Gaussian'
134
+ width_v (:obj:`float`): The width of the line profile in [m/s]. For a Gaussian
135
+ line profile, this corresponds to the FWHM.
136
+ read_frequencies (:obj:`bool`): Whether to read the frequencies of the
137
+ radiative transitions from the file or not. If False, calculates the
138
+ frequencies from the level energies given in the file. Setting this to
139
+ True can be useful since frequencies are sometimes given with more
140
+ significant digits than level energies. However, the LAMDA format does not
141
+ force a file to contain the frequencies, so they might not be present.
142
+ partition_function (func): A user-supplied partition function of one argument
143
+ (temperature). Defaults to None. If equal to None, the partition function
144
+ will be calculated by using the data from the provided LAMDA file.
145
+ """
146
+ Molecule.__init__(self,datafilepath=datafilepath,
147
+ partition_function=partition_function)
148
+ self.width_v = width_v
149
+ self.line_profile_type = line_profile_type
150
+ #convert radiative transitions to emission lines (but keep the same attribute name)
151
+ self.rad_transitions = [atomic_transition.EmissionLine.from_radiative_transition(
152
+ radiative_transition=rad_trans,
153
+ line_profile_type=line_profile_type,width_v=width_v)
154
+ for rad_trans in self.rad_transitions]
155
+ self.identify_overlapping_lines()
156
+ if self.any_line_has_overlap(line_indices=range(self.n_rad_transitions)):
157
+ self.has_overlapping_lines = True
158
+ else:
159
+ self.has_overlapping_lines = False
160
+ self.Tkin_data = {}
161
+ self.Tkin_data_limits = {}
162
+ for collider,coll_transitions in self.coll_transitions.items():
163
+ self.Tkin_data[collider] = coll_transitions[0].Tkin_data
164
+ assert np.all(self.Tkin_data[collider][:-1] < self.Tkin_data[collider][1:])
165
+ for coll_trans in coll_transitions:
166
+ assert np.all(coll_trans.Tkin_data==self.Tkin_data[collider])
167
+ self.Tkin_data_limits[collider] = np.min(self.Tkin_data[collider]),\
168
+ np.max(self.Tkin_data[collider])
169
+ self.set_K21_matrix()
170
+ #collecting parameters, needed for numba-compiled functions:
171
+ self.A21 = np.array([line.A21 for line in self.rad_transitions])
172
+ self.B21 = np.array([line.B21 for line in self.rad_transitions])
173
+ self.B12 = np.array([line.B12 for line in self.rad_transitions])
174
+ self.nu0 = np.array([line.nu0 for line in self.rad_transitions])
175
+ self.phi_nu0 = np.array([line.line_profile.phi_nu(line.nu0) for line
176
+ in self.rad_transitions])
177
+ self.gup_rad_transitions = np.array([line.up.g for line in self.rad_transitions])
178
+ self.glow_rad_transitions = np.array([line.low.g for line in self.rad_transitions])
179
+ self.nlow_rad_transitions = np.array([line.low.number for line in
180
+ self.rad_transitions])
181
+ self.nup_rad_transitions = np.array([line.up.number for line in
182
+ self.rad_transitions])
183
+ self.Delta_E_rad_transitions = np.array([line.Delta_E for line in
184
+ self.rad_transitions])
185
+ self.coll_gups = {}
186
+ self.coll_glows = {}
187
+ self.coll_DeltaEs = {}
188
+ self.coll_nup = {}
189
+ self.coll_nlow = {}
190
+ for collider,coll_transitions in self.coll_transitions.items():
191
+ self.coll_glows[collider] = np.array([c.low.g for c in coll_transitions])
192
+ self.coll_gups[collider] = np.array([c.up.g for c in coll_transitions])
193
+ self.coll_DeltaEs[collider] = np.array([c.Delta_E for c in
194
+ coll_transitions])
195
+ self.coll_nlow[collider] = np.array([c.low.number for c in coll_transitions])
196
+ self.coll_nup[collider] = np.array([c.up.number for c in coll_transitions])
197
+
198
+
199
+ def get_tau_nu0_lines(self,N,level_population):
200
+ r'''Compute the optical depth at the rest frequency of all lines (dust
201
+ and overlapping lines are ignored)
202
+
203
+ Args:
204
+ N (:obj:`float`): the column density in [m\ :sup:`-2`]
205
+ level_population (numpy.ndarray): the fractional population of each level,
206
+ where the levels are in the order of the LAMDA file
207
+
208
+ Returns:
209
+ numpy.ndarray: the optical depth at the rest frequency
210
+ '''
211
+ N1 = level_population[self.nlow_rad_transitions]*N
212
+ N2 = level_population[self.nup_rad_transitions]*N
213
+ tau_nu0 = atomic_transition.tau_nu(
214
+ A21=self.A21,phi_nu=self.phi_nu0,
215
+ g_up=self.gup_rad_transitions,g_low=self.glow_rad_transitions,
216
+ N1=N1,N2=N2,nu=self.nu0)
217
+ return tau_nu0
218
+
219
+ def get_tau_nu0_lines_LTE(self,N,T):
220
+ r'''Compute the optical depth at the rest frequency in LTE for all lines
221
+ (dust and overlapping lines are ignored)
222
+
223
+ Args:
224
+ N (:obj:`float`): the column density in [m\ :sup:`-2`]
225
+ T (:obj:`float`): the temperature in [K]
226
+
227
+ Returns:
228
+ numpy.ndarray: the optical depth at the rest frequency assuming LTE
229
+ '''
230
+ level_population = self.LTE_level_pop(T=T)
231
+ return self.get_tau_nu0_lines(N=N,level_population=level_population)
232
+
233
+ def get_Tex(self,level_population):
234
+ r'''Compute the excitation temperature for all radiative transitions
235
+
236
+ Args:
237
+ level_population (numpy.ndarray): the fractional population of each level,
238
+ where the levels are in the order of the LAMDA file
239
+
240
+ Returns:
241
+ numpy.ndarray: the excitation temperature for each radiative transition,
242
+ in the order as in the LAMDA file
243
+ '''
244
+ Tex = atomic_transition.Tex(
245
+ Delta_E=self.Delta_E_rad_transitions,g_up=self.gup_rad_transitions,
246
+ g_low=self.glow_rad_transitions,
247
+ x1=level_population[self.nlow_rad_transitions],
248
+ x2=level_population[self.nup_rad_transitions])
249
+ return Tex
250
+
251
+ def identify_overlapping_lines(self):
252
+ self.overlapping_lines = []
253
+ for i,line in enumerate(self.rad_transitions):
254
+ width_nu = self.width_v/constants.c*line.nu0
255
+ overlapping_lines = []
256
+ for j,overlap_line in enumerate(self.rad_transitions):
257
+ if j == i:
258
+ continue
259
+ nu0_overlap_line = overlap_line.nu0
260
+ width_nu_overlap_line = self.width_v/constants.c*nu0_overlap_line
261
+ if self.line_profile_type == 'rectangular':
262
+ if np.abs(line.nu0-nu0_overlap_line)\
263
+ <= width_nu/2 + width_nu_overlap_line/2:
264
+ overlapping_lines.append(j)
265
+ elif self.line_profile_type == 'Gaussian':
266
+ #here I need to be conservative because the Gaussian profile can,
267
+ #in theory, be arbitrarily broad (for arbitrarily high optical depth)
268
+ #take +- 1.57 FWHM, where the Gaussian is 0.1% of the peak
269
+ if np.abs(line.nu0-nu0_overlap_line)\
270
+ <= 1.57*width_nu + 1.57*width_nu_overlap_line:
271
+ overlapping_lines.append(j)
272
+ self.overlapping_lines.append(overlapping_lines)
273
+
274
+ def any_line_has_overlap(self,line_indices):
275
+ for index in line_indices:
276
+ if len(self.overlapping_lines[index]) > 0:
277
+ return True
278
+ return False
279
+
280
+ def get_tau_line_nu(self,line_index,level_population,N):
281
+ line = self.rad_transitions[line_index]
282
+ def tau_line_nu(nu):
283
+ return line.tau_nu(N1=N*level_population[line.low.number],
284
+ N2=N*level_population[line.up.number],nu=nu)
285
+ return tau_line_nu
286
+
287
+ def get_tau_tot_nu(self,line_index,level_population,N,tau_dust):
288
+ overlapping_indices = self.overlapping_lines[line_index]
289
+ line = self.rad_transitions[line_index]
290
+ overlapping_lines = [self.rad_transitions[i] for i in overlapping_indices]
291
+ def tau_tot_nu(nu):
292
+ tau_tot = line.tau_nu(N1=N*level_population[line.low.number],
293
+ N2=N*level_population[line.up.number],nu=nu)
294
+ for ovl_line in overlapping_lines:
295
+ x1 = level_population[ovl_line.low.number]
296
+ x2 = level_population[ovl_line.up.number]
297
+ tau_tot += ovl_line.tau_nu(N1=x1*N,N2=x2*N,nu=nu)
298
+ tau_tot += tau_dust(nu)
299
+ return tau_tot
300
+ return tau_tot_nu
301
+
302
+ def set_K21_matrix(self):
303
+ self.K21_matrix = {collider:np.zeros((len(coll_transitions),
304
+ self.Tkin_data[collider].size))
305
+ for collider,coll_transitions in
306
+ self.coll_transitions.items()}
307
+ for collider,coll_transitions in self.coll_transitions.items():
308
+ for i,coll_trans in enumerate(coll_transitions):
309
+ self.K21_matrix[collider][i,:] = coll_trans.K21_data
310
+
311
+ @staticmethod
312
+ @nb.jit(nopython=True,cache=True)
313
+ def construct_K_matrix(n_levels,K12,K21,nlow,nup):
314
+ K = np.zeros((n_levels,n_levels))
315
+ for i in range(len(K12)):
316
+ nl = nlow[i]
317
+ nu = nup[i]
318
+ #production of upper level from lower level:
319
+ K[nu,nl] += K12[i]
320
+ #destruction of lower level by transitions to upper level:
321
+ K[nl,nl] += -K12[i]
322
+ #production lower level from upper level:
323
+ K[nl,nu] += K21[i]
324
+ #destruction of upper level by transition to lower level:
325
+ K[nu,nu] += -K21[i]
326
+ return K
327
+
328
+ def interpolate_K(self,Tkin,collider):
329
+ Tlimits = self.Tkin_data_limits[collider]
330
+ assert Tlimits[0] <= Tkin <= Tlimits[1]
331
+ output = {}
332
+ Tkin_data = self.Tkin_data[collider]
333
+ j = np.searchsorted(Tkin_data,Tkin,side='left')
334
+ if j == 0:
335
+ K21 = self.K21_matrix[collider][:,0]
336
+ else:
337
+ i = j-1
338
+ x0 = Tkin_data[i]
339
+ y0 = self.K21_matrix[collider][:,i]
340
+ x1 = Tkin_data[j]
341
+ y1 = self.K21_matrix[collider][:,j]
342
+ x = Tkin
343
+ K21 = (y0*(x1-x) + y1*(x-x0)) / (x1-x0)
344
+ K12 = atomic_transition.compute_K12(
345
+ K21=K21,g_up=self.coll_gups[collider],
346
+ g_low=self.coll_glows[collider],
347
+ Delta_E=self.coll_DeltaEs[collider],Tkin=Tkin)
348
+ output['K21'] = K21
349
+ output['K12'] = K12
350
+ return output
351
+
352
+ def get_GammaC(self,Tkin,collider_densities):
353
+ GammaC = np.zeros((self.n_levels,)*2)
354
+ for collider,coll_dens in collider_densities.items():
355
+ interpK = self.interpolate_K(Tkin=Tkin,collider=collider)
356
+ K = self.construct_K_matrix(
357
+ n_levels=self.n_levels,K12=interpK['K12'],K21=interpK['K21'],
358
+ nlow=self.coll_nlow[collider],nup=self.coll_nup[collider])
359
+ GammaC += K*coll_dens
360
+ return GammaC