Source code for pynucastro.networks.nse_network

import warnings

import numpy as np
from scipy.optimize import fsolve

from pynucastro._version import version
from pynucastro.constants import constants
from pynucastro.networks.rate_collection import Composition, RateCollection
from pynucastro.rates import TabularRate
from pynucastro.screening import NseState, potekhin_1998


[docs] class NSETableEntry: """A simple container to hold a single entry in the NSE table. Parameters ---------- rho : float the density of the NSE state T : float the temperature of the NSE state Ye : float the electron fraction comp : Composition the NSE composition ydots : dict a dictionary of dY/dt keyed by Nucleus. This is meant to be the weak nuclear rates only, since those affect the NSE state. enu : float the weak rate neutrino energy loss comp_reduction_function : Callable a function that converts the NSE composition into a smaller set of nuclei. It takes a Composition object and returns a dictionary with the nucleus name (like "Ni56") as the key and the corresponding mass fraction as the value. It should be ordered in the way you want the nuclei output into the NSE table file. """ def __init__(self, rho, T, Ye, *, comp=None, ydots=None, enu=None, comp_reduction_func=None): self.rho = rho self.T = T self.Ye = Ye self.comp = comp self.ydots = ydots self.enu = enu # compute the bits we need for the table if comp: # mean molecular weight of the full NSE state self.abar = comp.abar # average binding energy / nucleon for the full NSE state self.bea = sum(q.nucbind * self.comp.X[q] for q in self.comp.X) # evolution of the electron fraction from weak rates alone self.dYedt = sum(q.Z * self.ydots[q] for q in self.comp.X) # evolution of Abar from weak rates alone self.dabardt = -self.abar**2 * sum(self.ydots[q] for q in self.comp.X) # evolution of B/A from weak rates alone self.dbeadt = sum(self.ydots[q] * q.nucbind for q in self.comp.X) self.X = None if comp_reduction_func: self.X = comp_reduction_func(self.comp) def __str__(self): return f"({self.rho:12.6g}, {self.T:12.6g}, {self.Ye:6.4f}): {self.abar:6.4f} {self.bea:6.4f} {self.dYedt:12.6g} {self.enu:12.6g}"
[docs] def value(self): """a simple integer used for sorting. This has the format (logrho)(logT)(1-Ye)""" return int(f"{np.log10(self.rho):08.5f}{np.log10(self.T):08.5f}{1-self.Ye:08.5f}".replace(".", ""))
def __lt__(self, other): return self.value() < other.value()
[docs] class NSENetwork(RateCollection): """a network for solving the NSE composition and outputting tabulated NSE quantities""" def _evaluate_mu_c(self, state, use_coulomb_corr=True): """A helper function that finds the coulomb potential of each nuclide in NSE state. The coulomb potential is evaluated using the Helmholtz free energy following Eq. 28 from Chabrier & Potekhin 1998 DOI: 10.1103/PhysRevE.58.4941 Also see appendix in Calder 2007 DOI: 10.1086/510709 A1 = -0.9052 A2 = 0.6322 A3 = -sqrt(3) / 2 - A1 / sqrt(A2) Γ = Z^{5/3} q_e^2 (4 π n_e / 3)^{1/3} / (k_B T) u^c / (k_B T)= A1 [ sqrt(Γ (A2 + Γ)) - A2 ln(sqrt(Γ / A2) + sqrt(1 + Γ / A2)) ] + 2 A3 [ sqrt(Γ) - atan(sqrt(Γ)) ] Parameters ---------- state : NseState NSE state use_coulomb_corr: bool whether to include coulomb correction terms Returns ------- u_c : dict A dictionary of coulomb potential keyed by Nucleus. """ if not use_coulomb_corr: u_c = {n: 0 for n in self.unique_nuclei} return u_c u_c = {} n_e = state.ye * state.dens / constants.m_u_C18 A_1 = -0.9052 A_2 = 0.6322 A_3 = -0.5 * np.sqrt(3.0) - A_1 / np.sqrt(A_2) Gamma_e = state.gamma_e_fac * n_e**(1.0/3.0) / state.temp for nuc in self.unique_nuclei: Gamma = Gamma_e * nuc.Z**(5.0 / 3.0) u_c[nuc] = constants.erg2MeV * constants.k * state.temp * \ (A_1 * (np.sqrt(Gamma * (A_2 + Gamma)) - A_2 * np.log(np.sqrt(Gamma / A_2) + np.sqrt(1.0 + Gamma / A_2))) + 2.0 * A_3 * (np.sqrt(Gamma) - np.arctan(np.sqrt(Gamma)))) return u_c def _nucleon_fraction_nse(self, u, u_c, state): """A helper function that computes the NSE mass fraction for a given NSE state. NSE condition says that the chemical potential of the i-th nuclei is equal to the chemical potential of the free nucleon. i.e. μ_i = Z_i μ_p + N_i μ_n where μ_p and μ_n are the chemical potential of free proton and neutron, which can be decomposed into the kinetic part μ^{id}, coulomb potential part μ^c and its rest energy. μ_p = μ^{id}_p + μ^{c}_p + m_p c^2 μ_n = μ^{id}_n + m_n c^2 Assuming Boltzmann statistics and the NSE condition, we get: X_i = m_i / ρ (2 J_i + 1) G_i (m_i k_B T / (2 π ℏ^2))^{3/2} * exp{ Z_i (μ^{id}_p + μ^{c}_p) + N_i μ^{id}_n + B_i - μ^{c}_i} See full derivation in appendix Smith Clark 2023 DOI: 10.3847/1538-4357/acbaff Parameters ---------- u : (ndarray, tuple, list) chemical potentials of proton and neutron, where we choose u[0] = μ^{id}_p + μ^{c}_p and u[1] = μ^{id}_n One can make the choice of setting u[0] = μ^{id}_p only and add μ^{c}_p as a separate constant. However it won't affect the end result. u_c : dict A dictionary of coulomb potential keyed by Nucleus. state : NseState NSE state. Returns ------- Xs : dict A dictionary of NSE mass fractions keyed by Nucleus. """ Xs = {} for nuc in self.unique_nuclei: if nuc.partition_function: pf = nuc.partition_function.eval(state.temp) else: pf = 1.0 if not nuc.spin_states: raise ValueError(f"The spin of {nuc} is not implemented for now.") nse_exponent = (nuc.Z * u[0] + nuc.N * u[1] - u_c[nuc] + nuc.nucbind * nuc.A) / (constants.k_MeV * state.temp) nse_exponent = min(500.0, nse_exponent) Xs[nuc] = (nuc.A_nuc * constants.m_u_C18)**2.5 * pf * nuc.spin_states / state.dens * \ (constants.k * state.temp / (2.0 * np.pi * constants.hbar**2))**1.5 * np.exp(nse_exponent) return Xs def _constraint_eq(self, u, u_c, state): """ Constraint Equations used to evaluate chemical potential for proton and neutron, which is used when evaluating composition at NSE. 1) Conservation of Mass: Σ_k X_k - 1 = 0 2) Conservation of Charge: Σ_k Z_k X_k / A_k - Y_e = 0 Parameters ---------- u : (ndarray, tuple, list) chemical potentials of proton and neutron. u[0] = μ^{id}_p + μ^{c}_p and u[1] = μ^{id}_n u_c : dict A dictionary of coulomb potential keyed by Nucleus. state : NseState NSE state Returns ------- constraint_eqs : List Constraint equations for NSE. """ Xs = self._nucleon_fraction_nse(u, u_c, state) constraint_eqs = [sum(Xs[nuc] for nuc in self.unique_nuclei) - 1.0, sum(Xs[nuc] * nuc.Z / nuc.A for nuc in self.unique_nuclei) - state.ye] return constraint_eqs
[docs] def get_comp_nse(self, rho, T, ye, init_guess=(-3.5, -15), tol=1.0e-11, use_coulomb_corr=False, return_sol=False): """ Returns the NSE composition given density, temperature and prescribed electron fraction using scipy.fsolve. Parameters ---------- rho : float NSE state density T : float NSE state Temperature ye : float prescribed electron fraction init_guess : (tuple, list) initial guess of chemical potential of proton and neutron, [μ^{id}_p + μ^{c}_p, μ^{id}_n] tol : float tolerance of scipy.fsolve use_coulomb_corr : bool whether to include coulomb correction terms return_sol : bool whether to return the solution of the proton and neutron chemical potential. Returns ------- comp : Composition the NSE composition u : ndarray the chemical potentials found by solving the nonlinear system. """ # Determine the upper and lower bound of possible ye for the current network. # Make sure the prescribed ye are within this range. ye_low = min(nuc.Z/nuc.A for nuc in self.unique_nuclei) ye_max = max(nuc.Z/nuc.A for nuc in self.unique_nuclei) assert ye_low <= ye <= ye_max, "input electron fraction goes outside of scope for current network" init_guess = np.array(init_guess) state = NseState(T, rho, ye) # Evaluate coulomb potential u_c = self._evaluate_mu_c(state, use_coulomb_corr) j = 0 is_pos_old = False found_sol = False # This nested loops should fine-tune the initial guess if fsolve is unable to find a solution while j < 20: i = 0 guess = init_guess.copy() init_dx = 0.5 while i < 20: # Filter out runtimewarnings from fsolve, here we check convergence by np.isclose with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=RuntimeWarning) u = fsolve(self._constraint_eq, guess, args=(u_c, state), xtol=tol, maxfev=800) Xs = self._nucleon_fraction_nse(u, u_c, state) res = self._constraint_eq(u, u_c, state) is_pos_new = all(k > 0 for k in res) found_sol = np.all(np.isclose(res, [0.0, 0.0], rtol=1.0e-11, atol=1.0e-11)) if found_sol: Xs = self._nucleon_fraction_nse(u, u_c, state) comp = Composition(self.unique_nuclei) comp.X = Xs if return_sol: return comp, u return comp if is_pos_old != is_pos_new: init_dx *= 0.8 if is_pos_new: guess -= init_dx else: guess += init_dx is_pos_old = is_pos_new i += 1 j += 1 init_guess[0] -= 0.5 raise ValueError("Unable to find a solution, try to adjust initial guess manually")
[docs] def generate_table(self, rho_values=None, T_values=None, Ye_values=None, comp_reduction_func=None, verbose=False, outfile="nse.tbl"): """Generate a table of NSE properties. For every combination of density, temperature, and Ye, we solve for the NSE state and output composition properties to a file. Parameters ---------- rho_values : numpy.ndarray values of density to use in the tabulation T_values : numpy.ndarray values of temperature to use in the tabulation Ye_values : numpy.ndarray values of electron fraction to use in the tabulation comp_reduction_func : Callable a function that takes the NSE composition and return a reduced composition verbose : bool output progress on creating the table as we go along outfile : str filename for the NSE table output """ # initial guess mu_p0 = -3.5 mu_n0 = -15.0 # arrays to cache the chemical potentials as mu_p(rho, Ye) mu_p = np.ones((len(rho_values), len(Ye_values)), dtype=np.float64) * mu_p0 mu_n = np.ones((len(rho_values), len(Ye_values)), dtype=np.float64) * mu_n0 nse_states = [] for T in reversed(T_values): for irho, rho in enumerate(reversed(rho_values)): for iye, ye in enumerate(reversed(Ye_values)): initial_guess = (mu_p[irho, iye], mu_n[irho, iye]) try: comp, sol = self.get_comp_nse(rho, T, ye, use_coulomb_corr=True, init_guess=initial_guess, return_sol=True) except ValueError: initial_guess = (-3.5, -15) comp, sol = self.get_comp_nse(rho, T, ye, use_coulomb_corr=True, init_guess=initial_guess, return_sol=True) mu_p[irho, iye] = sol[0] mu_n[irho, iye] = sol[1] # get the dY/dt for just the weak rates ydots = self.evaluate_ydots(rho, T, comp, screen_func=potekhin_1998, rate_filter=lambda r: isinstance(r, TabularRate)) _, enu = self.evaluate_energy_generation(rho, T, comp, screen_func=potekhin_1998, return_enu=True) nse_states.append(NSETableEntry(rho, T, ye, comp=comp, ydots=ydots, enu=enu, comp_reduction_func=comp_reduction_func)) if verbose: print(nse_states[-1]) with open(outfile, "w") as of: # write the header of.write(f"# NSE table generated by pynucastro {version}\n") of.write(f"# original NSENetwork had {len(self.unique_nuclei)} nuclei\n") of.write("#\n") of.write(f"# {'log10(rho)':^15} {'log10(T)':^15} {'Ye':^15} ") of.write(f"{'Abar':^15} {'<B/A>':^15} {'dYe/dt':^15} {'dAbar/dt':^15} {'d<B/A>/dt':^15} {'e_nu':^15} ") if nse_states[0].X: for nuc, _ in nse_states[0].X: _tmp = f"X({nuc})" of.write(f"{_tmp:^15} ") of.write("\n") for entry in sorted(nse_states): of.write(f"{np.log10(entry.rho):15.10f} {np.log10(entry.T):15.10f} {entry.Ye:15.10f} ") of.write(f"{entry.abar:15.10f} {entry.bea:15.10f} {entry.dYedt:15.8g} {entry.dabardt:15.8g} {entry.dbeadt:15.8g} {entry.enu:15.8g} ") if entry.X: for _, val in entry.X: of.write(f"{val:15.10g} ") of.write("\n")