Source code for pynucastro.rates.rate

"""Classes and methods to interface with files storing rate data.

"""

import math
from pathlib import Path

import numpy as np

import pynucastro.numba_util as numba
from pynucastro.nucdata import Nucleus
from pynucastro.numba_util import jitclass
from pynucastro.rates.files import _find_rate_file


[docs] class BaryonConservationError(Exception): pass
[docs] @jitclass([ ('T9', numba.float64), ('T9i', numba.float64), ('T913', numba.float64), ('T913i', numba.float64), ('T953', numba.float64), ('lnT9', numba.float64) ]) class Tfactors: """ precompute temperature factors for speed :param float T: input temperature (Kelvin) :var T9: T / 1.e9 K :var T9i: 1.0 / T9 :var T913i: 1.0 / T9 ** (1/3) :var T913: T9 ** (1/3) :var T953: T9 ** (5/3) :var lnT9: log(T9) """ def __init__(self, T): """ return the Tfactors object. Here, T is temperature in Kelvin """ self.T9 = T/1.e9 self.T9i = 1.0/self.T9 self.T913i = self.T9i**(1./3.) self.T913 = self.T9**(1./3.) self.T953 = self.T9**(5./3.) self.lnT9 = np.log(self.T9) @property def array(self): """Compute the various T factors in the same order required by the lambda fit used by ReacLib rates Returns ------- numpy.ndarray """ return np.array([1, self.T9i, self.T913i, self.T913, self.T9, self.T953, self.lnT9])
[docs] class Rate: """The base reaction rate class. Most rate types will subclass this and extend to their particular format. Parameters ---------- reactants : list(str), list(Nucleus) the reactants for the reaction products : list(str), list(Nucleus) the products of the reactions Q : float the energy release (in MeV) for the rate weak_type : str the type of weak reaction the rate represents. Possible values include "electron_capture", "beta_decay", or may include "_pos_" or "_neg_". label : str A descriptive label for the rate (usually representative of the source use_identical_particle_factor : bool For a rate that has the same nucleus multiple times as a reactant we apply a multiplicity factor, N!, where N is the number of times the nucleus appears. This can be disabled by setting use_identical_particle_factor = False """ def __init__(self, reactants=None, products=None, Q=None, weak_type="", label="generic", stoichiometry=None, use_identical_particle_factor=True): if reactants: self.reactants = Nucleus.cast_list(reactants) else: self.reactants = [] if products: self.products = Nucleus.cast_list(products) else: self.products = [] self.label = label self.source = None self.modified = False # the fname is used when writing the code to evaluate the rate reactants_str = '_'.join([repr(nuc) for nuc in self.reactants]) products_str = '_'.join([repr(nuc) for nuc in self.products]) self.fname = f'{reactants_str}__{products_str}__{label}' self.weak_type = weak_type # the identical particle factor scales the rate to prevent # double counting for a rate that has the same nucleus # multiple times as a reactant. Usually we want this # behavior, but for approximate rates, sometimes we need to # disable it. self.use_identical_particle_factor = use_identical_particle_factor # some subclasses might define a stoichmetry as a dict{Nucleus} # that gives the numbers for the dY/dt equations self.stoichiometry = stoichiometry # Set Q-value of the reaction rate. Needs to go after stoichiometry. if Q is None: self._set_q() else: self.Q = Q self._set_rhs_properties() self._set_screening() self._set_print_representation() # ensure that baryon number is conserved test = ( sum(n.A * self.reactant_count(n) for n in set(self.reactants)) == sum(n.A * self.product_count(n) for n in set(self.products)) ) if not test: raise BaryonConservationError(f"baryon number not conserved in rate {self}") self.tabular = False self.reverse = None self.rate_eval_needs_rho = False self.rate_eval_needs_comp = False def __repr__(self): return self.string def __hash__(self): return hash(self.__repr__()) def __eq__(self, other): """ Determine whether two Rate objects are equal. They are equal if they contain identical reactants and products""" return (self.reactants, self.products) == (other.reactants, other.products) def __lt__(self, other): """sort such that lightest reactants come first, and then look at products""" # this sort will make two nuclei with the same A be in order of Z # (assuming there are no nuclei with A > 999 # we want to compare based on the heaviest first, so we reverse self_react_sorted = sorted(self.reactants, key=lambda x: 1000*x.A + x.Z, reverse=True) other_react_sorted = sorted(other.reactants, key=lambda x: 1000*x.A + x.Z, reverse=True) if self_react_sorted != other_react_sorted: # reactants are different, so now we can check them for srn, orn in zip(self_react_sorted, other_react_sorted): if not srn == orn: return srn < orn else: # reactants are the same, so consider products self_prod_sorted = sorted(self.products, key=lambda x: 1000*x.A + x.Z, reverse=True) other_prod_sorted = sorted(other.products, key=lambda x: 1000*x.A + x.Z, reverse=True) for spn, opn in zip(self_prod_sorted, other_prod_sorted): if not spn == opn: return spn < opn # if we made it here, then the rates are the same return True def _set_q(self): """set the Q value of the reaction (in MeV)""" # from the masses of the nuclei, Q = M_products - M_reactants self.Q = 0 for n in set(self.reactants): c = self.reactant_count(n) self.Q += c * n.mass for n in set(self.products): c = self.product_count(n) self.Q += -c * n.mass def _set_print_representation(self): # string is output to the terminal, rid is used as a dict key, # and pretty_string is latex # some rates will have no nuclei particles (e.g. gamma) on the left or # right -- we'll try to infer those here lhs_other = [] rhs_other = [] self.string = "" self.rid = "" self.pretty_string = r"$" # put p, n, and alpha second treactants = [] for n in self.reactants: if n.raw not in ["p", "he4", "n"]: treactants.insert(0, n) else: treactants.append(n) # figure out if there are any non-nuclei present # for the moment, we just handle strong rates # there should be the same number of protons on each side and # the same number of neutrons on each side reactant_Zs = sum(n.Z * self.reactant_count(n) for n in set(self.reactants)) product_Zs = sum(n.Z * self.product_count(n) for n in set(self.products)) reactant_As = sum(n.A * self.reactant_count(n) for n in set(self.reactants)) product_As = sum(n.A * self.product_count(n) for n in set(self.products)) strong_test = reactant_Zs == product_Zs and reactant_As == product_As if strong_test: if len(self.products) == 1: rhs_other.append("gamma") else: # this is a weak rate if self.weak_type == "electron_capture": # we assume that all the tabular rates are electron capture for now # we expect an electron on the left -- let's make sure # the charge on the left should be +1 the charge on the right assert sum(n.Z for n in self.reactants) == sum(n.Z for n in self.products) + 1 lhs_other.append("e-") rhs_other.append("nu") elif self.weak_type == "beta_decay": # we expect an electron on the right assert sum(n.Z for n in self.reactants) + 1 == sum(n.Z for n in self.products) rhs_other.append("e-") rhs_other.append("nubar") elif self.weak_type and "_pos_" in self.weak_type: # we expect a positron on the right -- let's make sure assert sum(n.Z for n in self.reactants) == sum(n.Z for n in self.products) + 1 rhs_other.append("e+") rhs_other.append("nu") elif self.weak_type and "_neg_" in self.weak_type: # we expect an electron on the right -- let's make sure assert sum(n.Z for n in self.reactants) + 1 == sum(n.Z for n in self.products) rhs_other.append("e-") rhs_other.append("nubar") else: # we need to figure out what the rate is. We'll assume that it is # not an electron capture if sum(n.Z for n in self.reactants) == sum(n.Z for n in self.products) + 1: rhs_other.append("e+") rhs_other.append("nu") elif sum(n.Z for n in self.reactants) + 1 == sum(n.Z for n in self.products): rhs_other.append("e-") rhs_other.append("nubar") # this produces a sorted list with no dupes react_set = list(dict.fromkeys(treactants)) for n, r in enumerate(react_set): c = self.reactant_count(r) if c == 2: # special case so we do C12 + C12 instead of 2 C12 self.string += f"{r.c()} + {r.c()}" self.rid += f"{r} + {r}" self.pretty_string += fr"{r.pretty} + {r.pretty}" else: factor = "" if c != 1: factor = f"{c} " self.string += f"{factor}{r.c()}" self.rid += f"{factor}{r}" self.pretty_string += fr"{factor}{r.pretty}" if not n == len(react_set)-1: self.string += " + " self.rid += " + " self.pretty_string += r" + " for o in lhs_other: if o == "e-": self.string += " + e⁻" self.pretty_string += r" + \mathrm{e}^-" self.string += " ⟶ " self.rid += " --> " self.pretty_string += r" \rightarrow " prod_set = list(dict.fromkeys(self.products)) for n, p in enumerate(prod_set): c = self.product_count(p) if c == 2: # special case for 2 species self.string += f"{p.c()} + {p.c()}" self.rid += f"{p} + {p}" self.pretty_string += fr"{p.pretty} + {p.pretty}" else: factor = "" if c != 1: factor = f"{c} " self.string += f"{factor}{p.c()}" self.rid += f"{factor}{p}" self.pretty_string += fr"{factor}{p.pretty}" if not n == len(prod_set)-1: self.string += " + " self.rid += " + " self.pretty_string += r" + " for o in rhs_other: if o == "gamma": self.string += " + 𝛾" self.pretty_string += r"+ \gamma" elif o == "nu": self.string += " + 𝜈" self.pretty_string += r"+ \nu_e" elif o == "nubar": self.string += " + 𝜈" self.pretty_string += r"+ \bar{\nu}_e" if o == "e-": self.string += " + e⁻" self.pretty_string += r" + \mathrm{e}^-" if o == "e+": self.string += " + e⁺" self.pretty_string += r" + \mathrm{e}^+" self.pretty_string += r"$" def _set_rhs_properties(self): """ compute statistical prefactor and density exponent from the reactants. """ self.prefactor = 1.0 # this is 1/2 for rates like a + a (double counting) self.inv_prefactor = 1 if self.use_identical_particle_factor: for r in set(self.reactants): self.inv_prefactor = self.inv_prefactor * math.factorial(self.reactants.count(r)) self.prefactor = self.prefactor/float(self.inv_prefactor) self.dens_exp = len(self.reactants)-1 if self.weak_type == 'electron_capture': self.dens_exp = self.dens_exp + 1 def _set_screening(self): """Determine if this rate is eligible for screening and the nuclei to use.""" # Tells if this rate is eligible for screening, and if it is # then Rate.ion_screen is a 2-element (3 for 3-alpha) list of # Nucleus objects for screening; otherwise it is set to none self.ion_screen = [] nucz = [q for q in self.reactants if q.Z != 0] if len(nucz) > 1: nucz.sort(key=lambda x: x.Z) self.ion_screen = [] self.ion_screen.append(nucz[0]) self.ion_screen.append(nucz[1]) if len(nucz) == 3: self.ion_screen.append(nucz[2]) # if the rate is a reverse rate (defined as Q < 0), then we # might actually want to compute the screening based on the # reactants of the forward rate that was used in the detailed # balance. Rate.symmetric_screen is what should be used in # the screening in this case self.symmetric_screen = [] if self.Q < 0: nucz = [q for q in self.products if q.Z != 0] if len(nucz) > 1: nucz.sort(key=lambda x: x.Z) self.symmetric_screen = [] self.symmetric_screen.append(nucz[0]) self.symmetric_screen.append(nucz[1]) if len(nucz) == 3: self.symmetric_screen.append(nucz[2]) else: self.symmetric_screen = self.ion_screen
[docs] def cname(self): """A C++-safe version of the rate name Returns ------- str """ # replace the "__" separating reactants and products with "_to_" # and convert all other "__" to single "_" return self.fname.replace("__", "_to_", 1).replace("__", "_")
[docs] def get_rate_id(self): """An identifying string for this rate. Returns ------- str """ return f'{self.rid} <{self.label.strip()}>'
@property def id(self): """the rate's id string Returns ------- str """ return self.get_rate_id()
[docs] def heaviest(self): """Get the heaviest nuclide in this Rate. If two nuclei are tied in mass number, return the one with the lowest atomic number. Returns ------- Nucleus """ nuc = self.reactants[0] for n in self.reactants + self.products: if n.A > nuc.A or (n.A == nuc.A and n.Z < nuc.Z): nuc = n return nuc
[docs] def lightest(self): """Get the lightest nuclide in this Rate. If two nuclei are tied in mass number, return the one with the highest atomic number. Returns ------- Nucleus """ nuc = self.reactants[0] for n in self.reactants + self.products: if n.A < nuc.A or (n.A == nuc.A and n.Z > nuc.Z): nuc = n return nuc
[docs] def swap_protons(self): """Change any protons in the rate to NSE protons. These act the same as protons but will be kept as distinct in the network.""" p = Nucleus("p") p_nse = Nucleus("p_nse") for n, nuc in enumerate(self.reactants): if nuc == p: self.reactants[n] = p_nse for n, nuc in enumerate(self.products): if nuc == p: self.products[n] = p_nse # we need to update the Q value and the print string for the rate self._set_q() self._set_screening() self.fname = None # reset so it will be updated self._set_print_representation()
[docs] def reactant_count(self, n): """Return the number of times nucleus n appears as a reactant in the rate. Use the stoichiometry dict if present. Parameters ---------- n : Nucleus the nucleus appearing as a reactant Returns ------- float """ c_reac = self.reactants.count(n) if self.stoichiometry and c_reac > 0: return self.stoichiometry.get(n, c_reac) return c_reac
[docs] def product_count(self, n): """Return the number of times nucleus n appears as a product in the rate. Use the stoichiometry dict if present. Parameters ---------- n : Nucleus the nucleus appearing as a product Returns ------- float """ c_prod = self.products.count(n) if self.stoichiometry and c_prod > 0: return self.stoichiometry.get(n, c_prod) return c_prod
[docs] def modify_products(self, new_products): """Change the products of the rate to new_products. This will recompute the Q value and update the print representation. Parameters ---------- new_products : list(Nucleus) the new products to use with the rate. """ self.products = Nucleus.cast_list(new_products, allow_single=True) self.modified = True # we need to update the Q value and the print string for the rate self._set_q() self._set_screening() self.fname = None # reset so it will be updated self._set_print_representation()
[docs] def ydot_string_py(self): """Construct the string containing the term in a dY/dt equation in a reaction network corresponding to this rate. Returns ------- str """ ydot_string_components = [] # prefactor (for double counting) if self.prefactor != 1.0: ydot_string_components.append(f"{self.prefactor:1.14e}") # density dependence if self.dens_exp == 1: ydot_string_components.append("rho") elif self.dens_exp != 0: ydot_string_components.append(f"rho**{self.dens_exp}") # electron fraction dependence if self.weak_type == 'electron_capture' and not self.tabular: ydot_string_components.append("ye(Y)") # composition dependence for r in sorted(set(self.reactants)): c = self.reactants.count(r) if c > 1: ydot_string_components.append(f"Y[j{r.raw}]**{c}") else: ydot_string_components.append(f"Y[j{r.raw}]") # rate_eval.{fname} ydot_string_components.append(f"rate_eval.{self.fname}") return "*".join(ydot_string_components)
[docs] def eval(self, T, *, rho=None, comp=None): """Evaluate the reaction rate for temperature T. This is a stub and should be implemented by the derived class. Parameters ---------- T : float the temperature to evaluate the rate at rho : float the density to evaluate the rate at (not needed for ReacLib rates). comp : float the composition (of type :py:class:`Composition <pynucastro.networks.rate_collection.Composition>`) to evaluate the rate with (not needed for ReacLib rates). Raises ------ NotImplementedError """ raise NotImplementedError("base Rate class does not know how to eval()")
[docs] def jacobian_string_py(self, y_i): """Return a string containing the term in a jacobian matrix in a reaction network corresponding to this rate differentiated with respect to ``y_i`` Parameters ---------- y_i : Nucleus the nucleus we are differentiating with respect to. Returns ------- str """ if y_i not in self.reactants: return "" jac_string_components = [] # prefactor if self.prefactor != 1.0: jac_string_components.append(f"{self.prefactor:1.14e}") # density dependence if self.dens_exp == 1: jac_string_components.append("rho") elif self.dens_exp != 0: jac_string_components.append(f"rho**{self.dens_exp}") # electron fraction dependence if self.weak_type == 'electron_capture' and not self.tabular: jac_string_components.append("ye(Y)") # composition dependence for r in sorted(set(self.reactants)): c = self.reactants.count(r) if y_i == r: # take the derivative if c == 1: continue if c > 2: jac_string_components.append(f"{c}*Y[j{r.raw}]**{c-1}") elif c == 2: jac_string_components.append(f"2*Y[j{r.raw}]") else: # this nucleus is in the rate form, but we are not # differentiating with respect to it if c > 1: jac_string_components.append(f"Y[j{r.raw}]**{c}") else: jac_string_components.append(f"Y[j{r.raw}]") # rate_eval.{fname} jac_string_components.append(f"rate_eval.{self.fname}") return "*".join(jac_string_components)
[docs] def eval_jacobian_term(self, T, rho, comp, y_i): """Evaluate drate/d(y_i), the derivative of the rate with respect to ``y_i``. This rate term has the full composition and density dependence, i.e.: rate = ρ**n Y1**a Y2**b ... N_A <σv> The derivative is only non-zero if this term depends on nucleus ``y_i``. Parameters ---------- T : float the temperature to evaluate the rate with rho : float the density to evaluate the rate with comp : Composition the composition to use in the rate evaluation y_i : Nucleus the nucleus we are differentiating with respect to Returns ------- float """ if y_i not in self.reactants: return 0.0 ymolar = comp.get_molar() # composition dependence Y_term = 1.0 for r in sorted(set(self.reactants)): c = self.reactants.count(r) if y_i == r: # take the derivative if c == 1: continue Y_term *= c * ymolar[r]**(c-1) else: # this nucleus is in the rate form, but we are not # differentiating with respect to it Y_term *= ymolar[r]**c # density dependence dens_term = rho**self.dens_exp # electron fraction dependence if self.weak_type == 'electron_capture' and not self.tabular: y_e_term = comp.ye else: y_e_term = 1.0 # finally evaluate the rate -- for tabular rates, we need to set rhoY rate_eval = self.eval(T, rho=rho, comp=comp) return self.prefactor * dens_term * y_e_term * Y_term * rate_eval
[docs] class RateSource: """A class that stores the reference label information for various rates.""" csv_path = _find_rate_file("rate_sources.csv") urls = { "debo": "https://doi.org/10.1103/RevModPhys.89.035007", "langanke": "https://doi.org/10.1006/adnd.2001.0865", "suzuki": "https://doi.org/10.3847/0004-637X/817/2/163", "ffn": "https://doi.org/10.1086/190779", "reaclib": "https://reaclib.jinaweb.org/labels.php?action=viewLabel&label=" } @staticmethod def _read_rate_sources(urls: dict[str, str], csv_path: Path) -> dict[str, dict[str, str]]: """Builds the labels dictionary from the supplied csv file.""" labels = {} with csv_path.open("r") as csv: lines = csv.readlines() column_titles = lines[0].split("|") for line in lines[1:]: cells = [cell.strip() for cell in line.split("|")] label = cells[0] label_data = labels[label.lower()] = dict(zip(column_titles, cells)) label_data["URL"] = urls.get(label, urls["reaclib"] + label) return labels labels = _read_rate_sources(urls, csv_path)
[docs] @classmethod def source(cls, label: str) -> dict[str, str] | None: """Returns the source of a rate given its label, and None if not found. Parameters ---------- label : str """ return cls.labels.get(label.lower().strip())
[docs] class RatePair: """A pair of rates: the forward and reverse rates for a single reaction sequence. Forward rates are those with Q >= 0. Parameters ---------- forward : Rate the forward rate reverse : Rate the reverse rate """ def __init__(self, forward=None, reverse=None): self.forward = forward self.reverse = reverse def __repr__(self): return f"forward: {self.forward} ; reverse: {self.reverse}" def __lt__(self, other): if self.forward is not None and other.forward is not None: return self.forward < other.forward if self.forward is None: return False return True def __eq__(self, other): return self.forward == other.forward and self.reverse == other.reverse