reaxion 0.1.1__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.
- reaxion/__init__.py +5 -0
- reaxion/data/__init__.py +1 -0
- reaxion/data/atomic_weights.py +123 -0
- reaxion/data/solar_abundances.py +49 -0
- reaxion/eos/__init__.py +1 -0
- reaxion/eos/eos.py +3 -0
- reaxion/equation.py +41 -0
- reaxion/equation_system.py +380 -0
- reaxion/localstate.py +21 -0
- reaxion/misc.py +66 -0
- reaxion/networks/__init__.py +0 -0
- reaxion/numerics/__init__.py +1 -0
- reaxion/numerics/solvers.py +98 -0
- reaxion/numerics/tests/__init__.py +0 -0
- reaxion/numerics/tests/test_linear.py +33 -0
- reaxion/numerics/tests/test_newton_rootsolve.py +34 -0
- reaxion/process.py +126 -0
- reaxion/processes/__init__.py +7 -0
- reaxion/processes/freefree_emission.py +32 -0
- reaxion/processes/ionization.py +95 -0
- reaxion/processes/line_cooling.py +56 -0
- reaxion/processes/nbody_process.py +71 -0
- reaxion/processes/recombination.py +112 -0
- reaxion/processes/thermal_process.py +22 -0
- reaxion/symbols.py +57 -0
- reaxion-0.1.1.dist-info/METADATA +411 -0
- reaxion-0.1.1.dist-info/RECORD +30 -0
- reaxion-0.1.1.dist-info/WHEEL +5 -0
- reaxion-0.1.1.dist-info/licenses/LICENSE +21 -0
- reaxion-0.1.1.dist-info/top_level.txt +1 -0
reaxion/misc.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Various convenience routines used throughout the package"""
|
|
2
|
+
|
|
3
|
+
from string import digits
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def species_charge(species: str) -> int:
|
|
7
|
+
"""Returns the charge number of a species from its name"""
|
|
8
|
+
if species[-1] not in ("-", "+"):
|
|
9
|
+
return 0
|
|
10
|
+
if "++" in species:
|
|
11
|
+
return 2
|
|
12
|
+
elif "--" in species:
|
|
13
|
+
return -2
|
|
14
|
+
|
|
15
|
+
base = base_species(species)
|
|
16
|
+
suffix = species.split(base)[-1]
|
|
17
|
+
if suffix == "+":
|
|
18
|
+
return 1
|
|
19
|
+
if suffix == "-":
|
|
20
|
+
return -1
|
|
21
|
+
elif "+" in suffix:
|
|
22
|
+
return int(suffix.rstrip("+"))
|
|
23
|
+
else:
|
|
24
|
+
return -int(suffix.rstrip("-"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_an_ion(species: str) -> bool:
|
|
28
|
+
return species_charge(species) != 0 and species != "e-"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def base_species(species: str) -> str:
|
|
32
|
+
"""Removes the charge suffix from a species"""
|
|
33
|
+
base = species.rstrip(digits + "-+")
|
|
34
|
+
return base
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def charge_suffix(charge: int) -> str:
|
|
38
|
+
"""Returns the suffix for an input charge number"""
|
|
39
|
+
match charge:
|
|
40
|
+
case 0:
|
|
41
|
+
return ""
|
|
42
|
+
case 1:
|
|
43
|
+
return "+"
|
|
44
|
+
case 2:
|
|
45
|
+
return "++"
|
|
46
|
+
case -1:
|
|
47
|
+
return "-"
|
|
48
|
+
case -2:
|
|
49
|
+
return "--"
|
|
50
|
+
case _:
|
|
51
|
+
if charge < -2:
|
|
52
|
+
return str(abs(charge)) + "-"
|
|
53
|
+
else:
|
|
54
|
+
return str(abs(charge)) + "+"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ionize(species: str) -> str:
|
|
58
|
+
"""Returns the symbol of the species produced by removing an electron from the input species"""
|
|
59
|
+
charge = species_charge(species)
|
|
60
|
+
return base_species(species) + charge_suffix(charge + 1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def recombine(species: str) -> str:
|
|
64
|
+
"""Returns the symbol of the species produced by adding an electron to the input species"""
|
|
65
|
+
charge = species_charge(species)
|
|
66
|
+
return base_species(species) + charge_suffix(charge - 1)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .solvers import *
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import jax, jax.numpy as jnp
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def newton_rootsolve(
|
|
5
|
+
func,
|
|
6
|
+
guesses,
|
|
7
|
+
params=[],
|
|
8
|
+
jacfunc=None,
|
|
9
|
+
tolfunc=None,
|
|
10
|
+
rtol=1e-6,
|
|
11
|
+
max_iter=100,
|
|
12
|
+
careful_steps=1,
|
|
13
|
+
nonnegative=False,
|
|
14
|
+
return_num_iter=False,
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Solve the system f(X,p) = 0 for X, where both f and X can be vectors of arbitrary length and p is a set of fixed
|
|
18
|
+
parameters passed to f. Broadcasts and parallelizes over an arbitrary number of initial guesses and parameter
|
|
19
|
+
choices.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
func: callable
|
|
24
|
+
A JAX function of signature f(X,params) that implements the function we wish to rootfind, where X and params
|
|
25
|
+
are arrays of shape (n,) and (n_p,) for dimension n and parameter number n_p. In general can return an array of
|
|
26
|
+
shape (m,)
|
|
27
|
+
guesses: array_like
|
|
28
|
+
Shape (n,) or (N,n) array_like where N is the number of guesses + corresponding parameter choices
|
|
29
|
+
params: array_like
|
|
30
|
+
Shape (n,) or (N,n_p) array_like where N is the number of guesses + corresponding parameter choices
|
|
31
|
+
jacfunc: callable, optional
|
|
32
|
+
Function with the same signature as f that returns the Jacobian of f - will be computed with autodiff from f if
|
|
33
|
+
not specified.
|
|
34
|
+
rtol: float, optional
|
|
35
|
+
Relative tolerance - iteration will terminate if relative change in all quantities is less than this value.
|
|
36
|
+
atol: float, optional
|
|
37
|
+
Absolute tolerance: iteration will terminate if the value computed by tolfunc goes below this value.
|
|
38
|
+
careful_steps: int, optional
|
|
39
|
+
Number of "careful" initial steps to take, gradually ramping up the step size in the Newton iteration
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
X: array_like
|
|
44
|
+
Shape (N,n) array of solutions
|
|
45
|
+
"""
|
|
46
|
+
BIG, SMALL = 1e37, 1e-37
|
|
47
|
+
|
|
48
|
+
guesses = jnp.array(guesses)
|
|
49
|
+
guesses = jnp.where(nonnegative, guesses.clip(SMALL), guesses)
|
|
50
|
+
params = jnp.array(params)
|
|
51
|
+
if len(guesses.shape) < 2:
|
|
52
|
+
guesses = jnp.atleast_2d(guesses).T
|
|
53
|
+
if len(params.shape) < 2:
|
|
54
|
+
params = jnp.atleast_2d(params).T
|
|
55
|
+
|
|
56
|
+
if jacfunc is None:
|
|
57
|
+
jac = jax.jacfwd(func)
|
|
58
|
+
|
|
59
|
+
if tolfunc is None:
|
|
60
|
+
|
|
61
|
+
def tolfunc(X, *params):
|
|
62
|
+
return X
|
|
63
|
+
|
|
64
|
+
def solve(guess, params):
|
|
65
|
+
"""Function to be called in parallel that solves the root problem for one guess and set of parameters"""
|
|
66
|
+
|
|
67
|
+
def iter_condition(arg):
|
|
68
|
+
"""Iteration condition for the while loop: check if we are within desired tolerance."""
|
|
69
|
+
X, dx, num_iter = arg
|
|
70
|
+
fac = jnp.min(jnp.array([(num_iter + 1.0) / careful_steps, 1.0]))
|
|
71
|
+
tol2, tol1 = tolfunc(X, *params), tolfunc(X - dx, *params)
|
|
72
|
+
tolcheck = jnp.any(jnp.abs(tol1 - tol2) > rtol * jnp.abs(tol1) * fac)
|
|
73
|
+
return jnp.any(jnp.abs(dx) > fac * rtol * jnp.abs(X)) & (num_iter < max_iter) & tolcheck
|
|
74
|
+
|
|
75
|
+
def X_new(arg):
|
|
76
|
+
"""Returns the next Newton iterate and the difference from previous guess."""
|
|
77
|
+
X, _, num_iter = arg
|
|
78
|
+
fac = jnp.min(jnp.array([(num_iter + 1.0) / careful_steps, 1.0]))
|
|
79
|
+
J = jac(X, *params)
|
|
80
|
+
# condition number is nice but possibly very slow due to batching, e.g. https://github.com/jax-ml/jax/issues/11321
|
|
81
|
+
# there is no reason for this from a pure FLOPS standpoint!
|
|
82
|
+
# cond = jsp.linalg.cond(J) # , p=2)
|
|
83
|
+
# dx = jnp.where(cond < 1e30, -jnp.linalg.solve(J, func(X, *params)) * fac, jnp.zeros_like(X))
|
|
84
|
+
dx = -jnp.linalg.solve(J, func(X, *params)) * fac
|
|
85
|
+
dx_finite = jnp.all(jnp.isfinite(dx))
|
|
86
|
+
Xnew = jnp.where(dx_finite, jnp.where(nonnegative, (X + dx).clip(SMALL), X + dx), X)
|
|
87
|
+
return Xnew, dx, num_iter + 1
|
|
88
|
+
|
|
89
|
+
init_val = guess, 100 * guess, 0
|
|
90
|
+
X, _, num_iter = jax.lax.while_loop(iter_condition, X_new, init_val)
|
|
91
|
+
|
|
92
|
+
return X, num_iter
|
|
93
|
+
|
|
94
|
+
X, num_iter = jax.vmap(solve)(guesses, params)
|
|
95
|
+
if return_num_iter:
|
|
96
|
+
return X, num_iter
|
|
97
|
+
else:
|
|
98
|
+
return X
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import jax, jax.numpy as jnp
|
|
3
|
+
from ..solvers import newton_rootsolve
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_newton_rootsolve_linear(N=10**5, dim=10):
|
|
7
|
+
"""Test: solve a linear system of dim variables with the Newton solver and check the solution.
|
|
8
|
+
Solver should get the right answer to machine precision.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
A = np.random.rand(N, dim, dim)
|
|
12
|
+
cond = np.linalg.cond(A)
|
|
13
|
+
A, cond = A[cond < 1e3], cond[cond < 1e3] # just take the easy ones, we don't need to be fancy here
|
|
14
|
+
N = len(A)
|
|
15
|
+
b = np.random.rand(N, dim, 1)
|
|
16
|
+
sol = np.linalg.solve(A, b)[:, :, 0]
|
|
17
|
+
b = b[:, :, 0]
|
|
18
|
+
|
|
19
|
+
@jax.jit
|
|
20
|
+
def func(x, *params):
|
|
21
|
+
"""horrid function for mapping X, *params signature to linear residual Ax-b"""
|
|
22
|
+
dim = x.shape[0]
|
|
23
|
+
A = jnp.array(params)[: dim * dim].reshape((dim, dim))
|
|
24
|
+
b = jnp.array(params[dim * dim : dim * (dim + 1)])
|
|
25
|
+
return jnp.matmul(A, x) - b
|
|
26
|
+
|
|
27
|
+
p = np.c_[A.reshape(N, dim * dim), b.reshape(N, dim)]
|
|
28
|
+
guesses = np.copy(b)
|
|
29
|
+
sol_newton = newton_rootsolve(func, guesses, p) # [:,:,None]
|
|
30
|
+
|
|
31
|
+
error = np.sum((sol_newton - sol) ** 2, axis=1) ** 0.5
|
|
32
|
+
norm = np.sum(sol**2, axis=1)
|
|
33
|
+
assert np.all(error < 1e-2 * norm)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import jax, jax.numpy as jnp
|
|
3
|
+
import reaxion
|
|
4
|
+
from reaxion.numerics.solvers import newton_rootsolve
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_newton_rootsolve(N=10**5):
|
|
8
|
+
"""Test: solve the system x^p = a for various choices of p and a and check solution
|
|
9
|
+
|
|
10
|
+
NOTE: this newton iteration does not converge in general, so we will not catch all possible bugs that might return
|
|
11
|
+
nonfinite values...
|
|
12
|
+
"""
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
p = 0.1 + np.random.rand(N) * 10
|
|
16
|
+
a = 0.1 + np.random.rand(N)
|
|
17
|
+
params = jnp.c_[p, a]
|
|
18
|
+
guess = jnp.ones(N)
|
|
19
|
+
|
|
20
|
+
exact = np.atleast_2d(a ** (1.0 / p)).T
|
|
21
|
+
|
|
22
|
+
def func(x, *params):
|
|
23
|
+
return x ** params[0] - params[1]
|
|
24
|
+
|
|
25
|
+
func = jax.jit(func)
|
|
26
|
+
|
|
27
|
+
sol = newton_rootsolve(func, guess, params, nonnegative=True)
|
|
28
|
+
converged = jnp.all(jnp.isfinite(sol), axis=1)
|
|
29
|
+
assert converged.sum() > 0.9 * N
|
|
30
|
+
assert jnp.all(jnp.isclose(sol[converged], exact[converged], rtol=1e-3, atol=0))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
test_newton_rootsolve()
|
reaxion/process.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Implementation of base Process class with methods for managing and solving systems of equations"""
|
|
2
|
+
|
|
3
|
+
from .symbols import n_, d_dt
|
|
4
|
+
from .equation import Equation
|
|
5
|
+
from .equation_system import EquationSystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Process:
|
|
9
|
+
"""
|
|
10
|
+
Top-level class containing a description of a microscopic process
|
|
11
|
+
|
|
12
|
+
Most importantly, this implements the procedure for combining processes to build up a network for chemistry
|
|
13
|
+
+ conservation equations.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, name="", bibliography={}):
|
|
17
|
+
"""Construct an empty Process instance
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
name: str, optional
|
|
22
|
+
Name of the process
|
|
23
|
+
"""
|
|
24
|
+
self.name = name
|
|
25
|
+
self.initialize_network()
|
|
26
|
+
self.rate = 0
|
|
27
|
+
self.heat = 0
|
|
28
|
+
self.bibliography = bibliography
|
|
29
|
+
self.subprocesses = [self]
|
|
30
|
+
|
|
31
|
+
def __repr__(self):
|
|
32
|
+
"""Print the name in print()"""
|
|
33
|
+
return self.name
|
|
34
|
+
|
|
35
|
+
def __add__(self, other):
|
|
36
|
+
"""Sum 2 processes together: define a new process whose rates are the sum of the input process"""
|
|
37
|
+
if other == 0: # necessary for native sum() routine to work
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
attrs_to_sum = "heat", "subprocesses", "network" # all rates
|
|
41
|
+
|
|
42
|
+
sum_process = Process()
|
|
43
|
+
sum_process.rate = None # "rate" ceases to be meaningful for composite processes
|
|
44
|
+
for summed_attr in attrs_to_sum:
|
|
45
|
+
attr1, attr2 = getattr(self, summed_attr), getattr(other, summed_attr)
|
|
46
|
+
if attr1 is None or attr2 is None:
|
|
47
|
+
setattr(sum_process, summed_attr, None)
|
|
48
|
+
else:
|
|
49
|
+
setattr(sum_process, summed_attr, attr1 + attr2)
|
|
50
|
+
|
|
51
|
+
sum_process.name = f"{self.name} + {other.name}"
|
|
52
|
+
return sum_process
|
|
53
|
+
|
|
54
|
+
def __radd__(self, other):
|
|
55
|
+
return self.__add__(other)
|
|
56
|
+
|
|
57
|
+
def initialize_network(self):
|
|
58
|
+
self.network = EquationSystem() # this is a dict for which unknown keys are initialized to 0 by default
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def heat(self):
|
|
62
|
+
"""Energy lost from gas per unit volume and time"""
|
|
63
|
+
return self.__heat
|
|
64
|
+
|
|
65
|
+
@heat.setter
|
|
66
|
+
def heat(self, value):
|
|
67
|
+
"""Ensures that the network is always updated when we update the heat"""
|
|
68
|
+
self.__heat = value
|
|
69
|
+
self.network["heat"] = Equation(d_dt(n_("heat")), value)
|
|
70
|
+
|
|
71
|
+
def solve(
|
|
72
|
+
self,
|
|
73
|
+
known_quantities,
|
|
74
|
+
guess,
|
|
75
|
+
time_dependent=[],
|
|
76
|
+
dt=None,
|
|
77
|
+
verbose=False,
|
|
78
|
+
tol=1e-3,
|
|
79
|
+
careful_steps=10,
|
|
80
|
+
):
|
|
81
|
+
"""
|
|
82
|
+
Solves the equations for a set of desired quantities given a set of known quantities
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
known_quantities: dict
|
|
87
|
+
Dict of symbolic quantities and their values that will be plugged into the network solve as known quantities.
|
|
88
|
+
Can be arrays if you want to substitute multiple values. If T is included here, we solve for chemical
|
|
89
|
+
equilibrium. If T is not included, solve for thermochemical equilibrium.
|
|
90
|
+
guess: dict, optional
|
|
91
|
+
Dict of symbolic quantities and their values that will be plugged into the network solve as guesses for the
|
|
92
|
+
unknown quantities. Can be arrays if you want to substitute multiple values. Will default to trying sensible
|
|
93
|
+
guesses for recognized quantities.
|
|
94
|
+
normalize_to_H: bool, optional
|
|
95
|
+
Whether to return abundances normalized by the number density of H nucleons (default: True)
|
|
96
|
+
reduce_network: bool, optional
|
|
97
|
+
Whether to solve the reduced version of the network substituting conservation laws (default: True)
|
|
98
|
+
tol: float, optional
|
|
99
|
+
Desired relative error in chemical abundances (default: 1e-3)
|
|
100
|
+
careful_steps: int, optional
|
|
101
|
+
Number of careful initial steps in the Newton solve before full step size is used - try increasing this if
|
|
102
|
+
your solve has trouble converging.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
soldict: dict
|
|
107
|
+
Dict of solved quantities
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
return self.network.solve(
|
|
111
|
+
known_quantities,
|
|
112
|
+
guess,
|
|
113
|
+
time_dependent=time_dependent,
|
|
114
|
+
tol=tol,
|
|
115
|
+
careful_steps=careful_steps,
|
|
116
|
+
dt=dt,
|
|
117
|
+
verbose=verbose,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def solver_functions(self, solve_vars, time_dependent=[], return_jac=False, return_dict=False):
|
|
121
|
+
"""Returns the RHS of the system to solve and its Jacobian, applying simplifications"""
|
|
122
|
+
return self.network.solver_functions(solve_vars, time_dependent, return_jac, return_dict)
|
|
123
|
+
|
|
124
|
+
def generate_code(self, solve_vars, time_dependent=[], language="c", jac=True, cse=True):
|
|
125
|
+
"""Generates numerical code that implements the system RHS and/or Jacobian in the specified language."""
|
|
126
|
+
return self.network.generate_code(solve_vars, time_dependent, language, jac, cse)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Implementation of free-free emission as a 2-body process"""
|
|
2
|
+
|
|
3
|
+
from .nbody_process import NBodyProcess
|
|
4
|
+
from ..misc import species_charge
|
|
5
|
+
from ..symbols import T
|
|
6
|
+
import sympy as sp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def gaunt_factor(T):
|
|
10
|
+
"""Fit to data in table 3.3 of Spitzer (1978)"""
|
|
11
|
+
return 1.1 + 0.34 * sp.exp(-((5.5 - sp.log(T) / sp.log(10)) ** 2) / 3.0) # 1996ApJS..105...19K
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def FreeFreeEmission(ion: str) -> NBodyProcess:
|
|
15
|
+
"""Returns a free-free emisison process (i.e. bremmsstrahlung) for the input ion
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
ion: str
|
|
20
|
+
Ion species
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
process: NBodyProcess
|
|
25
|
+
`NBodyProcess` instance describing the cooling process
|
|
26
|
+
"""
|
|
27
|
+
process = NBodyProcess({ion, "e-"})
|
|
28
|
+
charge = species_charge(ion)
|
|
29
|
+
if charge <= 0:
|
|
30
|
+
raise ValueError(f"{ion} does not appear to be a cation - cannot do bremmstrahlung.")
|
|
31
|
+
process.heat_rate_coefficient = -1.42e-27 * gaunt_factor(T) * charge**2 * sp.sqrt(T) # 1996ApJS..105...19K
|
|
32
|
+
return process
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Implementation of ionization process"""
|
|
2
|
+
|
|
3
|
+
from ..process import Process
|
|
4
|
+
from ..misc import ionize
|
|
5
|
+
from ..symbols import T, T5, T3, T6, n_e, n_
|
|
6
|
+
import sympy as sp
|
|
7
|
+
from astropy import units as u
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Ionization(Process):
|
|
11
|
+
"""
|
|
12
|
+
Class describing an ionization process. Could be collisional, photo, or cosmic ray-induced.
|
|
13
|
+
|
|
14
|
+
Implements method for setting the chemistry network terms
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, species: str, rate_per_volume=0):
|
|
18
|
+
self.species = species
|
|
19
|
+
self.ionized_species = ionize(species)
|
|
20
|
+
self.__ionization_energy = None
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.__rate_per_volume = rate_per_volume
|
|
23
|
+
self.update_network()
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def rate(self):
|
|
27
|
+
return self.__rate_per_volume
|
|
28
|
+
|
|
29
|
+
@rate.setter
|
|
30
|
+
def rate(self, value):
|
|
31
|
+
self.__rate_per_volume = value
|
|
32
|
+
self.update_network()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ionization_energy(self):
|
|
36
|
+
if self.__ionization_energy is None:
|
|
37
|
+
self.__ionization_energy = ionization_energy(self.species)
|
|
38
|
+
return self.__ionization_energy
|
|
39
|
+
|
|
40
|
+
def update_network(self):
|
|
41
|
+
"""Sets up rate terms in the associated chemistry network for each species involved"""
|
|
42
|
+
if self.rate is None:
|
|
43
|
+
return
|
|
44
|
+
self.network[self.species] -= self.rate
|
|
45
|
+
self.network[self.ionized_species] += self.rate
|
|
46
|
+
self.network["e-"] += self.rate
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ionization_energy(species, unit=u.erg):
|
|
50
|
+
"""Return the energy in erg required to ionize a species"""
|
|
51
|
+
# NOTE: come back and get this from a proper datafile
|
|
52
|
+
energies_eV = {"H": 13.6, "He": 24.59, "He+": 54.42}
|
|
53
|
+
return energies_eV[species] * u.eV.to(unit)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
collisional_ionization_cooling_rates = {
|
|
57
|
+
"H": 1.27e-21 * sp.sqrt(T) * sp.exp(-157809.1 / T) / (1 + sp.sqrt(T5)), # 1996ApJS..105...19K
|
|
58
|
+
"He": 9.38e-22 * sp.sqrt(T) * sp.exp(-285335.4 / T) / (1 + sp.sqrt(T5)), # 1996ApJS..105...19K
|
|
59
|
+
"He+": 4.95e-22 * sp.sqrt(T) * sp.exp(-631515 / T) / (1 + sp.sqrt(T5)), # 1996ApJS..105...19K
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
collisional_ionization_rates = {
|
|
63
|
+
"H": 5.85e-11 * sp.sqrt(T) * sp.exp(-157809.1 / T) / (1 + sp.sqrt(T5)), # 1996ApJS..105...19K
|
|
64
|
+
"He": 2.38e-11 * sp.sqrt(T) * sp.exp(-285335.4 / T) / (1 + sp.sqrt(T5)), # 1996ApJS..105...19K
|
|
65
|
+
"He+": 5.68e-12 * sp.sqrt(T) * sp.exp(-631515 / T) / (1 + sp.sqrt(T5)), # 1996ApJS..105...19K
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def CollisionalIonization(species=None) -> Ionization:
|
|
70
|
+
"""Return an ionization process representing collisional ionization of the input species.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
species: str, optional
|
|
75
|
+
Species being collisionally ionized. If None, we compose all collisional ionization processes for all ions rates are known.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
process: Ionization
|
|
80
|
+
`Ionization` instance describing the collisional ionization process
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if species is None:
|
|
84
|
+
return sum([CollisionalIonization(s) for s in collisional_ionization_rates], Process())
|
|
85
|
+
|
|
86
|
+
process = Ionization(species)
|
|
87
|
+
process.name = f"Collisional Ionization of {species}"
|
|
88
|
+
nprod = n_(species) * n_e
|
|
89
|
+
|
|
90
|
+
if species not in collisional_ionization_rates:
|
|
91
|
+
raise NotImplementedError(f"{species} does not have an available collisional ionization coefficient.")
|
|
92
|
+
process.rate = collisional_ionization_rates[species] * nprod
|
|
93
|
+
process.heat = -process.ionization_energy * process.rate
|
|
94
|
+
|
|
95
|
+
return process
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sympy as sp
|
|
2
|
+
from ..process import Process
|
|
3
|
+
from .nbody_process import NBodyProcess
|
|
4
|
+
from ..symbols import T, T5, n_
|
|
5
|
+
from ..data import SolarAbundances
|
|
6
|
+
|
|
7
|
+
# put analytic fits for cooling efficiencies
|
|
8
|
+
line_cooling_coeffs = {
|
|
9
|
+
"H": {"e-": 7.5e-19 * sp.exp(-118348 / T) / (1 + sp.sqrt(T5))}, # 1996ApJS..105...19K
|
|
10
|
+
"He+": {"e-": 5.54e-17 * T**-0.397 * sp.exp(-473638 / T) / (1 + sp.sqrt(T5))}, # 1996ApJS..105...19K
|
|
11
|
+
"C+": { # 2023MNRAS.519.3154H
|
|
12
|
+
"e-": 1e-27 * 4890 / sp.sqrt(T) * sp.exp(-91.211 / T) / SolarAbundances.x("C"),
|
|
13
|
+
"H": 1e-27 * 0.47 * T**0.15 * sp.exp(-91.211 / T) / SolarAbundances.x("C"),
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def LineCoolingSimple(emitter: str, collider=None) -> NBodyProcess:
|
|
19
|
+
"""Returns a 2-body process representing cooling via excitations from collisions of given pair of species
|
|
20
|
+
|
|
21
|
+
This is the simple approximation where everything is well below critical density and no ambient radiation field.
|
|
22
|
+
eventually would like to have a class that considers collisions from all available colliders, given just the
|
|
23
|
+
energies, deexcitation coefficients, temperature, and statistical weights...
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
emitter: str
|
|
28
|
+
Emitting excited species
|
|
29
|
+
collider: str, optional
|
|
30
|
+
Exciting colliding species. If None, will look up all known
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
An NBodyProcess instance whose heat attribute is the line cooling process's cooling rate in erg cm^-3
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
if emitter not in line_cooling_coeffs:
|
|
38
|
+
raise NotImplementedError(f"Line cooling not implemented for {emitter}")
|
|
39
|
+
|
|
40
|
+
coeffs = line_cooling_coeffs[emitter]
|
|
41
|
+
|
|
42
|
+
if collider is None: # if we haven't specified a collider, just take all of them and return the sum
|
|
43
|
+
p = [LineCoolingSimple(emitter, c) for c in coeffs]
|
|
44
|
+
return sum(p) # type: ignore # have to put a 0-process in here as start variable or it will try to add 0 + process
|
|
45
|
+
|
|
46
|
+
process = NBodyProcess({emitter, collider})
|
|
47
|
+
if collider not in line_cooling_coeffs[emitter]:
|
|
48
|
+
raise NotImplementedError(f"Excitation by collisions with {collider} not implemented for {emitter}")
|
|
49
|
+
|
|
50
|
+
process.heat_rate_coefficient = -line_cooling_coeffs[emitter][collider]
|
|
51
|
+
process.name = f"{emitter}-{collider} Line Cooling"
|
|
52
|
+
|
|
53
|
+
return process
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# def LineCooling(emitter: str)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Class specifying an N-body collisional process with generic methods"""
|
|
2
|
+
|
|
3
|
+
from ..process import Process
|
|
4
|
+
from ..symbols import n_
|
|
5
|
+
import sympy as sp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NBodyProcess(Process):
|
|
9
|
+
"""Process implementing special methods specific to n-body processes, whose rates
|
|
10
|
+
all follow the pattern
|
|
11
|
+
|
|
12
|
+
rate per volume = k * prod_i(n_i) for i in species
|
|
13
|
+
|
|
14
|
+
rate and heat are promoted from attributes to properties implemented to compute this pattern.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
colliding_species:
|
|
19
|
+
iterable of strings representing the colliding species.
|
|
20
|
+
rate_coefficient: sympy.core.symbol.Symbol, optional
|
|
21
|
+
Symbol symbol expression for k
|
|
22
|
+
heat_rate_coefficient: sympy.core.symbol.Symbol, optional
|
|
23
|
+
Symbol symbol expression for the heat rate coefficient = average radiated energy * k
|
|
24
|
+
name: str, optional
|
|
25
|
+
Name of the process
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, colliding_species, rate_coefficient=0, heat_rate_coefficient=0, name: str = ""):
|
|
29
|
+
self.name = name
|
|
30
|
+
self.initialize_network()
|
|
31
|
+
self.colliding_species = colliding_species
|
|
32
|
+
self.rate_coefficient = rate_coefficient
|
|
33
|
+
self.heat_rate_coefficient = heat_rate_coefficient
|
|
34
|
+
self.subprocesses = [self]
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def heat_rate_coefficient(self):
|
|
38
|
+
"""Returns the heat rate coefficient of the N-body process"""
|
|
39
|
+
return self.__heat_rate_coefficient
|
|
40
|
+
|
|
41
|
+
@heat_rate_coefficient.setter
|
|
42
|
+
def heat_rate_coefficient(self, value):
|
|
43
|
+
"""Ensures that the network is always updated when we update the rate coefficient"""
|
|
44
|
+
self.__heat_rate_coefficient = value
|
|
45
|
+
self.heat = value * sp.prod([n_(c) for c in self.colliding_species])
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def rate(self):
|
|
49
|
+
"""Returns the number of events per unit time and volume"""
|
|
50
|
+
if self.rate_coefficient is None:
|
|
51
|
+
return None
|
|
52
|
+
return self.rate_coefficient * sp.prod([n_(c) for c in self.colliding_species])
|
|
53
|
+
|
|
54
|
+
# @property
|
|
55
|
+
# def heat(self):
|
|
56
|
+
# """Returns the energy radiated per unit time and volume"""
|
|
57
|
+
# return super().heat
|
|
58
|
+
|
|
59
|
+
# # self.__heat = self.heat_rate_coefficient * sp.prod([sp.Symbol("n_" + c) for c in self.colliding_species])
|
|
60
|
+
# # return self.__heat
|
|
61
|
+
|
|
62
|
+
# @heat.setter
|
|
63
|
+
# def heat(self, value):
|
|
64
|
+
# raise NotImplementedError(
|
|
65
|
+
# "Cannot directly set the heat value of an N-body process - set the rate coefficient instead."
|
|
66
|
+
# )
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def num_colliding_species(self):
|
|
70
|
+
"""Number of colliding species"""
|
|
71
|
+
return len(self.colliding_species)
|