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,548 @@
1
+ from scipy import constants
2
+ import numpy as np
3
+ from pythonradex import helpers,escape_probability,atomic_transition,flux,rate_equations
4
+ from pythonradex.molecule import EmittingMolecule
5
+ import warnings
6
+ import numba as nb
7
+ import numbers
8
+ import traceback
9
+
10
+
11
+ class Cloud():
12
+
13
+ '''
14
+ Solving the non-LTE radiative transfer using escape probabilities.
15
+
16
+ Attributes:
17
+
18
+ emitting_molecule (pythonradex.molecule.EmittingMolecule): object
19
+ containing all the information about the emitting atom or molecule
20
+ tau_nu0_individual_transitions (numpy.ndarray): optical depth of each individual
21
+ transition at the central frequency (contributions of dust or overlapping lines
22
+ to the optical depth are not included)
23
+ level_pop (numpy.ndarray): fractional population of each level
24
+ Tex (numpy.ndarray): excitation temperature of each transition
25
+
26
+ Note:
27
+ The attributes tau_nu0_individual_transitions, level_pop and Tex are available
28
+ only after solving the radiative transfer by calling solve_radiative_transfer
29
+
30
+ '''
31
+ relative_convergence = 1e-6
32
+ min_iter = 10
33
+ max_iter = 1000
34
+ underrelaxation = 0.3 #RADEX uses 0.3
35
+ min_tau_considered_for_convergence = 1e-2
36
+ min_iter_before_ng_acceleration = 4
37
+ ng_acceleration_interval = 4
38
+ slow_variation_limit = 0.1
39
+ geometries = {'uniform sphere':escape_probability.UniformSphere,
40
+ 'uniform sphere RADEX':escape_probability.UniformSphereRADEX,
41
+ 'uniform slab':escape_probability.UniformSlab,
42
+ 'LVG slab':escape_probability.UniformLVGSlab,
43
+ 'LVG sphere':escape_probability.UniformLVGSphere,
44
+ 'LVG sphere RADEX':escape_probability.LVGSphereRADEX}
45
+ line_profiles = {'Gaussian':atomic_transition.GaussianLineProfile,
46
+ 'rectangular':atomic_transition.RectangularLineProfile}
47
+
48
+ def __init__(self,datafilepath,geometry,line_profile_type,width_v,
49
+ use_Ng_acceleration=True,treat_line_overlap=False,
50
+ warn_negative_tau=True,verbose=False,test_mode=False):
51
+ '''Initialises a new instance of the Cloud class.
52
+
53
+ Args:
54
+ datafilepath (:obj:`str`): filepath to the LAMDA file that contains
55
+ the atomic / molecular data
56
+ geometry (:obj:`str`): determines how the escape probability
57
+ and flux are calculated. Available options: "uniform sphere",
58
+ "uniform sphere RADEX", "uniform slab", "LVG slab", "LVG sphere" and
59
+ "LVG sphere RADEX". The options containing "RADEX" are meant to
60
+ mimic the behaviour of the original RADEX code by using the same
61
+ equations as RADEX.
62
+ line_profile_type (:obj:`str`): The type of the line profile.
63
+ Available options: "rectangular" or "Gaussian". Note that for
64
+ LVG geometries, only "rectangular" is allowed.
65
+ width_v (:obj:`float`): The width of the line profile in [m/s]. For a Gaussian
66
+ profile, this is interpreted as the FWHM. Note that the interpretation
67
+ of this parameter depends on the adopted geometry. For the static
68
+ geometries, width_v is the width of the intrinsic emission profile.
69
+ On the other hand, for LVG geometries (for which the line profile
70
+ is rectangular), width_v corresponds to the total velocity width
71
+ of the cloud. So, for "LVG sphere", width_v=2*V, where V is the
72
+ velocity at the surface of the sphere. In terms of the constant
73
+ velocity gradient dv/dr=V/R (with R the radius of the sphere),
74
+ we can also say width_v=dv/dr*2*R. For "LVG slab", width_v=dv/dz*Z
75
+ where Z is the depth of the slab and dv/dz the constant velocity
76
+ gradient of the slab.
77
+ use_Ng_acceleration (:obj:`bool`): Whether to use Ng acceleration. Defaults
78
+ to True.
79
+ treat_line_overlap (:obj:`bool`): Whether to treat the overlap of emission
80
+ lines by averaging over the line profile. If False, all calculations
81
+ are done at the rest frequency (same as RADEX), and overlap effects
82
+ are neglected. If True, overlapping lines are treated correctly by
83
+ performing averages over frequency. This slows down the calculation
84
+ considerably. Defaults to False. Can only be used in combination
85
+ with static geometries (i.e. not with LVG geometries).
86
+ warn_negative_tau (:obj:`bool`): Whether the raise a warning when negative
87
+ optical depth is encountered. Defaults to True. Setting this to False
88
+ is useful when calculating a grid of models.
89
+ verbose (:obj:`bool`): Whether to print additional information. Defaults to False.
90
+ test_mode (:obj:`bool`): Enter test mode. Only for developer, should not be used
91
+ by general user. Defaults to False.
92
+
93
+ '''
94
+ assert width_v < 10000*constants.kilo,\
95
+ 'assumption of small nu0/Delta_nu for flux calculation not satisfied'
96
+ self.emitting_molecule = EmittingMolecule(
97
+ datafilepath=datafilepath,
98
+ line_profile_type=line_profile_type,
99
+ width_v=width_v)
100
+ self.treat_line_overlap = treat_line_overlap
101
+ self.geometry_name = geometry
102
+ if self.treat_line_overlap:
103
+ if 'LVG' in self.geometry_name:
104
+ #this is because in LVG, the assumption is that once the radiation
105
+ #escapes from a local slab, it also escapes the cloud. With overlapping
106
+ #lines this assumption breaks down
107
+ raise ValueError('treatment of overlapping lines currently not'
108
+ +' supported for LVG geometries')
109
+ if self.emitting_molecule.has_overlapping_lines and not self.treat_line_overlap:
110
+ warnings.warn('some lines are overlapping, but treatement of'
111
+ +' overlapping lines not activated')
112
+ if verbose:
113
+ for i in range(len(self.emitting_molecule.overlapping_lines)):
114
+ if len(self.emitting_molecule.overlapping_lines[i]) > 0:
115
+ print(f'identified overlaps for line {i}:'
116
+ +f' {self.emitting_molecule.overlapping_lines[i]}')
117
+ if 'LVG' in self.geometry_name and not test_mode:
118
+ if not self.emitting_molecule.line_profile_type == 'rectangular':
119
+ #this is to get the right optical depth which is independent of
120
+ #velocity in this model (e.g. Eq. 13 in de Jong et al. 1975)
121
+ raise ValueError(f'{self.geometry_name} requires a rectangular profile')
122
+ #velocity at the surface of a LVG sphere
123
+ self.V_LVG_sphere = self.emitting_molecule.width_v/2
124
+ self.geometry = self.geometries[geometry]()
125
+ self.use_Ng_acceleration = use_Ng_acceleration
126
+ self.warn_negative_tau = warn_negative_tau
127
+ self.verbose = verbose
128
+ self.Einstein_kwargs = {'B12':self.emitting_molecule.B12,
129
+ 'B21':self.emitting_molecule.B21,
130
+ 'A21':self.emitting_molecule.A21}
131
+
132
+ def check_parameters(self,collider_densities,T_dust,tau_dust,ext_background):
133
+ if collider_densities is not None:
134
+ for collider in collider_densities.keys():
135
+ if collider not in self.emitting_molecule.coll_transitions:
136
+ raise ValueError(f'no data for collider "{collider}" available')
137
+ if 'LVG' in self.geometry_name:
138
+ if not (T_dust in (None,0) and tau_dust in (None,0)):
139
+ #this is because for LVG, it is assumed that radiation escaping
140
+ #the local slab will escape the entire cloud, which is not true
141
+ #if there is dust
142
+ raise ValueError('including dust continuum is currently not'
143
+ +' supported for LVG geometries')
144
+
145
+ @staticmethod
146
+ def should_be_updated(new_func_or_number,old):
147
+ #if it is a number, I can decide whether to update or not; if it is a function,
148
+ #I always update
149
+ if new_func_or_number is None:
150
+ return False
151
+ if isinstance(new_func_or_number,numbers.Number):
152
+ return new_func_or_number != old
153
+ else:
154
+ #if it's not a number and not None, then always update
155
+ return True
156
+
157
+ def update_parameters(self,N=None,Tkin=None,collider_densities=None,
158
+ ext_background=None,T_dust=None,tau_dust=None):
159
+ r'''Set or update the parameters for a radiative transfer calculation.
160
+ Any of the parameters can be set to None if no update of that
161
+ parameter is wished.
162
+
163
+ Args:
164
+ ext_background (func, number or None): A function taking the
165
+ frequency in Hz as input and returning the background radiation
166
+ field in [W/m\ :sup:`2`/Hz/sr]. A single number is interpreted as
167
+ a constant value for all frequencies. Defaults to None
168
+ (i.e. do not update).
169
+ N (:obj:`float` or None): The column density in [m\ :sup:`-2`].
170
+ Defaults to None (i.e. do not update).
171
+ Tkin (:obj:`float` or None): The kinetic temperature of the gas in [K].
172
+ Defaults to None (i.e. do not update).
173
+ collider_densities (dict or None): A dictionary of the number densities of
174
+ each collider that should be considered, in units of [m\ :sup:`-3`]. The
175
+ following keys are recognised: "H2", "para-H2", "ortho-H2", "e",
176
+ "H", "He", "H+". Defaults to None (i.e. do not update).
177
+ T_dust (func, number or None): The dust temperature in [K] as a
178
+ function of frequency. It is assumed that the source function
179
+ of the dust is a black body at temperature T_dust. A single number
180
+ is interpreted as a constant temperature for all frequencies.
181
+ Can only be used with static geometries (i.e. not with LVG geometries).
182
+ Defaults to None (i.e. do not update). For a model without dust,
183
+ put this parameter to 0.
184
+ tau_dust (func, number or None): optical depth of the dust as a function of
185
+ frequency. A single number is interpreted as a constant optical
186
+ depth for all frequencies. Can only be used with static geometries
187
+ (i.e. not with LVG geometries). Defaults to None (i.e. do not update).
188
+ For a model without dust, put this parameter to 0.
189
+
190
+ '''
191
+ #why do I put this into a seperate method rather than __init__? The reason
192
+ #is that the stuff in __init__ is expensive (in particular reading the
193
+ #LAMDA file). So if I explore e.g. a grid of N, the stuff in __init__
194
+ #should only be done once to save computation time. Then this method
195
+ #can be called inside the loop over e.g. the N values
196
+ #note that setting up the rate equations is expensive, so I only do it if
197
+ #necessary
198
+ #unfortunately a fairly complicated function, couldn't find any easier way...
199
+ self.check_parameters(collider_densities=collider_densities,T_dust=T_dust,
200
+ tau_dust=tau_dust,ext_background=ext_background)
201
+ if not hasattr(self,'rate_equations'):
202
+ params = {'N':N,'Tkin':Tkin,'collider_densities':collider_densities,
203
+ 'ext_background':ext_background,'T_dust':T_dust,
204
+ 'tau_dust':tau_dust}
205
+ for p_name,p in params.items():
206
+ assert p is not None,\
207
+ f'for initial setup, all params need to be defined; {p_name} missing'
208
+ self.rate_equations = rate_equations.RateEquations(
209
+ molecule=self.emitting_molecule,
210
+ treat_line_overlap=self.treat_line_overlap,
211
+ geometry=self.geometry,**params)
212
+ else:
213
+ if N is not None:
214
+ #updating N should be very cheap, so I don't care if I really need
215
+ #to update or not...
216
+ self.rate_equations.set_N(N)
217
+ update_Tkin = self.should_be_updated(
218
+ new_func_or_number=Tkin,old=self.rate_equations.Tkin)
219
+ update_coll_densities = self.should_be_updated(
220
+ new_func_or_number=collider_densities,
221
+ old=self.rate_equations.collider_densities)
222
+ if update_Tkin or update_coll_densities:
223
+ Tkin_updated = Tkin if update_Tkin else self.rate_equations.Tkin
224
+ coll_dens_updated = collider_densities if update_coll_densities else\
225
+ self.rate_equations.collider_densities
226
+ self.rate_equations.set_collision_rates(
227
+ Tkin=Tkin_updated,collider_densities=coll_dens_updated)
228
+ if self.should_be_updated(new_func_or_number=ext_background,
229
+ old=self.rate_equations.ext_background):
230
+ self.rate_equations.set_ext_background(ext_background=ext_background)
231
+ update_T_dust = self.should_be_updated(
232
+ new_func_or_number=T_dust,old=self.rate_equations.T_dust)
233
+ update_tau_dust = self.should_be_updated(
234
+ new_func_or_number=tau_dust,
235
+ old=self.rate_equations.tau_dust)
236
+ if update_T_dust or update_tau_dust:
237
+ Td = T_dust if update_T_dust else self.rate_equations.T_dust
238
+ taud = tau_dust if update_tau_dust else self.rate_equations.tau_dust
239
+ self.rate_equations.set_dust(T_dust=Td,tau_dust=taud)
240
+
241
+ def get_initial_level_pop(self):
242
+ #assume LTE for the first iteration
243
+ LTE_level_pop = self.emitting_molecule.LTE_level_pop(T=self.rate_equations.Tkin)
244
+ return self.rate_equations.solve(level_population=LTE_level_pop)
245
+
246
+ @staticmethod
247
+ @nb.jit(nopython=True,cache=True)
248
+ def compute_residual(Tex_residual,tau,min_tau):
249
+ #same as RADEX: consider only lines above a minimum tau for convergence
250
+ selection = tau > min_tau
251
+ n_selected = selection.sum()
252
+ if n_selected > 0:
253
+ return np.sum(Tex_residual[selection]) / n_selected
254
+ else:
255
+ return 0
256
+
257
+ @staticmethod
258
+ @nb.jit(nopython=True,cache=True)
259
+ def ng_accelerate(level_pop,old_level_pops):
260
+ #implemented in the same way as JADEX
261
+ #https://github.com/autocorr/Jadex.jl/blob/main/src/solver.jl
262
+ #see Dullemond lecture notes (ALI_basics_and_NGAcceleratin.pdf) for the
263
+ #formulas
264
+ #the first element of old_level_pops is the oldest level population
265
+ x1 = level_pop
266
+ x2 = old_level_pops[2]
267
+ x3 = old_level_pops[1]
268
+ x4 = old_level_pops[0]
269
+ q1 = x1 - 2*x2 + x3
270
+ q2 = x1 - x2 - x3 + x4
271
+ q3 = x1 - x2
272
+ A1 = np.sum(q1*q1)
273
+ A2 = np.sum(q2*q1)
274
+ B1 = A2 # q1*q2
275
+ B2 = np.sum(q2*q2)
276
+ C1 = np.sum(q1*q3)
277
+ C2 = np.sum(q2*q3)
278
+ denom = A1 * B2 - A2 * B1
279
+ if denom > 0:
280
+ a = (C1 * B2 - C2 * B1) / denom
281
+ b = (C2 * A1 - C1 * A2) / denom
282
+ accelerated_level_pop = (1 - a - b) * x1 + a * x2 + b * x3
283
+ else:
284
+ accelerated_level_pop = level_pop
285
+ return accelerated_level_pop
286
+
287
+ def solve_radiative_transfer(self):
288
+ """Solves the radiative transfer."""
289
+ level_pop = self.get_initial_level_pop()
290
+ #typed list because this will be used by compiled function ng_accelerate:
291
+ old_level_pops = nb.typed.List([])
292
+ Tex_residual = np.ones(self.emitting_molecule.n_rad_transitions) * np.inf
293
+ old_Tex = 0
294
+ counter = 0
295
+ residual = np.inf
296
+ while residual>self.relative_convergence or counter < self.min_iter:
297
+ counter += 1
298
+ if counter%10 == 0 and self.verbose:
299
+ print(f'iteration {counter}')
300
+ if counter > self.max_iter:
301
+ raise RuntimeError('maximum number of iterations reached')
302
+ new_level_pop = self.rate_equations.solve(level_population=level_pop)
303
+ Tex = self.emitting_molecule.get_Tex(new_level_pop)
304
+ Tex_residual = helpers.relative_difference(Tex,old_Tex)
305
+ if self.verbose:
306
+ print(f'max relative Tex residual: {np.max(Tex_residual):.3g}')
307
+ tau_nu0 = self.emitting_molecule.get_tau_nu0_lines(
308
+ N=self.rate_equations.N,level_population=new_level_pop)
309
+ residual = self.compute_residual(
310
+ Tex_residual=Tex_residual,tau=tau_nu0,
311
+ min_tau=self.min_tau_considered_for_convergence)
312
+ old_Tex = Tex.copy()
313
+ old_level_pops.append(level_pop.copy())
314
+ if len(old_level_pops) > 3:
315
+ old_level_pops.pop(0)
316
+ level_pop = self.underrelaxation*new_level_pop\
317
+ + (1-self.underrelaxation)*level_pop
318
+ if self.use_Ng_acceleration\
319
+ and counter > self.min_iter_before_ng_acceleration\
320
+ and counter%self.ng_acceleration_interval == 0:
321
+ level_pop = self.ng_accelerate(level_pop=level_pop,
322
+ old_level_pops=old_level_pops)
323
+ if self.verbose:
324
+ print(f'converged in {counter} iterations')
325
+ self.n_iter_convergence = counter
326
+ self.tau_nu0_individual_transitions = self.emitting_molecule.get_tau_nu0_lines(
327
+ N=self.rate_equations.N,level_population=level_pop)
328
+ if self.warn_negative_tau:
329
+ if np.any(self.tau_nu0_individual_transitions < 0):
330
+ negative_tau_transition_indices = np.where(self.tau_nu0_individual_transitions < 0)[0]
331
+ negative_tau_transitions = [self.emitting_molecule.rad_transitions[i]
332
+ for i in negative_tau_transition_indices]
333
+ warnings.warn('negative optical depth!')
334
+ for i,trans in zip(negative_tau_transition_indices,
335
+ negative_tau_transitions):
336
+ print(f'{trans.name}: tau_nu0 = {self.tau_nu0_individual_transitions[i]:.3g}')
337
+ self.level_pop = level_pop
338
+ self.Tex = self.emitting_molecule.get_Tex(level_pop)
339
+ self.flux_calculator = flux.FluxCalculator(
340
+ emitting_molecule=self.emitting_molecule,
341
+ level_population=self.level_pop,
342
+ geometry_name=self.geometry_name,
343
+ V_LVG_sphere=self.V_LVG_sphere,
344
+ compute_flux_nu=self.geometry.compute_flux_nu,
345
+ tau_nu0_individual_transitions=self.tau_nu0_individual_transitions,
346
+ tau_dust=self.rate_equations.tau_dust,
347
+ S_dust=self.rate_equations.S_dust)
348
+
349
+ def fluxes_of_individual_transitions(self,solid_angle,transitions):
350
+ r''' Calculate the fluxes of individual lines.
351
+ The flux of individual lines is the amount of
352
+ energy per time reaching the telescope via photons emitted by the molecule.
353
+ This calculation is only easily possible if the dust is optically thin (i.e.
354
+ the dust does not hinder line photons from escaping the cloud). Thus, this
355
+ function throws an error if the dust is not optically thin. Similarly,
356
+ the calculation is not possible when lines are overlapping and are not
357
+ optically thin. It is the responsibility of the user to choose an
358
+ appropriate observational quantity to be compared to the line fluxes
359
+ calculated here. In particular, for optically thin lines, the
360
+ continuum-subtracted observation might be appropriate. On the other
361
+ hand, for optically thick lines, the non-continuum-subtracted
362
+ observations might be more appropriate (because the optically thick
363
+ line blocks the continuum at the line enter; see e.g. Weaver et al. 2018)
364
+
365
+ Args:
366
+ solid_angle (:obj:`float`): The solid angle of the source in [sr].
367
+ transitions (:obj:`list` of :obj:`int` or None): The indices of the
368
+ transitions for which to calculate the fluxes. If None, then the
369
+ fluxes of all transitions are calculated. Defaults to None. The
370
+ indices correspond to the list of transitions in the LAMDA file,
371
+ starting with 0.
372
+
373
+ Returns:
374
+ list: The list of fluxes in [W/m\ :sup:`2`] corresponding to the
375
+ input list of requested transitions. If not specific transitions
376
+ where requested (transitions=None), then the list of fluxes corresponds
377
+ to the transitions as listed in the LAMDA file.
378
+ '''
379
+ return self.flux_calculator.fluxes_of_individual_transitions(
380
+ solid_angle=solid_angle,transitions=transitions)
381
+
382
+ def tau_nu(self,nu):
383
+ r''' Calculate the total optical depth (all lines plus dust) at each
384
+ input frequencies
385
+
386
+ Args:
387
+ nu (numpy.ndarray): The frequencies in [Hz] for which the optical depth
388
+ should be calculated
389
+
390
+ Returns:
391
+ np.ndarray: The total optical depth at the input frequencies.
392
+ '''
393
+ self.flux_calculator.set_nu(nu=nu)
394
+ return self.flux_calculator.tau_nu_tot
395
+
396
+ def spectrum(self,solid_angle,nu):
397
+ r''' Calculate the total flux (lines + dust) at each input frequency
398
+
399
+ Args:
400
+ solid_angle (:obj:`float`): The solid angle of the source in [sr].
401
+ nu (numpy.ndarray): The frequencies in [Hz] for which the optical depth
402
+ should be calculated
403
+
404
+ Returns:
405
+ np.ndarray: The flux in [W/m2/Hz] for each input frequency.
406
+ '''
407
+ self.flux_calculator.set_nu(nu=nu)
408
+ return self.flux_calculator.spectrum(solid_angle=solid_angle)
409
+
410
+ def model_grid(self,ext_backgrounds,N_values,Tkin_values,collider_densities_values,
411
+ requested_output,T_dust=0,tau_dust=0,solid_angle=None,
412
+ transitions=None,nu=None):
413
+ r'''Iterator over a grid of models.
414
+ Models are calculated for all possible combinations of the input
415
+ parameters ext_backgrounds, N_values, Tkin_values and
416
+ collider_densities_values.
417
+
418
+ Args:
419
+ ext_backgrounds (:obj:`dict`): A dictionary, one entry for
420
+ each background that should be used. Each entry can be a function
421
+ of frequency, or a single number (interpreted as a radiation field
422
+ independent of frequency). The units are W/m2/Hz/sr. The keys
423
+ of the dictionary are used in the output to identify which
424
+ background was used.
425
+ N_values (:obj:`list` or numpy.ndarray): The list of column densities to
426
+ compute models for, in [m\ :sup:`-2`].
427
+ Tkin_values (:obj:`list` or numpy.ndarray): The list of kinetic temperatures
428
+ to compute models for, in [K].
429
+ collider_densities_values (:obj:`dict`): A dictionary with one entry for each
430
+ collider. Each entry is a list of densities for which models should be
431
+ computed for, using a "zip" logic (i.e. calculate a model for the first
432
+ entries of each list, for the second entries of each list, etc).
433
+ Units are [m\ :sup:`-3`]. Example:
434
+ collider_densities_values={'para-H2':[400,600],'ortho-H2':[700,1000]}
435
+ requested_output (:obj:`list`): The list of requested outputs. Possible
436
+ entries are 'level_pop', 'Tex', 'tau_nu0_individual_transitions',
437
+ 'fluxes_of_individual_transitions', 'tau_nu', and 'spectrum'
438
+ T_dust (func or number): The dust temperature in [K] as a function of frequency.
439
+ It is assumed that the source function of the dust is a black body
440
+ at temperature T_dust. A single number is interpreted as a constant value
441
+ for all frequencies. Defaults to 0 (i.e. no internal dust
442
+ radiation field).
443
+ tau_dust (func or number): optical depth of the dust as a function of frequency.
444
+ A single number is interpreted as a constant value
445
+ for all frequencies. Defaults to 0 (i.e. no internal dust
446
+ radiation field).
447
+ solid_angle (:obj:`float`): The solid angle of the source in [sr].
448
+ Defaults to None. Compulsory if 'fluxes_of_individual_transitions'
449
+ or 'spectrum' are requested.
450
+ transitions (:obj:`list`): The indices of the transitions for which
451
+ to calculate Tex, tau_nu and fluxes. If None, then
452
+ values for all transitions are calculated. Defaults to None. The
453
+ indices are relative to the list of transitions in the LAMDA file,
454
+ starting with 0.
455
+ nu (numpy.ndarray): The frequencies in [Hz]. Compulsory if 'tau_nu'
456
+ or 'spectrum' is requested. Defaults to None.
457
+
458
+ Returns:
459
+ dict: dictionary representing the model
460
+ The dictionary fields are 'ext_background', 'N', 'Tkin' and
461
+ 'collider_densities' to identify the input parameters of the model,
462
+ as well as any requested output. Units of outputs:
463
+ 'level_pop': no units; 'Tex': [K]; 'tau_nu0': no units;
464
+ 'fluxes_of_individual_transitions': [W/m\ :sup:`2`];
465
+ 'tau_nu': no units; 'spectrum': [W/m\ :sup:`2`/Hz]. If the model
466
+ for a specific set of parameters could not be calculated, the output
467
+ fields are None.
468
+ '''
469
+ #it is expensive to update Tkin and collider densities, so those should be in
470
+ #the outermost loops
471
+ allowed_outputs = ('level_pop','Tex','tau_nu0_individual_transitions',
472
+ 'fluxes_of_individual_transitions','tau_nu','spectrum')
473
+ for request in requested_output:
474
+ assert request in allowed_outputs,f'requested output "{request}" is invalid'
475
+ if 'fluxes_of_individual_transitions' in requested_output\
476
+ or 'spectrum' in requested_output:
477
+ assert solid_angle is not None
478
+ if 'tau_nu' in requested_output or 'spectrum' in requested_output:
479
+ assert nu is not None
480
+ if transitions is None:
481
+ transitions = [i for i in range(self.emitting_molecule.n_rad_transitions)]
482
+ n_coll_values = np.array([len(coll_values) for coll_values in
483
+ collider_densities_values.values()])
484
+ assert np.all(n_coll_values==n_coll_values[0]),\
485
+ 'please provide the same number of collider densities for each collider'
486
+ n_coll_values = n_coll_values[0]
487
+ #set T_dust and tau_dust, other values are not important because they are
488
+ #going to change in the loop
489
+ initial_ext_bg = list(ext_backgrounds.values())[0]
490
+ initial_coll_dens = {coll_name:coll_values[0] for coll_name,coll_values
491
+ in collider_densities_values.items()}
492
+ self.update_parameters(
493
+ ext_background=initial_ext_bg,N=N_values[0],Tkin=Tkin_values[0],
494
+ collider_densities=initial_coll_dens,T_dust=T_dust,
495
+ tau_dust=tau_dust)
496
+ for Tkin in Tkin_values:
497
+ for i in range(n_coll_values):
498
+ collider_densities = {collider:values[i] for collider,values
499
+ in collider_densities_values.items()}
500
+ #note updating Tkin and coll dens together to avoid
501
+ #unnecessary overhead
502
+ self.update_parameters(Tkin=Tkin,collider_densities=collider_densities)
503
+ for ext_background_name,ext_background in ext_backgrounds.items():
504
+ self.update_parameters(ext_background=ext_background)
505
+ for N in N_values:
506
+ output = {'ext_background':ext_background_name,'N':N,
507
+ 'Tkin':Tkin,'collider_densities':collider_densities}
508
+ try:
509
+ self.update_parameters(N=N)
510
+ self.solve_radiative_transfer()
511
+ if 'level_pop' in requested_output:
512
+ output['level_pop'] = self.level_pop
513
+ if 'Tex' in requested_output:
514
+ output['Tex'] = self.Tex[transitions]
515
+ if 'tau_nu0_individual_transitions' in requested_output:
516
+ output['tau_nu0_individual_transitions']\
517
+ = self.tau_nu0_individual_transitions[transitions]
518
+ if 'fluxes_of_individual_transitions' in requested_output:
519
+ output['fluxes_of_individual_transitions']\
520
+ = self.fluxes_of_individual_transitions(
521
+ solid_angle=solid_angle,transitions=transitions)
522
+ if 'tau_nu' in requested_output:
523
+ output['tau_nu'] = self.tau_nu(nu=nu)
524
+ if 'spectrum' in requested_output:
525
+ output['spectrum'] = self.spectrum(
526
+ solid_angle=solid_angle,nu=nu)
527
+ yield output
528
+ except:
529
+ print('Error during calculation of model with following'
530
+ +f' parameters: {output}')
531
+ traceback.print_exc()
532
+ for out in requested_output:
533
+ output[out] = None
534
+ yield output
535
+
536
+ def print_results(self):
537
+ '''Prints the results from the radiative transfer computation.'''
538
+ print('\n')
539
+ print(' up low nu0 [GHz] T_ex [K] poplow popup'\
540
+ +' tau_nu0')
541
+ for i,line in enumerate(self.emitting_molecule.rad_transitions):
542
+ output = f'{line.up.number:>4d} {line.low.number:>4d} '\
543
+ +f'{line.nu0/constants.giga:>14.6f} {self.Tex[i]:>10.2f} '\
544
+ +f'{self.level_pop[line.low.number]:>14g} '\
545
+ +f'{self.level_pop[line.up.number]:>14g} '\
546
+ +f'{self.tau_nu0_individual_transitions[i]:>14g}'
547
+ print(output)
548
+ print('\n')