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.
- pythonradex/LAMDA_file.py +129 -0
- pythonradex/__about__.py +4 -0
- pythonradex/__init__.py +4 -0
- pythonradex/atomic_transition.py +351 -0
- pythonradex/escape_probability.py +119 -0
- pythonradex/escape_probability_functions.py +198 -0
- pythonradex/flux.py +209 -0
- pythonradex/helpers.py +69 -0
- pythonradex/molecule.py +360 -0
- pythonradex/radiative_transfer.py +548 -0
- pythonradex/rate_equations.py +308 -0
- pythonradex-1.0.0.dist-info/METADATA +28 -0
- pythonradex-1.0.0.dist-info/RECORD +15 -0
- pythonradex-1.0.0.dist-info/WHEEL +4 -0
- pythonradex-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Created on Sun Nov 12 17:05:01 2017
|
|
4
|
+
|
|
5
|
+
@author: gianni
|
|
6
|
+
"""
|
|
7
|
+
from pythonradex import atomic_transition
|
|
8
|
+
from scipy import constants
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
def is_comment(line):
|
|
12
|
+
if line.replace(' ','')[0] == '!':
|
|
13
|
+
return True
|
|
14
|
+
else:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
def read(datafilepath,read_frequencies,read_quantum_numbers=False):
|
|
18
|
+
'''
|
|
19
|
+
Read a LAMDA data file. LAMDA files can be downloaded from the LAMDA database
|
|
20
|
+
at http://home.strw.leidenuniv.nl/~moldata
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
datafilepath (:obj:`str`): The filepath to the LAMDA file.
|
|
24
|
+
read_frequencies (:obj:`bool`): Whether to read the frequencies of the
|
|
25
|
+
radiative transitions from the file or not. If False, calculates the
|
|
26
|
+
frequencies from the level energies given in the file. Setting this to
|
|
27
|
+
True can be useful since frequencies are sometimes given with more
|
|
28
|
+
significant digits than level energies. However, the LAMDA format does not
|
|
29
|
+
force a file to contain the frequencies, so they might not be present.
|
|
30
|
+
read_quantum_numbers (:obj:`bool`): Whether to read the quantum numbers
|
|
31
|
+
from the file. Defaults to False. The LAMDA format does not force a
|
|
32
|
+
file to list quantum numbers, so they might not be present.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
dict: A dictionary containing the data read from the file.
|
|
36
|
+
The following keys give acces to lists containing the data.
|
|
37
|
+
The elements of these lists are in the order they appear in the file.
|
|
38
|
+
'levels' (a list of pythonradex.atomic_transition.Level objects);
|
|
39
|
+
'radiative transitions' (a list of
|
|
40
|
+
pythonradex.atomic_transitions.RadiativeTransition objects);
|
|
41
|
+
'collisional transitions' (a list of
|
|
42
|
+
pythonradex.atomic_transition.CollisionalTransition objects);
|
|
43
|
+
'quantum numbers' (list of str, empty if read_quantum_numbers
|
|
44
|
+
was set to False)
|
|
45
|
+
'''
|
|
46
|
+
#identifiers used in the LAMDA database files:
|
|
47
|
+
LAMDA_coll_ID = {'1':'H2','2':'para-H2','3':'ortho-H2','4':'e',
|
|
48
|
+
'5':'H','6':'He','7':'H+'}
|
|
49
|
+
datafile = open(datafilepath,'r')
|
|
50
|
+
levels = []
|
|
51
|
+
rad_transitions = []
|
|
52
|
+
coll_transitions = {}
|
|
53
|
+
quantum_numbers = []
|
|
54
|
+
comment_offset = 0
|
|
55
|
+
for i,line in enumerate(datafile):
|
|
56
|
+
if is_comment(line) or line=='' or line=='\n':
|
|
57
|
+
comment_offset += 1
|
|
58
|
+
continue
|
|
59
|
+
if i == 0+comment_offset:
|
|
60
|
+
continue
|
|
61
|
+
if i == 1+comment_offset:
|
|
62
|
+
continue
|
|
63
|
+
if i == 2+comment_offset:
|
|
64
|
+
n_levels = int(line)
|
|
65
|
+
continue
|
|
66
|
+
if 3+comment_offset <= i < 3+comment_offset+n_levels:
|
|
67
|
+
line_entries = line.split()
|
|
68
|
+
leveldata = [float(string) for string in line_entries[:3]]
|
|
69
|
+
assert int(leveldata[0]) == i-2-comment_offset,\
|
|
70
|
+
'level numeration not consistent'
|
|
71
|
+
#transforming energy from cm-1 to J; level numbers starting from 0:
|
|
72
|
+
lev = atomic_transition.Level(
|
|
73
|
+
g=leveldata[2],
|
|
74
|
+
E=constants.c*constants.h*leveldata[1]/constants.centi,
|
|
75
|
+
number=int(leveldata[0])-1)
|
|
76
|
+
levels.append(lev)
|
|
77
|
+
if read_quantum_numbers:
|
|
78
|
+
quantum_numbers.append(line_entries[3])
|
|
79
|
+
continue
|
|
80
|
+
if i == 3+comment_offset+n_levels:
|
|
81
|
+
n_rad_transitions = int(line)
|
|
82
|
+
continue
|
|
83
|
+
if 4+comment_offset+n_levels <= i < 4+comment_offset+n_levels+n_rad_transitions:
|
|
84
|
+
radtransdata = [float(string) for string in line.split()]
|
|
85
|
+
up = next(level for level in levels if level.number==radtransdata[1]-1)
|
|
86
|
+
low = next(level for level in levels if level.number==radtransdata[2]-1)
|
|
87
|
+
rad_trans_kwargs = {'up':up,'low':low,'A21':radtransdata[3]}
|
|
88
|
+
if read_frequencies:
|
|
89
|
+
nu0 = radtransdata[4]*constants.giga
|
|
90
|
+
Delta_E = up.E-low.E
|
|
91
|
+
assert np.isclose(nu0,Delta_E/constants.h,atol=0,rtol=1e-3),\
|
|
92
|
+
'read frequency is inconsistent with level energies'
|
|
93
|
+
rad_trans_kwargs['nu0'] = nu0
|
|
94
|
+
rad_trans = atomic_transition.RadiativeTransition(**rad_trans_kwargs)
|
|
95
|
+
rad_transitions.append(rad_trans)
|
|
96
|
+
continue
|
|
97
|
+
if i == 4+comment_offset+n_levels+n_rad_transitions:
|
|
98
|
+
coll_partner_offset = 0
|
|
99
|
+
continue
|
|
100
|
+
if i == 5+comment_offset+n_levels+n_rad_transitions + coll_partner_offset:
|
|
101
|
+
coll_ID = LAMDA_coll_ID[line[0]]
|
|
102
|
+
coll_transitions[coll_ID] = []
|
|
103
|
+
continue
|
|
104
|
+
if i == 6+comment_offset+n_levels+n_rad_transitions + coll_partner_offset:
|
|
105
|
+
n_coll_transitions = int(line)
|
|
106
|
+
continue
|
|
107
|
+
if i == 7+comment_offset+n_levels+n_rad_transitions + coll_partner_offset:
|
|
108
|
+
continue #this lines contains the number of temperature elements
|
|
109
|
+
if i == 8+comment_offset+n_levels+n_rad_transitions + coll_partner_offset:
|
|
110
|
+
coll_temperatures = np.array([float(string) for string in line.split()])
|
|
111
|
+
continue
|
|
112
|
+
last_coll_trans_i = 9+comment_offset+n_levels+n_rad_transitions\
|
|
113
|
+
+coll_partner_offset+n_coll_transitions-1
|
|
114
|
+
if 9+comment_offset+n_levels+n_rad_transitions+coll_partner_offset <= i <=\
|
|
115
|
+
last_coll_trans_i:
|
|
116
|
+
coll_trans_data = [float(string) for string in line.split()]
|
|
117
|
+
up = next(level for level in levels if level.number==coll_trans_data[1]-1)
|
|
118
|
+
low = next(level for level in levels if level.number==coll_trans_data[2]-1)
|
|
119
|
+
K21_data = np.array(coll_trans_data[3:])*constants.centi**3
|
|
120
|
+
coll_trans = atomic_transition.CollisionalTransition(
|
|
121
|
+
up=up,low=low,K21_data=K21_data,
|
|
122
|
+
Tkin_data=coll_temperatures)
|
|
123
|
+
coll_transitions[coll_ID].append(coll_trans)
|
|
124
|
+
if i == last_coll_trans_i:
|
|
125
|
+
coll_partner_offset += 4+n_coll_transitions
|
|
126
|
+
continue
|
|
127
|
+
datafile.close()
|
|
128
|
+
return {'levels':levels,'radiative transitions':rad_transitions,
|
|
129
|
+
'collisional transitions':coll_transitions,'quantum numbers':quantum_numbers}
|
pythonradex/__about__.py
ADDED
pythonradex/__init__.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Created on Sun Nov 12 17:03:55 2017
|
|
4
|
+
|
|
5
|
+
@author: gianni
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pythonradex import helpers
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy import constants
|
|
11
|
+
import numba as nb
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@nb.jit(nopython=True,cache=True)
|
|
15
|
+
def tau_nu(A21,phi_nu,g_low,g_up,N1,N2,nu):
|
|
16
|
+
return constants.c**2/(8*np.pi*nu**2)*A21*phi_nu*(g_up/g_low*N1-N2)
|
|
17
|
+
|
|
18
|
+
@nb.jit(nopython=True,cache=True,error_model='numpy')
|
|
19
|
+
def Tex(Delta_E,g_low,g_up,x1,x2):
|
|
20
|
+
return np.where((x1==0) & (x2==0),0,
|
|
21
|
+
-Delta_E/(constants.k*np.log(g_low*x2/(g_up*x1))))
|
|
22
|
+
|
|
23
|
+
@nb.jit(nopython=True,cache=True)
|
|
24
|
+
def compute_K12(K21,g_up,g_low,Delta_E,Tkin):
|
|
25
|
+
#see RADEX manual
|
|
26
|
+
return (g_up/g_low*K21*np.exp(-Delta_E/(constants.k*Tkin)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LineProfile():
|
|
30
|
+
|
|
31
|
+
'''Abstract class representing a general, normalised line profile.'''
|
|
32
|
+
|
|
33
|
+
n_nu_elements_for_coarse_array = 10
|
|
34
|
+
|
|
35
|
+
def __init__(self,nu0,width_v):
|
|
36
|
+
self.nu0 = nu0
|
|
37
|
+
self.width_v = width_v
|
|
38
|
+
self.width_nu = helpers.Delta_nu(Delta_v=self.width_v,nu0=self.nu0)
|
|
39
|
+
self.initialise_phi_nu_params()
|
|
40
|
+
# self.initialise_coarse_nu_array()
|
|
41
|
+
# self.coarse_phi_nu_array = self.phi_nu(nu=self.coarse_nu_array)
|
|
42
|
+
self.phi_nu0 = self.phi_nu(nu=self.nu0)
|
|
43
|
+
|
|
44
|
+
def initialise_phi_nu_params(self):
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
def phi_nu(self,nu):
|
|
48
|
+
r'''The value of the line profile in frequency space.
|
|
49
|
+
|
|
50
|
+
The line profile is normalised such that its integral over frequency equals 1.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
nu (float or numpy.ndarray): frequency in [Hz]
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
float or numpy.ndarray: The line profile in [Hz\ :sup:`-1`]
|
|
57
|
+
'''
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
|
|
60
|
+
def phi_v(self,v):
|
|
61
|
+
r'''The value of the line profile in velocity space.
|
|
62
|
+
|
|
63
|
+
The line profile is normalised such that its integral over velocity equals 1.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
v (float or numpy.ndarray): velocity in [m/s]
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
float or numpy.ndarra: The line profile in [(m/s)\ :sup:`-1`]
|
|
70
|
+
'''
|
|
71
|
+
nu = self.nu0*(1-v/constants.c)
|
|
72
|
+
return self.phi_nu(nu)*self.nu0/constants.c
|
|
73
|
+
|
|
74
|
+
def average_over_phi_nu(self,func):
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GaussianLineProfile(LineProfile):
|
|
79
|
+
|
|
80
|
+
"""Represents a Gaussian line profile
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
nu0 (:obj:`float`): rest frequency in [Hz]
|
|
84
|
+
width_nu (:obj:`float`): FWHM in [Hz]
|
|
85
|
+
width_v (:obj:`float`): FWHM in [m/s]
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def initialise_phi_nu_params(self):
|
|
89
|
+
self.sigma_nu = helpers.FWHM2sigma(self.width_nu)
|
|
90
|
+
self.normalisation = 1/(self.sigma_nu*np.sqrt(2*np.pi))
|
|
91
|
+
|
|
92
|
+
def phi_nu(self,nu):
|
|
93
|
+
return self.normalisation*np.exp(-(nu-self.nu0)**2/(2*self.sigma_nu**2))
|
|
94
|
+
|
|
95
|
+
def average_over_phi_nu(self,func):
|
|
96
|
+
#choose +- 1 FWHM initially, where the Gaussian is at ~6% of the peak
|
|
97
|
+
#note that at least two iterations are done, so the final width is
|
|
98
|
+
#at least +-1.65 FWHM
|
|
99
|
+
width = 2 #units of width_nu
|
|
100
|
+
elements_per_width = 20
|
|
101
|
+
old_average = np.inf
|
|
102
|
+
residual = np.inf
|
|
103
|
+
niter = 0
|
|
104
|
+
while residual > 1e-2:
|
|
105
|
+
n_elements = int(elements_per_width*width)
|
|
106
|
+
nu = np.linspace(self.nu0-width/2*self.width_nu,
|
|
107
|
+
self.nu0+width/2*self.width_nu,n_elements)
|
|
108
|
+
phi_nu = self.phi_nu(nu)
|
|
109
|
+
norm = np.trapezoid(y=phi_nu,x=nu)
|
|
110
|
+
new_average = np.trapezoid(y=func(nu)*phi_nu,x=nu)/norm
|
|
111
|
+
residual = helpers.relative_difference(a=np.array((new_average,)),
|
|
112
|
+
b=np.array((old_average,)))
|
|
113
|
+
old_average = new_average
|
|
114
|
+
#I want to cover at least the width corresponding to the definition of
|
|
115
|
+
#overlapping lines, which is 3.14; so add 1.3 to make it 2+1.3=3.3
|
|
116
|
+
width += 1.3
|
|
117
|
+
elements_per_width += 15
|
|
118
|
+
niter += 1
|
|
119
|
+
return new_average
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RectangularLineProfile(LineProfile):
|
|
123
|
+
|
|
124
|
+
'''Represents a rectangular line profile, i.e. constant over width_v, 0 outside'''
|
|
125
|
+
|
|
126
|
+
def initialise_phi_nu_params(self):
|
|
127
|
+
self.normalisation = 1/self.width_nu
|
|
128
|
+
|
|
129
|
+
def phi_nu(self,nu):
|
|
130
|
+
inside_line = (nu>=self.nu0-self.width_nu/2) & (nu<=self.nu0+self.width_nu/2)
|
|
131
|
+
return np.where(inside_line,self.normalisation,0)
|
|
132
|
+
|
|
133
|
+
def average_over_phi_nu(self,func):
|
|
134
|
+
#easier than Gaussian, just have to iterate over n_elements
|
|
135
|
+
#note that the func doesn't need to be constant over the line profile,
|
|
136
|
+
#so I need to have a sufficient number of elements
|
|
137
|
+
n_elements = 20
|
|
138
|
+
old_average = np.inf
|
|
139
|
+
residual = np.inf
|
|
140
|
+
while residual > 1e-2:
|
|
141
|
+
nu = np.linspace(self.nu0-self.width_nu/2,self.nu0+self.width_nu/2,
|
|
142
|
+
n_elements)
|
|
143
|
+
phi_nu = self.phi_nu(nu)
|
|
144
|
+
norm = np.trapezoid(y=phi_nu,x=nu)
|
|
145
|
+
new_average = np.trapezoid(y=func(nu)*phi_nu,x=nu)/norm
|
|
146
|
+
residual = helpers.relative_difference(a=np.array((new_average,)),
|
|
147
|
+
b=np.array((old_average,)))
|
|
148
|
+
old_average = new_average
|
|
149
|
+
n_elements += 15
|
|
150
|
+
return new_average
|
|
151
|
+
|
|
152
|
+
line_profiles = {'Gaussian':GaussianLineProfile,'rectangular':RectangularLineProfile}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Level():
|
|
156
|
+
'''Represents an atomic / molecular level.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
g (:obj:`float`): the statistical weight of the level
|
|
160
|
+
E (:obj:`float`): the energy of the level in [J]
|
|
161
|
+
number (:obj:`int`): the number of the level
|
|
162
|
+
'''
|
|
163
|
+
|
|
164
|
+
def __init__(self,g,E,number):
|
|
165
|
+
self.g = g
|
|
166
|
+
self.E = E
|
|
167
|
+
self.number = number
|
|
168
|
+
|
|
169
|
+
def LTE_level_pop(self,Z,T):
|
|
170
|
+
'''Calculates the fractional population of the level in LTE.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
Z (:obj:`float`): The partition function.
|
|
174
|
+
T (:obj:`float`): The temperature in [K].
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
float: The fractional population of the level in LTE.
|
|
178
|
+
'''
|
|
179
|
+
#this is a convenience function that is not used when solving the non-LTE
|
|
180
|
+
#radiative transfer
|
|
181
|
+
return self.g*np.exp(-self.E/(constants.k*T))/Z
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Transition():
|
|
185
|
+
|
|
186
|
+
def __init__(self,up,low):
|
|
187
|
+
self.up = up
|
|
188
|
+
self.low = low
|
|
189
|
+
self.Delta_E = self.up.E-self.low.E
|
|
190
|
+
self.name = f'{self.up.number}-{self.low.number}'
|
|
191
|
+
|
|
192
|
+
def Tex(self,x1,x2):
|
|
193
|
+
'''Computes the excitation temperature.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
x1: (numpy.ndarray): fractional population of the lower level
|
|
197
|
+
x2: (numpy.ndarray): fractional population of the upper level
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
numpy.ndarray: excitation temperature in [K]
|
|
201
|
+
'''
|
|
202
|
+
return Tex(Delta_E=self.Delta_E,g_up=self.up.g,g_low=self.low.g,
|
|
203
|
+
x1=x1,x2=x2)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class RadiativeTransition(Transition):
|
|
207
|
+
|
|
208
|
+
r'''Represents the radiative transition between two energy levels.
|
|
209
|
+
|
|
210
|
+
Attributes:
|
|
211
|
+
up (pythonradex.atomic_transition.Level): the upper level of the transition
|
|
212
|
+
low (pythonradex.atomic_transition.Level): the lower level of the transition
|
|
213
|
+
Delta_E (:obj:`float`): the energy difference between the upper and lower level
|
|
214
|
+
name (:obj:`str`): name of the transition
|
|
215
|
+
nu0 (:obj:`float`): rest frequency in [Hz]
|
|
216
|
+
A21 (:obj:`float`): Einstein A21 coefficient in [s\ :sup:`-1`]
|
|
217
|
+
B21 (:obj:`float`): Einstein B21 coefficient in [sr m\ :sup:`2` Hz / Jy]
|
|
218
|
+
B12 (:obj:`float`): Einstein B12 coefficient in [sr m\ :sup:`2` Hz / Jy]
|
|
219
|
+
'''
|
|
220
|
+
|
|
221
|
+
def __init__(self,up,low,A21,nu0=None):
|
|
222
|
+
Transition.__init__(self,up=up,low=low)
|
|
223
|
+
assert self.Delta_E > 0, 'non-positive Delta_E for radiative transition'
|
|
224
|
+
self.A21 = A21
|
|
225
|
+
nu0_from_Delta_E = self.Delta_E/constants.h
|
|
226
|
+
if nu0 is None:
|
|
227
|
+
self.nu0 = nu0_from_Delta_E
|
|
228
|
+
else:
|
|
229
|
+
#when reading LAMDA files, it is useful to specify nu0 directly, since
|
|
230
|
+
#it is sometimes given with more significant digits
|
|
231
|
+
assert np.isclose(nu0_from_Delta_E,nu0,atol=0,rtol=1e-3)
|
|
232
|
+
self.nu0 = nu0
|
|
233
|
+
self.B21 = helpers.B21(A21=self.A21,nu=self.nu0)
|
|
234
|
+
self.B12 = helpers.B12(A21=self.A21,nu=self.nu0,g1=self.low.g,g2=self.up.g)
|
|
235
|
+
|
|
236
|
+
def source_function(self,x1,x2):
|
|
237
|
+
S = np.where((x1==0) & (x2==0),0,self.A21*x2/(x1*self.B12-x2*self.B21))
|
|
238
|
+
# if np.any(S < 0):
|
|
239
|
+
# raise RuntimeError('negative source function, potentially due to '
|
|
240
|
+
# +'high columndensity and/or low collider density')
|
|
241
|
+
return S
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class EmissionLine(RadiativeTransition):
|
|
245
|
+
|
|
246
|
+
r'''Represents an emission line arising from the radiative transition between
|
|
247
|
+
two levels
|
|
248
|
+
|
|
249
|
+
Attributes:
|
|
250
|
+
up (pythonradex.atomic_transition.Level): the upper level of the transition
|
|
251
|
+
low (pythonradex.atomic_transition.Level): the lower level of the transition
|
|
252
|
+
Delta_E (:obj:`float`): the energy difference between the upper and lower level
|
|
253
|
+
in [J]
|
|
254
|
+
name (:obj:`str`): name of the transition
|
|
255
|
+
nu0 (:obj:`float`): rest frequency in [Hz]
|
|
256
|
+
A21 (:obj:`float`): Einstein A21 coefficient in [s\ :sup:`-1`]
|
|
257
|
+
B21 (:obj:`float`): Einstein B21 coefficient in [sr m\ :sup:`2` Hz / Jy]
|
|
258
|
+
B12 (:obj:`float`): Einstein B12 coefficient in [sr m\ :sup:`2` Hz / Jy]
|
|
259
|
+
line_profile (pythonradex.atomic_transition.LineProfile): object representing
|
|
260
|
+
the shape of the line profile
|
|
261
|
+
'''
|
|
262
|
+
def __init__(self,up,low,A21,line_profile_type,width_v,nu0=None):
|
|
263
|
+
RadiativeTransition.__init__(self,up=up,low=low,A21=A21,nu0=nu0)
|
|
264
|
+
self.line_profile = line_profiles[line_profile_type](nu0=self.nu0,width_v=width_v)
|
|
265
|
+
self.tau_kwargs = {'A21':self.A21,'g_up':self.up.g,'g_low':self.low.g}
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def from_radiative_transition(cls,radiative_transition,line_profile_type,
|
|
269
|
+
width_v):
|
|
270
|
+
'''Alternative constructor, taking an instance of RadiativeTransition,
|
|
271
|
+
a line profile class and the width of the line'''
|
|
272
|
+
return cls(up=radiative_transition.up,low=radiative_transition.low,
|
|
273
|
+
A21=radiative_transition.A21,line_profile_type=line_profile_type,
|
|
274
|
+
width_v=width_v,nu0=radiative_transition.nu0)
|
|
275
|
+
|
|
276
|
+
def tau_nu(self,N1,N2,nu):
|
|
277
|
+
r'''Computes the optical depth
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
N1: (:obj:`float`): column density of molecules in the lower level
|
|
281
|
+
in [m\ :sup:`-2`]
|
|
282
|
+
N2: (:obj:`float`): column density of molecules in the upper level
|
|
283
|
+
in [m\ :sup:`-2`]
|
|
284
|
+
nu: (numpy.ndarray): frequencies in [Hz]
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
numpy.ndarray: the optical depth at the requested frequencies
|
|
288
|
+
|
|
289
|
+
'''
|
|
290
|
+
return tau_nu(phi_nu=self.line_profile.phi_nu(nu),N1=N1,N2=N2,nu=nu,
|
|
291
|
+
**self.tau_kwargs)
|
|
292
|
+
|
|
293
|
+
def tau_nu0(self,N1,N2):
|
|
294
|
+
r'''Computes the optical depth at the rest frequency
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
N1: (:obj:`float` or numpy.ndarray): column density of molecules
|
|
298
|
+
in the lower level in [m\ :sup:`-2`]
|
|
299
|
+
N2: (:obj:`float` or numpy.ndarray): column density of molecules
|
|
300
|
+
in the upper level in [m\ :sup:`-2`]
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
float or numpy.ndarray: the optical depth at the rest frequency
|
|
304
|
+
|
|
305
|
+
'''
|
|
306
|
+
return tau_nu(phi_nu=self.line_profile.phi_nu0,N1=N1,N2=N2,nu=self.nu0,
|
|
307
|
+
**self.tau_kwargs)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class CollisionalTransition(Transition):
|
|
311
|
+
|
|
312
|
+
'''Represent the collisional transition between two energy levels
|
|
313
|
+
|
|
314
|
+
Attributes:
|
|
315
|
+
up (pythonradex.atomic_transition.Level): the upper level of the transition
|
|
316
|
+
low (pythonradex.atomic_transition.Level): the lower level of the transition
|
|
317
|
+
Delta_E (:obj:`float`): the energy difference between the upper and lower level
|
|
318
|
+
in [J]
|
|
319
|
+
name (:obj:`str`): name of the transition
|
|
320
|
+
'''
|
|
321
|
+
|
|
322
|
+
def __init__(self,up,low,K21_data,Tkin_data):
|
|
323
|
+
Transition.__init__(self,up=up,low=low)
|
|
324
|
+
assert np.all(K21_data >= 0)
|
|
325
|
+
self.K21_data = K21_data
|
|
326
|
+
self.Tkin_data = Tkin_data
|
|
327
|
+
self.Tkin_data_limits = np.min(self.Tkin_data),np.max(self.Tkin_data)
|
|
328
|
+
|
|
329
|
+
def coeffs(self,Tkin):
|
|
330
|
+
r'''
|
|
331
|
+
Computes the collisional coefficients
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
Tkin (float or numpy.ndarray): kinetic temperature in [K]
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
tuple: The collision coefficients K12 and K21 in [m\ :sup:`3`/s]
|
|
338
|
+
at the requested temperature(s)
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
AssertionError: If Tkin is outside the available temperature range.
|
|
342
|
+
'''
|
|
343
|
+
Tmin,Tmax = self.Tkin_data_limits
|
|
344
|
+
assert np.all(Tmin <= Tkin),\
|
|
345
|
+
'requested temperature below minimum collider temperature'
|
|
346
|
+
assert np.all(Tkin <= Tmax),\
|
|
347
|
+
'requested temperature above maximum collider temperature'
|
|
348
|
+
K21 = np.interp(Tkin,self.Tkin_data,self.K21_data)
|
|
349
|
+
K12 = compute_K12(K21=K21,g_up=self.up.g,g_low=self.low.g,Delta_E=self.Delta_E,
|
|
350
|
+
Tkin=Tkin)
|
|
351
|
+
return [K12,K21]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Created on Sun Nov 12 17:31:02 2017
|
|
4
|
+
|
|
5
|
+
@author: gianni
|
|
6
|
+
"""
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numba as nb
|
|
9
|
+
from pythonradex import escape_probability_functions
|
|
10
|
+
from scipy import constants
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EscapeProbabilityUniformSphere():
|
|
14
|
+
|
|
15
|
+
'''Represents the escape probability from a uniform spherical medium.'''
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.beta = escape_probability_functions.beta_uniform_sphere
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UniformSphere(EscapeProbabilityUniformSphere):
|
|
22
|
+
|
|
23
|
+
'''Represents the escape probability and emerging flux from a uniform
|
|
24
|
+
spherical medium'''
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
@nb.jit(nopython=True,cache=True)
|
|
28
|
+
def compute_flux_nu(tau_nu,source_function,solid_angle):
|
|
29
|
+
'''Computes the observed flux in [W/m2/Hz], given the optical depth
|
|
30
|
+
tau_nu, the source function in [W/m2/Hz/sr] and the solid angle in [sr].'''
|
|
31
|
+
#see old Osterbrock book for this formula, appendix 2
|
|
32
|
+
#this is the flux per surface of the emitting region
|
|
33
|
+
#for lower tau_nu, the Osterbrock formula becomes numerically unstable,
|
|
34
|
+
#so we use a Taylor expansion
|
|
35
|
+
min_tau_nu = 1e-2
|
|
36
|
+
stable_region = tau_nu > min_tau_nu
|
|
37
|
+
flux_nu = 2*np.pi*source_function/tau_nu**2\
|
|
38
|
+
*(tau_nu**2/2-1+(tau_nu+1)*np.exp(-tau_nu))
|
|
39
|
+
flux_nu_Taylor = 2*np.pi*source_function*(tau_nu/3-tau_nu**2/8+tau_nu**3/30
|
|
40
|
+
-tau_nu**4/144) #from Wolfram Alpha
|
|
41
|
+
flux_nu = np.where(stable_region,flux_nu,flux_nu_Taylor)
|
|
42
|
+
assert np.all(np.isfinite(flux_nu))
|
|
43
|
+
#the observed flux is (flux at sphere surface)*4*pi*r**2/(4*pi*d**2)
|
|
44
|
+
#=F_surface*Omega*4/(4*pi)
|
|
45
|
+
return flux_nu*solid_angle/np.pi
|
|
46
|
+
|
|
47
|
+
@nb.jit(nopython=True,cache=True)
|
|
48
|
+
def exp_tau_factor(tau_nu):
|
|
49
|
+
#for very small tau, the exp can introduce numerical errors, so we
|
|
50
|
+
#take care of that when computing (1-exp(-tau))
|
|
51
|
+
return np.where(tau_nu<1e-5,tau_nu,1-np.exp(-tau_nu))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Flux1D():
|
|
55
|
+
|
|
56
|
+
'''Represents the computation of the flux when considering only a single direction'''
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
@nb.jit(nopython=True,cache=True)
|
|
60
|
+
def compute_flux_nu(tau_nu,source_function,solid_angle):
|
|
61
|
+
'''Computes the observed flux in [W/m2/Hz], given the optical depth
|
|
62
|
+
tau_nu, the source function in [W/m2/Hz/sr] and the solid angle of
|
|
63
|
+
the source (in [sr]) seen by the observer.'''
|
|
64
|
+
tau_factor = exp_tau_factor(tau_nu=tau_nu)
|
|
65
|
+
return source_function*tau_factor*solid_angle
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class UniformSphereRADEX(EscapeProbabilityUniformSphere,Flux1D):
|
|
69
|
+
"""Represents the escape probability from a uniform sphere, but uses the
|
|
70
|
+
single direction assumption to compute the emerging flux. This is what is
|
|
71
|
+
done in the original RADEX code."""
|
|
72
|
+
#see line 288 and following in io.f of RADEX
|
|
73
|
+
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UniformSlab(Flux1D):
|
|
78
|
+
#Since I assume the source is in the far field, it is ok to calculate the flux
|
|
79
|
+
#with the 1D formula
|
|
80
|
+
'''Represents the escape probability and emerging flux from a uniform
|
|
81
|
+
slab'''
|
|
82
|
+
|
|
83
|
+
def __init__(self):
|
|
84
|
+
self.beta = escape_probability_functions.beta_uniform_slab
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class UniformLVGSlab(Flux1D):
|
|
88
|
+
"""The escape probability and flux for a uniform large velocity gradient (LVG)
|
|
89
|
+
slab"""
|
|
90
|
+
|
|
91
|
+
def __init__(self):
|
|
92
|
+
self.beta = escape_probability_functions.beta_LVG_slab
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class UniformLVGSphere():
|
|
96
|
+
"""The escape probability and flux for a uniform large velocity gradient (LVG)
|
|
97
|
+
sphere"""
|
|
98
|
+
|
|
99
|
+
def __init__(self):
|
|
100
|
+
self.beta = escape_probability_functions.beta_LVG_sphere
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
@nb.jit(nopython=True,cache=True)
|
|
104
|
+
def compute_flux_nu(tau_nu,source_function,solid_angle,nu,nu0,V):
|
|
105
|
+
#V is the velocity at the surface of the sphere
|
|
106
|
+
#this formula can be derived by using an approach similar to
|
|
107
|
+
#de Jong et al. (1975, Fig. 3)
|
|
108
|
+
tau_factor = exp_tau_factor(tau_nu=tau_nu)
|
|
109
|
+
v = constants.c*(1-nu/nu0)
|
|
110
|
+
return source_function*tau_factor*solid_angle*(1-(v/V)**2)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class LVGSphereRADEX(Flux1D):
|
|
114
|
+
"""The escape probability and flux for a large velocity gradient (LVG)
|
|
115
|
+
sphere (Expanding sphere in RADEX terminology), using the same
|
|
116
|
+
formula for the escape probability as the RADEX code"""
|
|
117
|
+
|
|
118
|
+
def __init__(self):
|
|
119
|
+
self.beta = escape_probability_functions.beta_LVG_sphere_RADEX
|