NSE vs. Direct Integration

NSE vs. Direct Integration#

import pynucastro as pyna

To further demonstrate the validity of the NSE equation, we will compare the equilibrium mass fractions computed via direct integration and the mass fractions obtained from NSE equations.

We will create the PythonNetwork needed for direct integration using the same library as our previous example.

First we recreate our library, but we make sure that we use reverse rates calculated via detailed balance. In pynucastro, this is the DerivedRate.

rl = pyna.ReacLibLibrary()

all_nuclei = ["p", "he4",
              "c12", "n13",
              "o16", "ne20", "na23",
              "mg24", "al27", "si28",
              "p31", "s32", "cl35",
              "ar36", "k39", "ca40",
              "sc43", "ti44", "v47",
              "cr48", "mn51",
              "fe52","co55","ni56"]

lib = rl.linking_nuclei(all_nuclei)

Here we search through the existing ReacLib reverse rates and remove them. So that Library only contains forward rates. We use the backward method for this.

reverse_rates = lib.backward().get_rates()
for r in reverse_rates:
    lib.remove_rate(r)

Now create the corresponding DerivedRate using the forward rate and add them to Library.

for r in lib.get_rates():
    d = pyna.DerivedRate(rate=r, compute_Q=True, use_pf=True)
    lib.add_rate(d)

We can also use ModifiedRate where we modify the reactants and/or the products of a reaction rate but still use the original rate. This is compatible with NSE as long as a corresponding DerivedRate is added.

Warning

Currently stoichiometry does NOT work with NSE.

other_rates = [("c12(c12,n)mg23", "mg24"),
               ("o16(o16,n)s31", "s32"),
               ("o16(c12,n)si27", "si28")]
for r, mp in other_rates:
    _r = rl.get_rate_by_name(r)
    forward_rate = pyna.ModifiedRate(_r, new_products=[mp])
    derived_rate = pyna.DerivedRate(rate=forward_rate, compute_Q=True, use_pf=True)
    lib.add_rates([forward_rate, derived_rate])

Now create the PythonNetwork using the modified Library object.

net = pyna.PythonNetwork(libraries=lib)

We can also do some approximations, such as the \((\alpha,p)(p,\gamma)\) approximation. This is fully compatible with NSE.

net.make_ap_pg_approx(intermediate_nuclei=["cl35", "k39", "sc43", "v47", "mn51", "co55"])
net.remove_nuclei(["cl35", "k39", "sc43", "v47", "mn51", "co55"])

Finally, we write out python module that contains essential functions needed for integration, such as the rhs and jacobian.

net.write_network(outfile="network.py")
/opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages/pynucastro/rates/derived_rate.py:126: UserWarning: C12 partition function is not supported by tables: set pf = 1.0 by default
  warnings.warn(UserWarning(f'{nuc} partition function is not supported by tables: set pf = 1.0 by default'))
/opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages/pynucastro/rates/derived_rate.py:126: UserWarning: N13 partition function is not supported by tables: set pf = 1.0 by default
  warnings.warn(UserWarning(f'{nuc} partition function is not supported by tables: set pf = 1.0 by default'))

Important

In order for the integrated equilibrium result to be fully compatible with NSE equations, watch out for a number of things:

  1. Make sure that all forward rates in the network has a corresponding DerivedRate. Otherwise there is no detailed balance.

  2. All species are connected.

  3. NSE only guarantees equilibrium of strong reactions. So if there are weak rates in the network, \(Y_e\) will evolve in time.

Direct integration#

Now we want to integrate. Since we will compare to NSE, we want to use the same screening that NSE uses–potekhin_1998

from pynucastro.screening import potekhin_1998
import network

and we’ll integrate using solve_ivp from scipy.integrate

import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp

Set up an initial condition and let it evolve until we reach equilibrium.

rho = 1e7
T = 6e9

X0 = np.ones(network.nnuc)
X0 /= np.sum(X0)
Y0 = X0/network.A

ye = network.ye(Y0)

tmax = 1.e-1
sol = solve_ivp(network.rhs, [0, tmax], Y0, method="BDF",
                 dense_output=True, args=(rho, T, potekhin_1998),
                 rtol=3.e-14, atol=3.e-14, jac=network.jacobian)

Let’s plot the evolution to see how mass fraction evolve with time. Equilibrium is indicated by flat lines.

nucs = list(map(pyna.Nucleus, network.names))
nuc_labels = [f"${n.pretty}$" for n in nucs]
fig, ax = plt.subplots()
for i in range(network.nnuc):
    ax.loglog(sol.t, sol.y[i,:]*network.A[i], label=rf"X(${nucs[i].pretty}$)")

ax.set_xlim(1e-16, tmax)
ax.set_xlabel(f"time [s]", fontsize=12)
ax.set_ylabel(f"mass fractions", fontsize=12)
ax.legend(loc="best", fontsize="small", ncol=3)
ax.grid(ls=":")
fig.tight_layout()
_images/8f84d2f8ebead9625fa55a1eadd8c28525c13353bafc9b03087667afa76a1ec2.png

NSE calculation#

We can create an NSENetwork by simply grabbing the rates from our current network and using them to construct the NSE network

nse = pyna.NSENetwork(rates=net.get_rates())
nse.summary()
Network summary
---------------
  explicitly carried nuclei: 18
  approximated-out nuclei: 6
  inert nuclei (included in carried): 0

  total number of rates: 105

  rates explicitly connecting nuclei: 66
  hidden rates: 39

  reaclib rates: 45
  tabular rates: 0
  approximate rates: 12
  derived rates: 45
  modified rates: 3
  custom rates: 0

Now let’s get the directly-computed NSE state

nse_comp = nse.get_comp_nse(rho, T, ye, init_guess=(-3.5, -14.0), use_coulomb_corr=True)

Finally, we can make some plots to see the direct comparison between the two results.

X_net = sol.y[:,-1]*network.A
X_nse = np.array(list(nse_comp.X.values()))

x = np.arange(len(network.names))
width = 0.35

fig, ax = plt.subplots()
ax.bar(x - width/2.0, X_nse, width, label = 'NSE')
ax.bar(x + width/2.0, X_net, width, label = 'Integration')
ax.set_xlabel("nucleus", fontsize=14)
ax.set_xticks(x, labels=nuc_labels, rotation=90, fontsize=14)
ax.set_ylabel("mass fractions", fontsize=14)
ax.legend(fontsize=14)
ax.set_yscale("log")
ax.set_ylim(ymax = 1)
ax.legend()
fig.tight_layout()
_images/d1fdafebe4b19d4309e6d47933cd11b2752fc724f5b830012f3f1dd98aa2bc72.png

Now a plot of absolute and relative difference shows that the mass fraction obtained from direct integration matches with NSE equations to machine precision. The differences between the two are largely set by the tolerances of both the NSE solver and the ODE integrator.

diff = np.abs(X_net - X_nse) 
diff_rel = diff / X_net

fig, ax = plt.subplots()
ax.scatter(x, diff, label='Absolute Error', marker='o', color='r')
ax.scatter(x, diff_rel, label='Relative Error', marker ='x', color='b')
ax.set_xticks(x, labels=nucs, rotation=90, fontsize=14)
ax.set_xlabel("nucleus", fontsize=14)
ax.set_yscale("log")
ax.legend(fontsize=14)
ax.set_ylim(ymax = 1)
ax.legend()
fig.tight_layout()
_images/1b982deaf1b111e1efcfa8c58e627eebe152bcaf54d84a706591caa8a465a8a3.png

Visualizing equilibrium#

At equilibrium, the forward and reverse rates linking nuclei should come into balance. We can visualize this graphically using the network plot function, and plotting the difference between the forward and reverse rates (use_net_rate=True). We will also normalize by the total activity of the rate (forward + reverse) by doing normalize_net_rate=True.

First we’ll convert the final composition from the integration into a pynucastro Composition, keeping in mind that the network evolved molar fractions (\(Y = X / A\)).

comp = pyna.Composition(net.unique_nuclei)
comp.set_array(sol.y[:, -1] * network.A)
fig = net.plot(rho, T, comp,
               screen_func=potekhin_1998,
               rotated=True, size=(1200, 500),
               node_size=650, node_font_size="9",
               use_net_rate=True, normalize_net_rate=True,
               color_nodes_by_abundance=True,
               hide_xalpha=True)
/opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages/pynucastro/rates/derived_rate.py:126: UserWarning: C12 partition function is not supported by tables: set pf = 1.0 by default
  warnings.warn(UserWarning(f'{nuc} partition function is not supported by tables: set pf = 1.0 by default'))
/opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages/pynucastro/rates/derived_rate.py:126: UserWarning: N13 partition function is not supported by tables: set pf = 1.0 by default
  warnings.warn(UserWarning(f'{nuc} partition function is not supported by tables: set pf = 1.0 by default'))
_images/522a897efd04c1239256a3eb902a6e2f39979eee88e323ad9ee3a67bd094f824.png

Here we see that the normalized net rates are all < \(10^{-12}\), so we are effectively seeing the integration tolerance, and are very close to machine precision.