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,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Created on Thu May 23 11:10:38 2024
|
|
5
|
+
|
|
6
|
+
@author: gianni
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numba as nb
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
#It would be much more elegant to put these functions directly into the
|
|
14
|
+
#classes defined in escape_probability.py. But I want to compile the beta functions
|
|
15
|
+
#with numba, so I have to put these functions outside the class definitions
|
|
16
|
+
#(defining them as staticmethod does not work here)
|
|
17
|
+
|
|
18
|
+
min_reliable_tau = -1
|
|
19
|
+
|
|
20
|
+
@nb.jit(nopython=True,cache=True)
|
|
21
|
+
def clip_prob(prob):
|
|
22
|
+
return np.where(prob>1,1,np.where(prob<0,0,prob))
|
|
23
|
+
|
|
24
|
+
####### functions for escape probability using Taylor expansion ######
|
|
25
|
+
|
|
26
|
+
@nb.jit(nopython=True,cache=True)
|
|
27
|
+
def identify_tau_regions(tau_nu):
|
|
28
|
+
tau_epsilon = 0.05 #important that this is not too small, to avoid the unstable region
|
|
29
|
+
normal_tau_region = tau_nu > tau_epsilon
|
|
30
|
+
small_tau_region = np.abs(tau_nu) <= tau_epsilon
|
|
31
|
+
negative_tau_region = (tau_nu >= min_reliable_tau) & (tau_nu<-tau_epsilon)
|
|
32
|
+
unreliable_negative_region = tau_nu < min_reliable_tau
|
|
33
|
+
total_region_points = normal_tau_region.sum()+small_tau_region.sum()\
|
|
34
|
+
+negative_tau_region.sum()+unreliable_negative_region.sum()
|
|
35
|
+
assert total_region_points==len(tau_nu),\
|
|
36
|
+
'selection needs to cover all points of tau_nu'
|
|
37
|
+
return normal_tau_region,small_tau_region,negative_tau_region,\
|
|
38
|
+
unreliable_negative_region
|
|
39
|
+
|
|
40
|
+
@nb.jit(nopython=True,cache=True)
|
|
41
|
+
def beta_analytical_uniform_sphere(tau_nu):
|
|
42
|
+
'''Computes the escape probability for a uniform sphere analytically,
|
|
43
|
+
given the optical depth tau_nu'''
|
|
44
|
+
#see the RADEX manual for this formula; derivation is found in the old
|
|
45
|
+
#Osterbrock (1974) book, appendix 2. Note that Osterbrock uses tau for
|
|
46
|
+
#radius, while I use it for diameter
|
|
47
|
+
return 1.5/tau_nu*(1-2/tau_nu**2+(2/tau_nu+2/tau_nu**2)*np.exp(-tau_nu))
|
|
48
|
+
|
|
49
|
+
@nb.jit(nopython=True,cache=True)
|
|
50
|
+
def beta_Taylor_uniform_sphere(tau_nu):
|
|
51
|
+
'''Computes the escape probability of a uniform sphere using a Taylor expansion,
|
|
52
|
+
given the optical depth tau_nu'''
|
|
53
|
+
#Taylor expansion of beta for uniform sphere is easier to evaluate numerically
|
|
54
|
+
#(for small tau_nu)
|
|
55
|
+
#Series calculated using Wolfram Alpha; not so easy analytically, to
|
|
56
|
+
#calculate the limit as tau->0, use rule of L'Hopital
|
|
57
|
+
return 1 - 0.375*tau_nu + 0.1*tau_nu**2 - 0.0208333*tau_nu**3
|
|
58
|
+
|
|
59
|
+
@nb.jit(nopython=True,cache=True)
|
|
60
|
+
def beta_analytical_LVG_slab(tau_nu):
|
|
61
|
+
#see line 357 in matrix.f and RADEX paper, or Scoville & Solomon (1974)
|
|
62
|
+
return (1-np.exp(-3*tau_nu))/(3*tau_nu)
|
|
63
|
+
|
|
64
|
+
@nb.jit(nopython=True,cache=True)
|
|
65
|
+
def beta_Taylor_LVG_slab(tau_nu):
|
|
66
|
+
return 1 - (3*tau_nu)/2 + (3*tau_nu**2)/2 - (9*tau_nu**3)/8
|
|
67
|
+
|
|
68
|
+
#about the LVG sphere
|
|
69
|
+
#we need to assume that the LVG sphere is homogeneous, because from formula 2.6.36
|
|
70
|
+
#in Elitzur92, it becomes clear that the optical depth in a velocity-
|
|
71
|
+
#coherent element depends on the local number density. But since pythonradex assumes
|
|
72
|
+
#uniform excitation temperature and a single optical depth characerising the source,
|
|
73
|
+
#the local number density must be the same everywhere
|
|
74
|
+
#Thus, we need to assume that the LVG sphere is uniform, otherwise we cannot handle it
|
|
75
|
+
#in pythonradex.
|
|
76
|
+
#We are indeed considering the model by Goldreich & Kwan (1974) which has uniform density.
|
|
77
|
+
#in fact, same argument also implies that LVG slab needs to be uniform...
|
|
78
|
+
|
|
79
|
+
@nb.jit(nopython=True,cache=True)
|
|
80
|
+
def beta_analytical_LVG_sphere(tau_nu):
|
|
81
|
+
#e.g Elitzur p. 44, or Ramos & Elitzur 2018, eq. 14
|
|
82
|
+
return (1-np.exp(-tau_nu))/tau_nu
|
|
83
|
+
|
|
84
|
+
@nb.jit(nopython=True,cache=True)
|
|
85
|
+
def beta_Taylor_LVG_sphere(tau_nu):
|
|
86
|
+
#from Wolfram Alpha
|
|
87
|
+
return 1 - tau_nu/2 + tau_nu**2/6 - tau_nu**3/24
|
|
88
|
+
|
|
89
|
+
def generate_Taylor_beta(beta_ana,beta_Taylor):
|
|
90
|
+
@nb.jit(nopython=True,cache=True)
|
|
91
|
+
def beta(tau_nu):
|
|
92
|
+
#use Taylor expansion if tau is below epsilon; this is to avoid numerical
|
|
93
|
+
#problems with the anlytical formula to compute the escape probability.
|
|
94
|
+
#Concerning negative optical depth, the RADEX paper advises that
|
|
95
|
+
#negativ tau (inverted population) cannot be treated correctly by a
|
|
96
|
+
#non-local code like RADEX, and that results for lines with tau <~ -1
|
|
97
|
+
#should be ignored. Moreover, negative tau can make the code crash:
|
|
98
|
+
#very negative tau leads to very large transition rates, which makes the
|
|
99
|
+
#matrix of the rate equations ill-conditioned.
|
|
100
|
+
#thus, for tau < -1, I just take abs(tau). Note that this is different
|
|
101
|
+
#from RADEX: they make something quite strange: they have different
|
|
102
|
+
#approximations for positive large, normal and small tau. But then they
|
|
103
|
+
#use abs(tau) to decide which function to use, but then use tau in that
|
|
104
|
+
#function (line 333 in matrix.f)
|
|
105
|
+
normal,small,negative,unreliable = identify_tau_regions(tau_nu=tau_nu)
|
|
106
|
+
prob = np.empty_like(tau_nu)
|
|
107
|
+
prob[normal] = beta_ana(tau_nu[normal])
|
|
108
|
+
#here I use tau even if tau < 0:
|
|
109
|
+
prob[small] = beta_Taylor(tau_nu[small])
|
|
110
|
+
prob[negative] = beta_ana(tau_nu[negative])
|
|
111
|
+
#here I just use abs(tau) to stabilize the code
|
|
112
|
+
prob[unreliable] = beta_ana(np.abs(tau_nu[unreliable]))
|
|
113
|
+
assert np.all(np.isfinite(prob))
|
|
114
|
+
return clip_prob(prob)
|
|
115
|
+
return beta
|
|
116
|
+
|
|
117
|
+
beta_uniform_sphere = generate_Taylor_beta(beta_ana=beta_analytical_uniform_sphere,
|
|
118
|
+
beta_Taylor=beta_Taylor_uniform_sphere)
|
|
119
|
+
beta_LVG_slab = generate_Taylor_beta(beta_ana=beta_analytical_LVG_slab,
|
|
120
|
+
beta_Taylor=beta_Taylor_LVG_slab)
|
|
121
|
+
beta_LVG_sphere = generate_Taylor_beta(beta_ana=beta_analytical_LVG_sphere,
|
|
122
|
+
beta_Taylor=beta_Taylor_LVG_sphere)
|
|
123
|
+
|
|
124
|
+
######### functions for uniform slab ##################
|
|
125
|
+
#see Elitzur92 (https://ui.adsabs.harvard.edu/abs/1992ASSL..170.....E/abstract,
|
|
126
|
+
#Problem 2.12) for confirmation of the formulas used here
|
|
127
|
+
|
|
128
|
+
@nb.jit(nopython=True,cache=True)
|
|
129
|
+
def integral_term_for_uniform_slab(tau):
|
|
130
|
+
mu = np.linspace(1e-5,1,200)
|
|
131
|
+
return np.trapezoid((1-np.exp(-tau/mu))*mu,mu)
|
|
132
|
+
|
|
133
|
+
tau_grid_for_UniformSlab = np.logspace(-5,2,1000)
|
|
134
|
+
min_grid_tau = np.min(tau_grid_for_UniformSlab)
|
|
135
|
+
max_grid_tau = np.max(tau_grid_for_UniformSlab)
|
|
136
|
+
#the expression for the flux contains an integral term;
|
|
137
|
+
#here I pre-compute this term so it can be interpolated to speed up the code
|
|
138
|
+
integral_term_grid = np.array([integral_term_for_uniform_slab(tau)
|
|
139
|
+
for tau in tau_grid_for_UniformSlab])
|
|
140
|
+
|
|
141
|
+
@nb.jit(nopython=True,cache=True)
|
|
142
|
+
def interpolated_integral_term(tau):
|
|
143
|
+
interp = np.interp(x=tau,xp=tau_grid_for_UniformSlab,fp=integral_term_grid)
|
|
144
|
+
#limiting values:
|
|
145
|
+
# -very large tau: integral term goes to 0.5
|
|
146
|
+
# -very small tau: integral term goes to tau
|
|
147
|
+
#note that I cannot use the left and right keywords of np.interp because
|
|
148
|
+
#I want to compile this function; so I use np.where instead
|
|
149
|
+
return np.where(tau>max_grid_tau,0.5,np.where(tau<min_grid_tau,tau,interp))
|
|
150
|
+
|
|
151
|
+
@nb.jit(nopython=True,cache=True)
|
|
152
|
+
def beta_uniform_slab(tau_nu):
|
|
153
|
+
#for negative tau, this function will also return 1, which is fine if tau
|
|
154
|
+
#is close to 0, but not correct for very negative tau; in general, results
|
|
155
|
+
#should be ignored for tau < -1
|
|
156
|
+
int_term = interpolated_integral_term(tau=tau_nu)
|
|
157
|
+
prob = np.where(tau_nu<min_grid_tau,1,int_term/tau_nu)
|
|
158
|
+
assert np.all(np.isfinite(prob))
|
|
159
|
+
return clip_prob(prob)
|
|
160
|
+
|
|
161
|
+
######### LVG sphere as implemented by RADEX ##################
|
|
162
|
+
|
|
163
|
+
@nb.jit(nopython=True,cache=True)
|
|
164
|
+
def beta_LVG_sphere_RADEX_gtr7(tau_nu):
|
|
165
|
+
tau_nu_r = tau_nu/2
|
|
166
|
+
return 2/(tau_nu_r*4*(np.sqrt(np.log(tau_nu_r/np.sqrt(np.pi)))))
|
|
167
|
+
|
|
168
|
+
@nb.jit(nopython=True,cache=True)
|
|
169
|
+
def beta_LVG_sphere_RADEX_less7(tau_nu):
|
|
170
|
+
tau_nu_r = tau_nu/2
|
|
171
|
+
return 2*(1-np.exp(-2.34*tau_nu_r))/(4.68*tau_nu_r)
|
|
172
|
+
|
|
173
|
+
@nb.jit(nopython=True,cache=True)
|
|
174
|
+
def beta_LVG_sphere_RADEX(tau_nu):
|
|
175
|
+
#RADEX paper, eq. 18, claims that they use beta_analytical_LVG_sphere
|
|
176
|
+
#(beta= (1-exp(-tau))/tau
|
|
177
|
+
#but in fact they use a different formula from de Jong+80
|
|
178
|
+
#see line 346 in matrix.f
|
|
179
|
+
#I use the same formulae as RADEX, except that I take care of negative tau
|
|
180
|
+
#handling negative tau turns out to be important, e.g. for CO, Tkin=200,
|
|
181
|
+
#width_v=3 km/s, Ntot=1e20 cm-2, RADEX gives an invalid solution
|
|
182
|
+
assert -7 < min_reliable_tau < -0.01
|
|
183
|
+
gtr7 = 7 <= tau_nu
|
|
184
|
+
less7 = (0.001 <= tau_nu) & (tau_nu < 7)
|
|
185
|
+
small = (-0.001 <= tau_nu) & (tau_nu < 0.001)
|
|
186
|
+
negative = (min_reliable_tau <= tau_nu) & (tau_nu < -0.001)
|
|
187
|
+
unreliable_less7 = (-7 <= tau_nu) & (tau_nu < min_reliable_tau)
|
|
188
|
+
unreliable_gtr7 = tau_nu < -7
|
|
189
|
+
assert gtr7.sum()+less7.sum()+small.sum()+negative.sum()+unreliable_less7.sum()\
|
|
190
|
+
+unreliable_gtr7.sum() == tau_nu.size
|
|
191
|
+
beta = np.empty_like(tau_nu)
|
|
192
|
+
beta[gtr7] = beta_LVG_sphere_RADEX_gtr7(tau_nu[gtr7])
|
|
193
|
+
beta[less7] = beta_LVG_sphere_RADEX_less7(tau_nu[less7])
|
|
194
|
+
beta[small] = 1.0
|
|
195
|
+
beta[negative] = beta_LVG_sphere_RADEX_less7(tau_nu[negative])
|
|
196
|
+
beta[unreliable_less7] = beta_LVG_sphere_RADEX_less7(np.abs(tau_nu[unreliable_less7]))
|
|
197
|
+
beta[unreliable_gtr7] = beta_LVG_sphere_RADEX_gtr7(np.abs(tau_nu[unreliable_gtr7]))
|
|
198
|
+
return clip_prob(beta)
|
pythonradex/flux.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Created on Fri Aug 2 10:32:03 2024
|
|
5
|
+
|
|
6
|
+
@author: gianni
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from scipy import constants
|
|
10
|
+
from pythonradex import helpers
|
|
11
|
+
import numpy as np
|
|
12
|
+
import warnings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FluxCalculator():
|
|
16
|
+
|
|
17
|
+
tau_peak_fraction = 1e-2
|
|
18
|
+
nu_per_FHWM = 20
|
|
19
|
+
n_nu_elements = {'regular':15,'LVG sphere':51}
|
|
20
|
+
|
|
21
|
+
def __init__(self,emitting_molecule,level_population,geometry_name,
|
|
22
|
+
V_LVG_sphere,compute_flux_nu,tau_nu0_individual_transitions,
|
|
23
|
+
tau_dust,S_dust):
|
|
24
|
+
self.emitting_molecule = emitting_molecule
|
|
25
|
+
self.level_population = level_population
|
|
26
|
+
self.geometry_name = geometry_name
|
|
27
|
+
self.V_LVG_sphere = V_LVG_sphere
|
|
28
|
+
self.Tex = self.emitting_molecule.get_Tex(level_population=level_population)
|
|
29
|
+
self.compute_flux_nu = compute_flux_nu
|
|
30
|
+
self.tau_nu0_individual_transitions = tau_nu0_individual_transitions
|
|
31
|
+
self.tau_dust = tau_dust
|
|
32
|
+
self.S_dust = S_dust
|
|
33
|
+
self.is_LVG_sphere = self.geometry_name == 'LVG sphere'
|
|
34
|
+
|
|
35
|
+
def get_flux_parameters_for_rectangular_flux(self,transitions):
|
|
36
|
+
n_nu_elements = self.n_nu_elements['LVG sphere'] if self.is_LVG_sphere\
|
|
37
|
+
else self.n_nu_elements['regular']
|
|
38
|
+
assert n_nu_elements%2 == 1,\
|
|
39
|
+
'need odd number of elements to include nu0 in the array'
|
|
40
|
+
middle_index = n_nu_elements//2
|
|
41
|
+
nu0 = self.emitting_molecule.nu0[transitions]
|
|
42
|
+
width_nu = self.emitting_molecule.width_v/constants.c*nu0
|
|
43
|
+
# a matrix where first index is transition, second index is nu
|
|
44
|
+
nu = np.linspace(nu0-width_nu/2,nu0+width_nu/2,n_nu_elements,axis=-1)
|
|
45
|
+
tau_nu = 1/nu**2
|
|
46
|
+
tau_nu0 = self.tau_nu0_individual_transitions[transitions]
|
|
47
|
+
tau_nu *= tau_nu0[:,None]/tau_nu[:,middle_index][:,None]
|
|
48
|
+
source_function = helpers.B_nu(T=self.Tex[transitions][:,None],nu=nu)
|
|
49
|
+
return nu,tau_nu,source_function
|
|
50
|
+
|
|
51
|
+
def fast_line_fluxes_rectangular_without_overlap(self,solid_angle,transitions):
|
|
52
|
+
#note that integrated fluxes only make sense for non-overlapping lines,
|
|
53
|
+
#so this function can only be used for non-overlapping lines
|
|
54
|
+
nu,tau_nu,source_function = self.get_flux_parameters_for_rectangular_flux(
|
|
55
|
+
transitions=transitions)
|
|
56
|
+
kwargs = {'tau_nu':tau_nu,'source_function':source_function,
|
|
57
|
+
'solid_angle':solid_angle}
|
|
58
|
+
if self.is_LVG_sphere:
|
|
59
|
+
kwargs['nu'] = nu
|
|
60
|
+
kwargs['nu0'] = self.emitting_molecule.nu0[transitions][:,None]
|
|
61
|
+
kwargs['V'] = self.V_LVG_sphere
|
|
62
|
+
flux_nu = self.compute_flux_nu(**kwargs)
|
|
63
|
+
flux = np.trapezoid(flux_nu,nu,axis=1)
|
|
64
|
+
return flux
|
|
65
|
+
|
|
66
|
+
def fast_line_fluxes_Gaussian_without_overlap(self,solid_angle,transitions):
|
|
67
|
+
nu0 = self.emitting_molecule.nu0[transitions]
|
|
68
|
+
FWHM_nu = self.emitting_molecule.width_v/constants.c*nu0
|
|
69
|
+
sigma_nu = helpers.FWHM2sigma(FWHM_nu)
|
|
70
|
+
#for thin emission, I just want to include out to a certain fraction
|
|
71
|
+
#of the peak
|
|
72
|
+
#but for thick emission, the spectrum is saturated, so a fraction of the
|
|
73
|
+
#peak is not useful; in those cases, I need to set an absolute value
|
|
74
|
+
#for the minimum tau to include
|
|
75
|
+
tau_nu0 = self.tau_nu0_individual_transitions[transitions]
|
|
76
|
+
peak_frac = self.tau_peak_fraction*tau_nu0
|
|
77
|
+
min_tau = np.where(peak_frac>0.01,0.01,peak_frac)
|
|
78
|
+
#calculate the distance in freq spac between tau_nu0 and min_tau:
|
|
79
|
+
#(min_tau = tau_nu0*exp(-(nu-nu0)**2/(2*sigma_nu**2))
|
|
80
|
+
Delta_nu = sigma_nu*np.sqrt(-2*np.log(min_tau/tau_nu0))
|
|
81
|
+
n_nu = np.max((2*Delta_nu/FWHM_nu * self.nu_per_FHWM).astype(int))
|
|
82
|
+
#first index transition, second index frequency:
|
|
83
|
+
nu = np.linspace(nu0-Delta_nu,nu0+Delta_nu,n_nu,axis=-1)
|
|
84
|
+
#doing an approximation: in principle, tau has an additional 1/nu**2 dependence,
|
|
85
|
+
#but if Delta_nu is small compared to nu0, that dependence is negligible
|
|
86
|
+
phi_nu_shape = np.exp(-(nu-nu0[:,None])**2/(2*sigma_nu[:,None]**2))
|
|
87
|
+
tau_nu = tau_nu0[:,None]*phi_nu_shape
|
|
88
|
+
source_function = helpers.B_nu(T=self.Tex[:,None][transitions],nu=nu)
|
|
89
|
+
spectrum = self.compute_flux_nu(tau_nu=tau_nu,source_function=source_function,
|
|
90
|
+
solid_angle=solid_angle)
|
|
91
|
+
flux = np.trapezoid(spectrum,nu,axis=1)
|
|
92
|
+
return flux
|
|
93
|
+
|
|
94
|
+
def all_transitions(self):
|
|
95
|
+
return list(range(self.emitting_molecule.n_rad_transitions))
|
|
96
|
+
|
|
97
|
+
def determine_tau_of_overlapping_lines(self,transitions):
|
|
98
|
+
total_tau = []
|
|
99
|
+
for i in transitions:
|
|
100
|
+
tau = 0
|
|
101
|
+
for j in self.emitting_molecule.overlapping_lines[i]:
|
|
102
|
+
tau += self.tau_nu0_individual_transitions[j]
|
|
103
|
+
total_tau.append(tau)
|
|
104
|
+
return np.array(total_tau)
|
|
105
|
+
|
|
106
|
+
def fluxes_of_individual_transitions(self,solid_angle,transitions=None):
|
|
107
|
+
max_acceptable_tau = 0.1 #for dust and overlapping lines
|
|
108
|
+
if transitions is None:
|
|
109
|
+
transitions = self.all_transitions()
|
|
110
|
+
if self.emitting_molecule.any_line_has_overlap(transitions):
|
|
111
|
+
tau_overlapping_lines = self.determine_tau_of_overlapping_lines(
|
|
112
|
+
transitions=transitions)
|
|
113
|
+
if np.any(tau_overlapping_lines>max_acceptable_tau):
|
|
114
|
+
raise ValueError('fluxes of individual lines can only be calculated'
|
|
115
|
+
+' for non-overlapping lines or thin overlapping lines')
|
|
116
|
+
if np.any(self.tau_dust(self.emitting_molecule.nu0) > max_acceptable_tau):
|
|
117
|
+
raise ValueError('dust is not optically thin, cannot calculate'+
|
|
118
|
+
' fluxes of individual lines')
|
|
119
|
+
if self.emitting_molecule.line_profile_type == 'Gaussian':
|
|
120
|
+
fast_flux = self.fast_line_fluxes_Gaussian_without_overlap(
|
|
121
|
+
solid_angle=solid_angle,transitions=transitions)
|
|
122
|
+
elif self.emitting_molecule.line_profile_type == 'rectangular':
|
|
123
|
+
fast_flux = self.fast_line_fluxes_rectangular_without_overlap(
|
|
124
|
+
solid_angle=solid_angle,transitions=transitions)
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError(f'line profile {self.emitting_molecule.line_profile_type} '
|
|
127
|
+
+'unknown')
|
|
128
|
+
return np.squeeze(fast_flux)
|
|
129
|
+
|
|
130
|
+
def set_nu(self,nu):
|
|
131
|
+
self.nu = nu
|
|
132
|
+
self.nu_selected_lines,self.nu_selected_line_indices = self.identify_lines()
|
|
133
|
+
self.tau_dust_nu = self.tau_dust(self.nu)
|
|
134
|
+
self.set_tau_nu_lines()
|
|
135
|
+
self.set_tau_nu_tot()
|
|
136
|
+
|
|
137
|
+
def identify_lines(self):
|
|
138
|
+
nu_min,nu_max = np.min(self.nu),np.max(self.nu)
|
|
139
|
+
selected_lines = []
|
|
140
|
+
selected_line_indices = []
|
|
141
|
+
for i,line in enumerate(self.emitting_molecule.rad_transitions):
|
|
142
|
+
if nu_min <= line.nu0 and line.nu0 <= nu_max:
|
|
143
|
+
selected_line_indices.append(i)
|
|
144
|
+
selected_lines.append(line)
|
|
145
|
+
return selected_lines,selected_line_indices
|
|
146
|
+
|
|
147
|
+
def construct_tau_nu_individual_line(self,line,nu,tau_nu0):
|
|
148
|
+
#tau is prop to phi_nu/nu**2
|
|
149
|
+
normalisation = line.line_profile.phi_nu0/line.nu0**2 / tau_nu0
|
|
150
|
+
return line.line_profile.phi_nu(nu)/nu**2 / normalisation
|
|
151
|
+
|
|
152
|
+
def set_tau_nu_lines(self):
|
|
153
|
+
self.tau_nu_lines = []
|
|
154
|
+
for line_index,line in zip(self.nu_selected_line_indices,self.nu_selected_lines):
|
|
155
|
+
tau = self.construct_tau_nu_individual_line(
|
|
156
|
+
line=line,nu=self.nu,
|
|
157
|
+
tau_nu0=self.tau_nu0_individual_transitions[line_index])
|
|
158
|
+
self.tau_nu_lines.append(tau)
|
|
159
|
+
|
|
160
|
+
def set_tau_nu_tot(self):
|
|
161
|
+
#note that this function automatically takes overlapping lines into account
|
|
162
|
+
#note also that I cannot use the get_tau_tot_nu method of the
|
|
163
|
+
#emitting_molecule, because that method only considers a single line
|
|
164
|
+
#and its overlaps
|
|
165
|
+
self.tau_nu_tot = np.sum(self.tau_nu_lines,axis=0) + self.tau_dust_nu
|
|
166
|
+
|
|
167
|
+
def get_S_tot(self):
|
|
168
|
+
S_nu = np.zeros_like(self.nu)
|
|
169
|
+
for i,line in enumerate(self.nu_selected_lines):
|
|
170
|
+
x1 = self.level_population[line.low.number]
|
|
171
|
+
x2 = self.level_population[line.up.number]
|
|
172
|
+
S_line = line.source_function(x1=x1,x2=x2)
|
|
173
|
+
S_nu += self.tau_nu_lines[i]*S_line
|
|
174
|
+
S_nu += self.S_dust(self.nu)*self.tau_dust_nu
|
|
175
|
+
S_tot = np.where(self.tau_nu_tot==0,0,S_nu/self.tau_nu_tot)
|
|
176
|
+
return S_tot
|
|
177
|
+
|
|
178
|
+
def spectrum(self,solid_angle):
|
|
179
|
+
#note that this function gives the right answer also in cases where
|
|
180
|
+
#line overlap treatment is not necessary
|
|
181
|
+
if self.is_LVG_sphere:
|
|
182
|
+
#assumption for LVG sphere: lines do not overlap and there is no dust,
|
|
183
|
+
#so I can just sum them up
|
|
184
|
+
#this also works for overlapping lines in the optically thin regime
|
|
185
|
+
#reason for this special case: the formula to compute the flux for an LVG
|
|
186
|
+
#sphere depends on nu0, which is not well defined if there are several
|
|
187
|
+
#lines
|
|
188
|
+
if self.emitting_molecule.any_line_has_overlap(
|
|
189
|
+
line_indices=self.nu_selected_line_indices):
|
|
190
|
+
warnings.warn('LVG sphere geometry: lines are overlapping, '
|
|
191
|
+
+'output spectrum will only be correct if all lines'
|
|
192
|
+
+' are optically thin!')
|
|
193
|
+
for dust_func in (self.S_dust,self.tau_dust):
|
|
194
|
+
assert np.all(dust_func(self.nu)==0),'LVG does not support dust'
|
|
195
|
+
spectrum = np.zeros_like(self.nu)
|
|
196
|
+
for line_index,line,tau_nu_line in zip(self.nu_selected_line_indices,
|
|
197
|
+
self.nu_selected_lines,
|
|
198
|
+
self.tau_nu_lines):
|
|
199
|
+
LVG_sphere_kwargs = {'nu':self.nu,'nu0':line.nu0,'V':self.V_LVG_sphere}
|
|
200
|
+
source_function = helpers.B_nu(T=self.Tex[line_index],nu=self.nu)
|
|
201
|
+
spectrum += self.compute_flux_nu(
|
|
202
|
+
tau_nu=tau_nu_line,source_function=source_function,
|
|
203
|
+
solid_angle=solid_angle,**LVG_sphere_kwargs)
|
|
204
|
+
else:
|
|
205
|
+
source_function = self.get_S_tot()
|
|
206
|
+
spectrum = self.compute_flux_nu(
|
|
207
|
+
tau_nu=self.tau_nu_tot,source_function=source_function,
|
|
208
|
+
solid_angle=solid_angle)
|
|
209
|
+
return spectrum
|
pythonradex/helpers.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy import constants
|
|
5
|
+
import numba as nb
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@nb.jit(nopython=True,cache=True)
|
|
9
|
+
def B21(A21,nu):
|
|
10
|
+
return constants.c**2/(2*constants.h*nu**3)*A21
|
|
11
|
+
|
|
12
|
+
@nb.jit(nopython=True,cache=True)
|
|
13
|
+
def B12(A21,nu,g1,g2):
|
|
14
|
+
return g2/g1*B21(A21=A21,nu=nu)
|
|
15
|
+
|
|
16
|
+
@nb.jit(nopython=True,cache=True)
|
|
17
|
+
def B_nu(nu,T):
|
|
18
|
+
r"""Planck function (black body)
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
nu (:obj:`float` or numpy.ndarray): frequency in [Hz]
|
|
22
|
+
T (:obj:`float` or numpy.ndarray): temperature in [K]
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
float or numpy.ndarray: value of Planck function in [W/m\ :sup:`2`/Hz/sr]
|
|
26
|
+
"""
|
|
27
|
+
return (2*constants.h*nu**3/constants.c**2
|
|
28
|
+
*(np.exp(constants.h*nu/(constants.k*T))-1)**-1)
|
|
29
|
+
|
|
30
|
+
def generate_CMB_background(z=0):
|
|
31
|
+
r'''generates a function that gives the CMB background at redshift z
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
z: (:obj:`float`): redshift
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
func: a function giving CMB background in [W/m\ :sup:`2`/Hz/sr] for an input frequency in [Hz]
|
|
38
|
+
'''
|
|
39
|
+
T_CMB = 2.73*(1+z)
|
|
40
|
+
def CMB_background(nu):
|
|
41
|
+
return B_nu(nu=nu,T=T_CMB)
|
|
42
|
+
return CMB_background
|
|
43
|
+
|
|
44
|
+
@nb.jit(nopython=True,cache=True)
|
|
45
|
+
def FWHM2sigma(FWHM):
|
|
46
|
+
"""Convert FWHM of a Gaussian to standard deviation.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
FWHM (:obj:`float` or numpy.ndarray): FWHM of the Gaussian
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
float or numpy.ndarray: the standard deviation corresponding to the input FWHM"""
|
|
53
|
+
return FWHM/(2*np.sqrt(2*np.log(2)))
|
|
54
|
+
|
|
55
|
+
@nb.jit(nopython=True,cache=True)
|
|
56
|
+
def relative_difference(a,b):
|
|
57
|
+
"""Computes the elementwise relative difference between a and b.
|
|
58
|
+
In general, return |a-b|/a.
|
|
59
|
+
Special cases:
|
|
60
|
+
a=0 and b=0: return 0
|
|
61
|
+
a=0 and b!=0: return 1"""
|
|
62
|
+
abs_diff = np.abs(a-b)
|
|
63
|
+
rel_diff = np.where((a==0) & (b==0),0,np.where(a==0,1,abs_diff/a))
|
|
64
|
+
assert not np.any(np.isnan(rel_diff))
|
|
65
|
+
return np.abs(rel_diff)
|
|
66
|
+
|
|
67
|
+
@nb.jit(nopython=True,cache=True)
|
|
68
|
+
def Delta_nu(Delta_v,nu0):
|
|
69
|
+
return nu0 * Delta_v / constants.c
|