"""Support modules to write a pure python reaction network ODE source."""
import io
import sys
import types
import warnings
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp
from pynucastro.constants import constants
from pynucastro.eos import StellarEOS
from pynucastro.networks.rate_collection import RateCollection
from pynucastro.neutrino_cooling import sneut5
from pynucastro.nucdata import Composition, Nucleus
from pynucastro.rates.rate import py_rate_func_args
from pynucastro.screening import get_screening_func, get_screening_pair_set
[docs]
class NetworkSolution:
"""A class to hold the solution from integrating PythonNetwork.
The member functions provide common visualization and
analysis routines.
Parameters
----------
sol : object
Solution object returned by :func:`scipy.integrate.solve_ivp`. The
array `sol.y` is assumed to contain the molar abundances, `Y_i`,
ordered consistently with `unique_nuclei`
rhs : Callable
Function that computes the RHS of the PythonNetwork
jac : Callable
Function that computes the Jacobian of the PythonNetwork
network : PythonNetwork
PythonNetwork used for integration
rho : float
density used to integrate the network
T : float
temperature used to integrate the network. Only needed if
this is not a self-heating burn
self_heating : bool
is temperature integrated together with composition?
thermal_neutrinos: bool
whether to include thermal neutrino cooling in the energy balance?
screen_func: Callable
screening function used to evaluate rates when integrating
the network
"""
def __init__(self, sol, rhs, jac, network, rho, T=None,
self_heating=False, thermal_neutrinos=False,
screen_func=None, do_rate_eval=None, ydot_eq=None):
self._sol = sol
self._rhs = rhs
self._jac = jac
self.unique_nuclei = tuple(network.unique_nuclei)
self.rho = rho
self.T = T
self.self_heating = self_heating
self.thermal_neutrinos = thermal_neutrinos
self.screen_func = screen_func
self._do_rate_eval = do_rate_eval
self._ydot_eq = ydot_eq
@property
def success(self):
"""Return whether the integration was successful
Returns
-------
bool
"""
return self._sol.success
@property
def t(self):
"""Return the time array for integration
Returns
-------
numpy.ndarray
"""
return self._sol.t
@property
def X(self):
"""Return the 2D array of mass fractions abundances
for all times.
Returns
-------
numpy.ndarray
"""
As = np.array([n.A for n in self.unique_nuclei])
return self._sol.y[0:len(self.unique_nuclei), :] * As[:, None]
@property
def Y(self):
"""Return the 2D array of molar abundances
for all times.
Returns
-------
numpy.ndarray
"""
return self._sol.y[0:len(self.unique_nuclei), :]
@property
def Temp(self):
"""Return the array of temperature for all times.
Returns
-------
numpy.ndarray
"""
assert self.self_heating
return self._sol.y[-1, :]
[docs]
def X_at(self, t):
"""Evaluate the mass fractions for a given time.
Parameters
----------
t : float or list or numpy.ndarray
time or time array used to evaluate the mass fractions. If a time
array is given, the output is an array of shape (nuc, times).
If a negative scalar time is passed in (e.g., -1.0), the state
at the end of integration is returned.
Returns
-------
numpy.ndarray
"""
As = np.array([n.A for n in self.unique_nuclei])
if isinstance(t, (float, int)) or np.ndim(t) == 0:
if t < 0:
t = self._sol.t[-1]
return self._sol.sol(t)[0:len(self.unique_nuclei)] * As
return self._sol.sol(t)[0:len(self.unique_nuclei), ...] * As[:, None]
[docs]
def Y_at(self, t):
"""Evaluate the molar abundances for a given time.
Parameters
----------
t : float or list or numpy.ndarray
time or time array used to evaluate the molar
abundances. If a time array is given, the output is an
array of shape (nuc, times).
If a negative scalar time is passed in (e.g., -1.0), the state
at the end of integration is returned.
Returns
-------
numpy.ndarray
"""
if isinstance(t, (float, int)):
if t < 0:
t = self._sol.t[-1]
return self._sol.sol(t)[0:len(self.unique_nuclei)]
return self._sol.sol(t)[0:len(self.unique_nuclei), ...]
[docs]
def comp_at(self, t):
"""Create a Composition object for the state at the specified time.
Parameters
----------
t : float
The time at which to evaluate the composition. If t < 0, then
the endpoint of integration is used.
"""
assert isinstance(t, (float, int))
_X = self.X_at(t)
comp = Composition(self.unique_nuclei)
for nuc, X in zip(self.unique_nuclei, _X):
comp.set_nuc(nuc, X)
return comp
[docs]
def T_at(self, t):
"""Evaluate the temperature for a given time.
Parameters
----------
t : float or list or numpy.ndarray
time or time array used to evaluate the molar abundances
If a negative scalar time is passed in (e.g., -1.0), the
temperature at the end of integration is returned.
Returns
-------
numpy.ndarray
"""
assert self.self_heating
if t < 0:
t = self._sol.t[-1]
return self._sol.sol(t)[-1, ...]
[docs]
def ye(self, Y):
"""Evaluate the electron fraction with a given set of molar fractions
Parameters
----------
Y : numpy.ndarray
Molar fraction array
Returns
-------
float
"""
ye = sum(nuc.Z * Y[i] for i, nuc in enumerate(self.unique_nuclei))
return ye
[docs]
def ye_at(self, t):
"""Evaluate the electron fraction for a given time
Parameters
----------
t : float
time used to evaluate the electron fraction
If a negative time is passed in (e.g., -1.0), Ye at
the end of integration is returned.
Returns
-------
float
"""
assert isinstance(t, (float, int)) or np.ndim(t) == 0
Y = self.Y_at(t)
return self.ye(Y)
[docs]
def rhs(self, t, Y, T=None):
"""Evaluate the RHS of the network with the same thermodynamic
condition and screening routine used to integrate the network.
Parameters
----------
t : float
time used to evaluate the RHS
Y : numpy.ndarray
molar abundances of the species
T : float
temperature (required for self-heating)
Returns
-------
numpy.ndarray
"""
if self.self_heating:
assert T is not None
return self._rhs(t, Y, self.rho, T, screen_func=self.screen_func)
return self._rhs(t, Y, self.rho, self.T, screen_func=self.screen_func)
[docs]
def rhs_at(self, t):
"""Evaluate the RHS of the network for a given time. Note:
for a self-heating burn, this gives only dY/dt.
Parameters
----------
t : float
time used to evaluate the RHS
If a negative time is passed in (e.g., -1.0), Ye at
the end of integration is returned.
Returns
-------
numpy.ndarray
"""
assert isinstance(t, (float, int)) or np.ndim(t) == 0
Y = self.Y_at(t)
if self.self_heating:
T = self.T_at(t)
return self.rhs(t, Y, T=T)
return self.rhs(t, Y)
[docs]
def jac(self, t, Y):
"""Evaluate the Jacobian of the network with the same thermodynamic
condition and screening routine used to integrate the network.
Parameters
----------
t : float
time used to evaluate the RHS
Y : numpy.ndarray
molar abundances of the species
Returns
-------
numpy.ndarray
"""
# we don't support the Jacobian for self-heating networks
assert not self.self_heating
return self._jac(t, Y, self.rho, self.T, screen_func=self.screen_func)
[docs]
def jac_at(self, t):
"""Evaluate the Jacobian of the network for a given time.
Parameters
----------
t : float
time used to evaluate the Jacobian
If a negative time is passed in (e.g., -1.0), Ye at
the end of integration is returned.
Returns
-------
numpy.ndarray
"""
assert isinstance(t, (float, int)) or np.ndim(t) == 0
Y = self.Y_at(t)
return self.jac(t, Y)
[docs]
def energy_release(self, dY):
"""Evaluate the energy release in erg/g (/s if dY is actually dY/dt)
Parameters
----------
dY : numpy.ndarray
Finite change or the rate of instataneous change in molar fractions
Returns
-------
float
"""
enuc = sum(nuc.mass * dY[i] for i, nuc in enumerate(self.unique_nuclei))
enuc *= -1*constants.N_A*constants.MeV2erg
return enuc
[docs]
def energy_release_at(self, t):
"""Evaluate the instantaneous energy release in erg/g/s for a given time
Parameters
----------
t : float
time used to evaluate the instantaneous energy release
If a negative scalar time is passed in (e.g., -1.0), the state
at the end of integration is returned.
Returns
-------
float
"""
assert isinstance(t, (float, int)) or np.ndim(t) == 0
if self._do_rate_eval is not None and self._ydot_eq is not None:
Y = self.Y_at(t)
T = self.T_at(t) if self.self_heating else self.T
rate_eval = self._do_rate_eval(t, Y, self.rho, T, self.screen_func)
dYdt = self._ydot_eq(Y, self.rho, rate_eval)
return self.energy_release(dYdt) + rate_eval.enuc_weak
dYdt = self.rhs_at(t)
enuc = self.energy_release(dYdt)
return enuc
[docs]
def plot_evolution(self, *,
tmin=None, tmax=None,
ymin=None, ymax=None,
size=(800, 600), dpi=100,
X_cutoff_value=None,
label_size=20, legend_size=15, linewidth=2.5,
ncol=None, legend_frameon=True,
legend_outside=False, tick_dir="in",
three_level_style=False,
outfile=None):
"""Plot the time evolution of nuclei mass fractions using the
solution returned by SciPy's solve_ivp().
Parameters
----------
tmin : float
Minimum time shown on the x-axis. If `None`, the first value of
`self.t` is used.
tmax : float
Maximum time shown on the x-axis. If `None`, the last value of
`self.t` is used.
ymin : float
Minimum mass fraction shown on the y-axis. If `None`,
use the Matplotlib autoscaled value.
ymax : float
Maximum mass fraction shown on the y-axis. If `None`,
use the Matplotlib autoscaled value. The autoscaled value
is capped at 1.2
dpi : int
dots per inch used with size to set output image size
size : (tuple, list)
(width, height) of the plot in pixels
X_cutoff_value : float
Minimum peak mass fraction required for a nucleus to be plotted.
label_size : int
Font size for axis labels.
legend_size : int
Font size for the legend.
linewidth : int, float
Linewidth for plotting.
ncol : int
Number of columns for legend
legend_frameon: bool
Turn legend frame on or off.
legend_outside: bool
Put the legend on top of the figure.
tick_dir: str
Tick directions. Either "in" or "out"
three_level_style : bool
If `True`, use three-level linestyle and linewidth based on the peak
mass fraction to help distinguish different curves.
If `False`, all curves use the same line style and linewidth.
outfile : str
output name of the plot (extension determines the type)
Returns
-------
matplotlib.figure.Figure
"""
if X_cutoff_value is None and ymin is not None:
X_cutoff_value = ymin
# sort the nuclei by peak X, since that will ensure that the
# color cycles don't put two very close species in the same
# color
sorted_nuc = []
for i, nuc in enumerate(self.unique_nuclei):
X = self.Y[i, :] * nuc.A
max_X = X.max()
sorted_nuc.append((i, nuc, max_X))
sorted_nuc.sort(key=lambda t: t[-1])
# now figure out the line colors and styles
c1 = 0
c2 = 0
c3 = 0
styles = {}
for _, nuc, max_X in reversed(sorted_nuc):
if X_cutoff_value is not None:
if max_X < X_cutoff_value:
continue
if three_level_style:
# Set 3 levels of visual levels depending on maximum mass fraction
if max_X > 0.5:
lw = linewidth + 1
ls = "-"
color = f"C{c1 % 10}"
c1 += 1
elif max_X > 0.01:
lw = linewidth
ls = "-"
color = f"C{c2 % 10}"
c2 += 1
else:
lw = linewidth - 0.5
ls = "--"
color = f"C{c3 % 10}"
c3 += 1
else:
lw = linewidth
ls = "-"
color = f"C{c1 % 10}"
c1 += 1
styles[nuc] = (color, ls, lw)
fig, ax = plt.subplots(figsize=(size[0]/dpi, size[1]/dpi))
for i, nuc in enumerate(self.unique_nuclei):
X = self.Y[i, :] * nuc.A
max_X = X.max()
if X_cutoff_value is not None and max_X <= X_cutoff_value:
continue
color, ls, lw = styles[nuc]
ax.loglog(self.t, X, color=color, lw=lw, ls=ls,
label=rf"X(${nuc.pretty}$)")
if tmin is None:
tmin = self.t[0]
if tmax is None:
tmax = self.t[-1]
# Auto set number of legend column
if ncol is None:
ncol = max(1, len(ax.lines) // 8 + 1)
ax.set_xlim(tmin, tmax)
ax.set_ylim(ymin, ymax)
cur_ymin, cur_ymax = ax.get_ylim()
if ymax is None and cur_ymax > 1.2:
# Make sure the autoscaled ymax is not greater than 1.2
cur_ymax = 1.2
ax.set_ylim(cur_ymin, cur_ymax)
ax.set_xlabel("time [s]", fontsize=label_size)
ax.set_ylabel("X", fontsize=label_size)
if legend_outside:
ax.legend(loc="lower center", bbox_to_anchor=(0.5, 1.01),
fontsize=legend_size, ncol=ncol,
frameon=legend_frameon)
else:
ax.legend(loc="best", fontsize=legend_size, ncol=ncol,
frameon=legend_frameon)
ax.tick_params(top=True, bottom=True,
left=True, right=True,
direction=tick_dir, which="both")
ax.grid(ls=":")
fig.tight_layout()
if outfile is not None:
fig.savefig(outfile, dpi=dpi)
return fig
[docs]
def plot_temperature(self,
tmin=None, tmax=None,
size=(800, 600), dpi=100,
label_size=14,
outfile=None):
"""Plot the time evolution of temperature for self-heating burns.
Parameters
----------
tmin : float
Minimum time shown on the x-axis. If `None`, the first value of
`self.t` is used.
tmax : float
Maximum time shown on the x-axis. If `None`, the last value of
`self.t` is used.
dpi : int
dots per inch used with size to set output image size
size : (tuple, list)
(width, height) of the plot in pixels
label_size : int
Font size for axis labels.
outfile : str
output name of the plot (extension determines the type)
Returns
-------
matplotlib.figure.Figure
"""
assert self.self_heating
fig, ax = plt.subplots(figsize=(size[0]/dpi, size[1]/dpi))
ax.loglog(self.t, self.Temp)
if tmin is None:
tmin = self.t[0]
if tmax is None:
tmax = self.t[-1]
ax.set_xlim(tmin, tmax)
ax.set_xlabel("time [s]", fontsize=label_size)
ax.set_ylabel("T [K]", fontsize=label_size)
ax.grid(ls=":")
fig.tight_layout()
if outfile is not None:
fig.savefig(outfile, dpi=dpi)
return fig
[docs]
def plot_energy_generation(self,
tmin=None, tmax=None,
ymin=None, ymax=None,
include_neutrino_loss=None,
size=(800, 600), dpi=100,
label_size=14, legend_size=10,
outfile=None):
"""Plot the nuclear energy generation rate, and optionally the thermal
neutrino cooling rate.
Parameters
----------
tmin: float
Minimum time shown on the x-axis. If `None`, the first value of
`self.t` is used.
tmax : float
Maximum time shown on the x-axis. If `None`, the last value of
`self.t` is used.
ymin : float
Minimum rate shown on the y-axis.
ymax : float
Maximum rate shown on the y-axis.
include_neutrino_loss : bool
Whether to also plot the thermal neutrino cooling rate. If `None`,
use `self.thermal_neutrinos`.
dpi : int
dots per inch used with size to set output image size.
size : (tuple, list)
(width, height) of the plot in pixels.
label_size : int
Font size for axis labels.
legend_size : int
Font size for the legend.
outfile : str
output name of the plot (extension determines the type).
Returns
-------
matplotlib.figure.Figure
"""
if include_neutrino_loss is None:
include_neutrino_loss = self.thermal_neutrinos
eps_nuc = np.array([self.energy_release_at(t) for t in self.t])
fig, ax = plt.subplots(figsize=(size[0]/dpi, size[1]/dpi))
ax.loglog(self.t, eps_nuc, label=r"$\epsilon_\mathrm{nuc}$")
if include_neutrino_loss:
eps_nu = []
for t in self.t:
comp = Composition(self.unique_nuclei)
comp.set_molar_array(self.Y_at(t))
if self.self_heating:
T = self.T_at(t)
else:
T = self.T
eps_nu.append(sneut5(self.rho, T, comp))
ax.loglog(self.t, eps_nu, label=r"$\epsilon_{\nu, \mathrm{therm}}$")
if tmin is None:
tmin = self.t[0]
if tmax is None:
tmax = self.t[-1]
ax.set_xlim(tmin, tmax)
ax.set_ylim(ymin, ymax)
ax.set_xlabel("Time [s]", fontsize=label_size)
ax.set_ylabel("Rates [erg/g/s]", fontsize=label_size)
ax.legend(loc="best", fontsize=legend_size)
ax.grid(ls=":")
fig.tight_layout()
if outfile is not None:
fig.savefig(outfile, dpi=dpi)
return fig
[docs]
class PythonNetwork(RateCollection):
"""A pure python reaction network. This can create a python
module as a file that contains everything needed to evaluate the
reaction rates and construct the righthand side and Jacobian
functions.
"""
def __init__(self, *args, **kwargs):
# initialize the base class
super().__init__(*args, **kwargs)
# carry the numba compiled network used in integration
self.network_module = None
def _build_collection(self):
super()._build_collection()
# invalidate any compiled network module since the network
# changed
self.network_module = None
[docs]
def resample(self, seed=None):
"""Resample starlib rates
Parameters
----------
seed: int
Seed for resampling. If no seed is provided then
an arbitrary seed is used.
"""
super().resample(seed=seed)
# invalid when we do resample.
# Since StarLibRate log_rate_data will be changed
self.network_module = None
[docs]
def unsample(self):
"""Restore starlib rates to median values."""
super().unsample()
# invalid when we do resample.
# Since StarLibRate log_rate_data will be changed
self.network_module = None
[docs]
def full_ydot_string(self, nucleus, indent=""):
"""Construct a string containing the python code for
dY(nucleus)/dt by considering every reaction that involves
nucleus, adding terms that result in its creation and
subtracting terms representing its destruction.
Parameters
----------
nucleus : Nucleus
The nucleus we are constructing the time derivative
of.
indent : str
A string that will be prepended to each line of the output,
typically consisting of just spaces representing the amount
of indent desired.
Returns
-------
str
"""
ostr = ""
if not self.nuclei_consumed[nucleus] + self.nuclei_produced[nucleus]:
# this captures an inert nucleus
ostr += f"{indent}dYdt[j{nucleus.raw}] = 0.0\n\n"
else:
ostr += f"{indent}dYdt[j{nucleus.raw}] = (\n"
# if a nucleus appears both as a reactant and product in a
# rate then it's contribution might be 0. So ignore this
# pair if that is true for both the forward and reverse
# for this nucleus
def net_coefficient(rate):
if rate is None:
return 0
return rate.product_count(nucleus) - rate.reactant_count(nucleus)
valid_pairs = [q for q in self.nuclei_rate_pairs[nucleus] if
not (net_coefficient(q.forward) == 0 and
net_coefficient(q.reverse) == 0)]
for ipair, rp in enumerate(valid_pairs):
# when we are working with rate pairs, one or more of the
# rates may be missing. We also have not clearly separated
# them into creation / destruction, so we'll figure that out
rlist = [r for r in [rp.forward, rp.reverse] if r is not None]
ostr += f"{indent} "
if len(rlist) > 1:
ostr += "( "
for rate in rlist:
c_reac = rate.reactant_count(nucleus)
c_prod = rate.product_count(nucleus)
c = c_prod - c_reac
if c == 1:
ostr += f"+{rate.ydot_string_py()} "
elif c == -1:
ostr += f"-{rate.ydot_string_py()} "
else:
ostr += f"+ {c}*{rate.ydot_string_py()} "
if len(rlist) > 1:
ostr += ")"
if ipair < len(valid_pairs) - 1:
ostr += " +"
ostr = ostr.rstrip() + "\n"
# if there were no rates, just output 0.0
if len(valid_pairs) == 0:
ostr += f"{indent} 0.0\n"
ostr += f"{indent} )\n\n"
return ostr
[docs]
def full_jacobian_element_string(self, ydot_i_nucleus, y_j_nucleus, indent=""):
"""Construct a string containing the python code for a single
element of the Jacobian, dYdot(ydot_i_nucleus)/dY(y_j_nucleus).
This includes implicit rate derivatives stored in ``RateEval``
for nuclei listed in a rate's ``rate_comp_dependence``.
Parameters
----------
ydot_i_nucleus: Nucleus
The nucleus representing the dY/dt term we are differentiating.
This is the row of the Jacobian.
y_j_nucleus: Nucleus
The nucleus we are differentiating with respect to. This
is the column of the Jacobian.
indent : str
A string that will be prepended to each line of the output,
typically consisting of just spaces representing the amount
of indent desired.
Returns
-------
str
"""
# this is the jac(i,j) string
idx_str = f"jac[j{ydot_i_nucleus.raw}, j{y_j_nucleus.raw}]"
ostr = ""
if not self.nuclei_consumed[ydot_i_nucleus] + self.nuclei_produced[ydot_i_nucleus]:
# this covers the case where a nucleus is not created or
# destroyed in the entire network, but is just passive
ostr += f"{indent}{idx_str} = 0.0\n\n"
else:
ostr += f"{indent}{idx_str} = (\n"
rate_terms_str = ""
seen_rate_ids = set()
for r in self.nuclei_consumed[ydot_i_nucleus] + self.nuclei_produced[ydot_i_nucleus]:
# A rate appearing in both lists contributes only once,
# with its net stoichiometric coefficient.
if id(r) in seen_rate_ids:
continue
seen_rate_ids.add(id(r))
c = r.product_count(ydot_i_nucleus) - r.reactant_count(ydot_i_nucleus)
if c == 0:
continue
jac_terms = [r.jacobian_string_py(y_j_nucleus)]
if r.rate_comp_dependence and y_j_nucleus in r.rate_comp_dependence:
# Product rule: retain the full abundance and density
# factors and replace the rate with its derivative.
deriv_name = f"drate_{r.fname}_dY{y_j_nucleus.cindex()}"
jac_terms.append(r.ydot_string_py().replace(f"rate_eval.{r.fname}",
f"rate_eval.{deriv_name}"))
for jac_str in jac_terms:
if jac_str == "":
continue
if c == 1:
rate_terms_str += f"{indent} +{jac_str}\n"
elif c == -1:
rate_terms_str += f"{indent} -{jac_str}\n"
else:
rate_terms_str += f"{indent} {c:+}*{jac_str}\n"
if rate_terms_str == "":
return ""
ostr += rate_terms_str
ostr += f"{indent} )\n\n"
return ostr
[docs]
def screening_string(self, indent=""):
"""Create a string containing the python code that sets up the
screening (PlasmaState) and calls the screening function on
every set of reactants in our network. This computes log(screening)
terms and store them in local variables.
Parameters
----------
indent : str
A string that will be prepended to each line of the output,
typically consisting of just spaces representing the amount
of indent desired.
Returns
-------
str
"""
ostr = ""
screening_pair_set = get_screening_pair_set(self.all_rates)
# Initialize log_scor to 0.0
for n1, n2 in screening_pair_set:
screen_var = f"log_scor_{n1}_{n2}"
ostr += f"{indent}{screen_var} = 0.0\n"
# Check if we're doing screening, return early if not screening
if not self.do_screening:
return ostr
ostr += "\n"
ostr += f"{indent}if screen_func is not None:\n"
indent += " "
ostr += f"{indent}plasma_state = PlasmaState(T, rho, Y, Z)\n\n"
for n1, n2 in screening_pair_set:
screen_var = f"log_scor_{n1}_{n2}"
ostr += f"{indent}scn_fac = ScreenFactors({n1.Z}, {n1.A}, {n2.Z}, {n2.A})\n"
ostr += f"{indent}{screen_var} = screen_func(plasma_state, scn_fac)\n"
return ostr
[docs]
def rates_string(self, indent=""):
"""Create the python code that calls the evaluation function
for each rate. Typically this is of the form
``name(rate_eval, ...)``, where ``rate_eval`` is a container
class in the output network that stores the rate values. This
also calls ``screening_string()`` after the main rates are
evaluated but before the approximate rates are constructed.
Parameters
----------
indent : str
A string that will be prepended to each line of the output,
typically consisting of just spaces representing the amount
of indent desired.
Returns
-------
str
"""
ostr = ""
# Precompute screening terms. Note here we compute log_screening
ostr += self.screening_string(indent=indent)
ostr += "\n"
ostr += f"{indent}rhoY = rho * ye(Y)\n"
ostr += f"{indent}log_rhoY = np.log10(rhoY)\n"
ostr += f"{indent}log_T = np.log10(T)\n\n"
if self.reaclib_rates:
ostr += f"{indent}# reaclib rates\n"
for r in self.reaclib_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.tabular_rates:
ostr += f"\n{indent}# tabular rates\n"
for r in self.tabular_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.temperature_tabular_rates:
ostr += f"\n{indent}# temperature tabular rates\n"
for r in self.temperature_tabular_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.starlib_rates:
ostr += f"\n{indent}# starlib rates\n"
for r in self.starlib_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.custom_rates:
ostr += f"\n{indent}# custom rates\n"
for r in self.custom_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.beta_limited_rates:
ostr += f"\n{indent}# beta-limited rates\n"
for r in self.beta_limited_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.modified_rates:
ostr += f"\n{indent}# modified rates\n"
for r in self.modified_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.branched_rates:
ostr += f"\n{indent}# branched rates\n"
for r in self.branched_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
# Derived rate should go last (before approx rates)
# since the inverse rate should be evaluated first.
if self.derived_rates:
ostr += f"\n{indent}# derived rates\n"
for r in self.derived_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
if self.approx_rates:
ostr += f"\n{indent}# approximate rates\n"
for r in self.approx_rates:
call_args = py_rate_func_args(r, mode="call")
ostr += f"{indent}{r.fname}({', '.join(call_args)})\n"
return ostr
def _write_network(self, outfile: str | Path = None):
"""Create the entire python code representing this network.
This includes defining the nuclei properties, evaluating
partition functions, defining the ``RateEval`` class, defining
the tabular rate data, writing the functions that evaluate
each of the rates, and then calling the functions that
construct the RHS and Jacobian.
Parameters
----------
outfile : str, Path
The name of the output file to write to. If this is ``None``,
then the output is written to stdout
"""
# pylint: disable=arguments-differ
if outfile is None:
of = sys.stdout
close_file = False
elif hasattr(outfile, "write"):
# already a file-like object
of = outfile
close_file = False
else:
outfile = Path(outfile)
of = outfile.open("w", encoding="utf-8")
close_file = True
indent = 4*" "
of.write("import numba\n")
of.write("import numpy as np\n")
of.write("from pynucastro.constants import constants\n")
of.write("from numba.experimental import jitclass\n\n")
of.write("N_A = constants.N_A\n\n")
of.write("from pynucastro.rates import (TableIndex, TableInterpolator, TabularWeakRate,\n")
of.write(" TempTableInterpolator, TemperatureTabularRate,\n")
of.write(" Tfactors)\n")
of.write("from pynucastro.screening import PlasmaState, ScreenFactors\n\n")
# integer keys
for i, n in enumerate(self.unique_nuclei):
of.write(f"j{n.raw} = {i}\n")
of.write(f"nnuc = {len(self.unique_nuclei)}\n\n")
# nuclei properties
of.write("A = np.zeros((nnuc), dtype=np.int32)\n\n")
for n in self.unique_nuclei:
of.write(f"A[j{n.raw}] = {n.A}\n")
of.write("\n")
of.write("Z = np.zeros((nnuc), dtype=np.int32)\n\n")
for n in self.unique_nuclei:
of.write(f"Z[j{n.raw}] = {n.Z}\n")
# we'll compute the masses here in erg
of.write("\n")
of.write("# masses in ergs\n")
of.write("mass = np.zeros((nnuc), dtype=np.float64)\n\n")
for n in self.unique_nuclei:
mass = n.A_nuc * constants.m_u_MeV_C18 * constants.MeV2erg
of.write(f"mass[j{n.raw}] = {mass}\n")
of.write("\n")
of.write("names = []\n")
for n in self.unique_nuclei:
name = n.short_spec_name
if name != "n":
name = name.capitalize()
of.write(f"names.append(\"{name}\")\n")
of.write("\n")
of.write("def to_composition(Y):\n")
of.write(f'{indent}''"""Convert an array of molar fractions to a Composition object."""\n')
of.write(f'{indent}'"from pynucastro import Composition, Nucleus\n")
of.write(f'{indent}'"nuclei = [Nucleus.from_cache(name) for name in names]\n")
of.write(f'{indent}'"comp = Composition(nuclei)\n")
of.write(f'{indent}'"for i, nuc in enumerate(nuclei):\n")
of.write(f'{indent*2}'"comp.X[nuc] = Y[i] * A[i]\n")
of.write(f'{indent}'"return comp\n\n")
of.write("\n")
of.write("def energy_release(dY):\n")
of.write(f'{indent}''"""return the energy release in erg/g (/s if dY is actually dY/dt)"""\n')
of.write(f'{indent}'"enuc = 0.0\n")
of.write(f'{indent}'"for i, y in enumerate(dY):\n")
of.write(f'{indent*2}'"enuc += y * mass[i]\n")
of.write(f'{indent}'"enuc *= -1*constants.N_A\n")
of.write(f'{indent}'"return enuc\n\n")
# partition function data (if needed)
nuclei_pfs = self.get_nuclei_needing_partition_functions()
for n in nuclei_pfs:
of.write(f"{n}_temp_array = np.array({list(n.partition_function.T9_points)})\n")
of.write(f"{n}_log_pf_array = np.array({list(n.partition_function.log_pf_data)})\n")
of.write("\n")
# rate_eval class
of.write("@jitclass([\n")
of.write(f'{indent}("enuc_weak", numba.float64),\n')
for r in self.all_rates:
of.write(f'{indent}("{r.fname}", numba.float64),\n')
of.write(f'{indent}("dweak_ydot_dYe", numba.float64[:]),\n')
for r in self.all_rates:
if nucs := r.rate_comp_dependence:
for n in nucs:
of.write(f'{indent}("drate_{r.fname}_dY{n.cindex()}", numba.float64),\n')
of.write("])\n")
of.write("class RateEval:\n")
of.write(f"{indent}def __init__(self):\n")
of.write(f"{indent*2}self.enuc_weak = 0.0\n")
for r in self.all_rates:
of.write(f"{indent*2}self.{r.fname} = np.nan\n")
of.write(f"{indent*2}self.dweak_ydot_dYe = np.zeros({len(self.unique_nuclei)})\n")
for r in self.all_rates:
if nucs := r.rate_comp_dependence:
for n in nucs:
of.write(f"{indent*2}self.drate_{r.fname}_dY{n.cindex()} = np.nan\n")
of.write("\n")
# tabular rate data
if self.tabular_rates:
of.write("# note: we cannot make the TableInterpolator global, since numba doesn't like global jitclass\n")
for r in self.tabular_rates:
of.write(f"# load data for {r.rid}\n")
of.write(f"{r.fname}_info = (\n")
of.write(f" {r.table_rhoy_lines}, # table_rhoy_lines\n")
of.write(f" {r.table_temp_lines}, # table_temp_lines\n")
of.write(" # tabular_data_table\n")
of.write(f" np.array({r.tabular_data_table.tolist()})\n")
of.write(")\n\n")
# temperature tabular / starlib rate data
if self.temperature_tabular_rates + self.starlib_rates:
of.write("# note: we cannot make the TempTableInterpolator global, since numba doesn't like global jitclass\n")
for r in self.temperature_tabular_rates + self.starlib_rates:
of.write(f"# temperature / rate tabulation for {r.rid}\n")
log_temp_str = np.array2string(r.log_t9_data,
max_line_width=70, precision=17,
separator=", ", threshold=sys.maxsize)
of.write(f"{r.fname}_log_t9_data = np.array(\n")
for line in log_temp_str.split("\n"):
of.write(f" {line}\n")
of.write(" )\n")
log_rate_str = np.array2string(r.log_rate_data,
max_line_width=70, precision=17,
separator=", ", threshold=sys.maxsize)
of.write(f"{r.fname}_log_rate_data = np.array(\n")
for line in log_rate_str.split("\n"):
of.write(f" {line}\n")
of.write(" )\n")
of.write(f"{r.fname}_info = ({r.fname}_log_t9_data, {r.fname}_log_rate_data)\n\n")
# Ye helper function
of.write("@numba.njit()\n")
of.write("def ye(Y):\n")
of.write(f"{indent}return np.sum(Z * Y)\n\n")
# the functions to evaluate the T dependence (strong) or ρ-T
# dependence (weak) of the rates
for r in self.all_rates:
of.write(r.function_string_py())
# the rhs() function
of.write("def rhs(t, Y, rho, T, screen_func=None):\n")
of.write(f"{indent}return rhs_eq(t, Y, rho, T, screen_func)\n\n")
of.write("@numba.njit()\n")
of.write("def do_rate_eval(t, Y, rho, T, screen_func):\n\n")
# get the rates
of.write(f"{indent}tf = Tfactors(T)\n")
of.write(f"{indent}rate_eval = RateEval()\n\n")
of.write(self.rates_string(indent=indent))
of.write("\n")
of.write(f"{indent}return rate_eval\n")
of.write("\n")
of.write("@numba.njit()\n")
of.write("def ydot_eq(Y, rho, rate_eval):\n\n")
of.write(f"{indent}dYdt = np.zeros((nnuc), dtype=np.float64)\n\n")
for n in self.unique_nuclei:
of.write(self.full_ydot_string(n, indent=indent))
of.write(f"{indent}return dYdt\n\n")
of.write("@numba.njit()\n")
of.write("def rhs_eq(t, Y, rho, T, screen_func):\n\n")
of.write(f"{indent}rate_eval = do_rate_eval(t, Y, rho, T, screen_func)\n")
of.write(f"{indent}return ydot_eq(Y, rho, rate_eval)\n\n")
# the jacobian() function
of.write("def jacobian(t, Y, rho, T, screen_func=None):\n")
of.write(f"{indent}return jacobian_eq(t, Y, rho, T, screen_func)\n\n")
of.write("@numba.njit()\n")
of.write("def jacobian_eq(t, Y, rho, T, screen_func):\n\n")
# get the rates
of.write(f"{indent}rate_eval = do_rate_eval(t, Y, rho, T, screen_func)\n\n")
of.write(f"{indent}jac = np.zeros((nnuc, nnuc), dtype=np.float64)\n\n")
# now fill each Jacobian element
for n_i in self.unique_nuclei:
for n_j in self.unique_nuclei:
of.write(self.full_jacobian_element_string(n_i, n_j, indent=indent))
# now the correction for the Ye dependence in weak rates
of.write(f"{indent}# add ∂λ_weak / ∂Y_e terms now\n")
of.write(f"{indent}# this uses ∂Y_e/∂Y_i = Z_i\n")
of.write(f"{indent}for irow in range(nnuc):\n")
of.write(f"{indent} for jcol in range(nnuc):\n")
of.write(f"{indent} jac[irow, jcol] += rate_eval.dweak_ydot_dYe[irow] * Z[jcol]\n\n")
of.write(f"{indent}return jac\n")
if close_file:
of.close()
[docs]
def integrate_network(self, tmax, rho, T, molar_composition=None,
screen_method=None,
self_heating=False,
thermal_neutrinos=False,
initial_comp="uniform",
stopping_condition=None,
rtol=1e-8, atol=1e-8):
"""Integrate the network to tmax given (rho, T, Y0) using
SciPy's solve_ivp() with BDF method. Optionally, we can
integrate temperature together in a self-heating mode.
Parameters
----------
tmax: float
final integration time.
rho : float
density used to integrate the network
T : float
temperature used to integrate the network
molar_composition : numpy.ndarray or Composition
initial molar abundance of the nuclei. This can be either
a NumPy array of molar fractions or a Composition object.
If not provided, the initial composition is initialized
according to `initial_comp`
screen_method : str
name of the screening function used to evaluate rates when integrating
the network. Valid choices are: `screen5`, `chugunov_2007`, `chugunov_2009`,
`potekhin_1998`, and `debye_huckel`. If `None`, no screening is applied.
self_heating : bool
do we evolve temperature (as dT/dt = ε / c_v) together with the EOS?
thermal_neutrinos: bool
whether to include thermal neutrino cooling in the energy balance?
initial_comp : str
different modes to use to set up the initial composition if
molar_composition is None.
Valid choices are: `uniform`, `random`, and `solar`.
stopping_condition : tuple([Nucleus, str], float)
If provided, then we stop the integration when the specified nucleus
mass fraction drops to the value provided.
rtol : float
relative tolerance for SciPy's solve_ivp()
atol : float
absolute tolerance for SciPy's solve_ivp()
Returns
-------
sol : object
Solution returned by :func:`scipy.integrate.solve_ivp`.
"""
# Write the network module as a string
if not self.network_module:
f = io.StringIO()
self.write_network(outfile=f)
network_code = f.getvalue()
# Create a new in-memory module called `network`
network = types.ModuleType("network")
# Execute the code inside the module namespace
exec(network_code, network.__dict__) # pylint: disable=exec-used
self.network_module = network
else:
network = self.network_module
# Get RHS and Jacobian. Use getattr to avoid pylint warning.
rhs = getattr(network, "rhs")
do_rate_eval = getattr(network, "do_rate_eval")
ydot_eq = getattr(network, "ydot_eq")
jacobian = getattr(network, "jacobian")
# Get the appropriate screening function
screen_func = get_screening_func(screen_method)
# Setup the initial molar abundance if Y0 is None
if molar_composition is None:
if initial_comp is None:
raise ValueError("Valid initial compositions are ['uniform', 'random', 'solar']")
comp = Composition(self.unique_nuclei, init=initial_comp)
Y0 = comp.get_molar_array()
else:
if isinstance(molar_composition, Composition):
# don't assume that the input composition is in the same order
# as this network
Y0 = np.array([molar_composition[nuc] / nuc.A for nuc in self.unique_nuclei])
else:
Y0 = np.asarray(molar_composition)
# make sure the initial compositon's mass fractions sum to ~ 1
sumX = sum(Y0[n] * nuc.A for n, nuc in enumerate(self.unique_nuclei))
if abs(sumX - 1) > 1.e-10:
raise ValueError("initial mass fractions don't sum to 1")
# if we have a stopping condition, setup the event
events = None
if stopping_condition:
nuc, val = stopping_condition
if isinstance(nuc, str):
nuc = Nucleus(nuc)
# find the index of nuc in the solution vector
idx = self.unique_nuclei.index(nuc)
assert idx >= 0, "nucleus not present in solution vector"
def exhaustion(t, y, *args): # pylint: disable=unused-argument
return y[idx] - val / nuc.A
exhaustion.terminal = True
exhaustion.direction = -1
events = [exhaustion]
if self_heating:
energy_release = getattr(network, "energy_release")
def rhs_wrapper(t, xi, do_rate_eval, ydot_eq, energy_release, rho, screen_func):
nspec = len(self.unique_nuclei)
assert len(xi) == nspec + 1
# unpack the integration state
Y = xi[0:nspec]
T = xi[-1]
dxidt = np.zeros_like(xi)
rate_eval = do_rate_eval(t, Y, rho, T, screen_func)
dYdt = ydot_eq(Y, rho, rate_eval)
dxidt[0:nspec] = dYdt
# now get the energy generation rate using this dY/dt
eps = energy_release(dYdt) + rate_eval.enuc_weak
# use the EOS to get the specific heat
eos = StellarEOS()
comp = Composition(self.unique_nuclei)
comp.set_molar_array(Y)
state = eos.pe_state(rho, T, comp)
# include neutrino loss in the temperature evolution equation if wanted
eps_nu = 0.0
if thermal_neutrinos:
eps_nu = sneut5(rho, T, comp)
# and finally compute dT/dt
dxidt[-1] = (eps - eps_nu) / state.c_v
return dxidt
xi0 = np.append(Y0, T)
sol = solve_ivp(rhs_wrapper, [0, tmax], xi0, method="BDF",
dense_output=True,
args=(do_rate_eval, ydot_eq, energy_release, rho, screen_func),
rtol=rtol, atol=atol,
events=events)
else:
# Integrate using SciPy's solve_ivp() using BDF method --
# good for stiff system.
sol = solve_ivp(rhs, [0, tmax], Y0, method="BDF",
dense_output=True, args=(rho, T, screen_func),
rtol=rtol, atol=atol, jac=jacobian,
events=events)
if not sol.success:
warnings.warn(f"Warning, integration failed, final integration time = {sol.t[-1]}")
# Create NetworkSolution
network_sol = NetworkSolution(sol, rhs, jacobian, self,
rho, T, screen_func=screen_func,
self_heating=self_heating,
thermal_neutrinos=thermal_neutrinos,
do_rate_eval=do_rate_eval,
ydot_eq=ydot_eq)
return network_sol