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] @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): """return t factors as array in order of lambda function""" 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. """ def __init__(self, reactants=None, products=None, Q=None, weak_type="", label="generic", use_identical_particle_factor=True): """a generic Rate class that acts as a base class for specific sources. Here we only specify the reactants and products and Q value""" 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}' if Q is None: self._set_q() else: self.Q = Q 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 self._set_rhs_properties() self._set_screening() self._set_print_representation() 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 self.reactants: self.Q += n.mass for n in self.products: self.Q += -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 strong_test = sum(n.Z for n in self.reactants) == sum(n.Z for n in self.products) and \ sum(n.A for n in self.reactants) == sum(n.A for n in self.products) 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 "_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 "_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") for n, r in enumerate(treactants): self.string += f"{r.c()}" self.rid += f"{r}" self.pretty_string += fr"{r.pretty}" if not n == len(self.reactants)-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 " for n, p in enumerate(self.products): self.string += f"{p.c()}" self.rid += f"{p}" self.pretty_string += fr"{p.pretty}" if not n == len(self.products)-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 # using screenz.f90 provided by StarKiller Microphysics. # If not eligible for screening, set to None # If eligible for screening, then # Rate.ion_screen is a 2-element (3 for 3-alpha) list of Nucleus objects for screening 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""" # 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): """ Get an identifying string for this rate.""" return f'{self.rid} <{self.label.strip()}>'
@property def id(self): return self.get_rate_id()
[docs] def heaviest(self): """ Return the heaviest nuclide in this Rate. If two nuclei are tied in mass number, return the one with the lowest atomic number. """ 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): """ Return the lightest nuclide in this Rate. If two nuclei are tied in mass number, return the one with the highest atomic number. """ 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 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. """ 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): """ Return a string containing the term in a dY/dt equation in a reaction network corresponding to this rate. """ ydot_string_components = [] # prefactor 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): 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 y_i is an object of the class ``Nucleus``. """ 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), y_i is a Nucleus object. This rate term has the full composition and density dependence, i.e.: rate = rho**n Y1**a Y2**b ... N_A <sigma v> The derivative is only non-zero if this term depends on nucleus y_i. """ 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", "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.""" return cls.labels.get(label.lower().strip())
[docs] class RatePair: """the forward and reverse rates for a single reaction sequence. Forward rates are those with Q >= 0. :var forward: the forward reaction Rate object :var reverse: the reverse reaction Rate object """ 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