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/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,7 @@
1
+ from ..process import *
2
+ from .nbody_process import *
3
+ from .thermal_process import *
4
+ from .freefree_emission import *
5
+ from .line_cooling import *
6
+ from .ionization import *
7
+ from .recombination import *
@@ -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)