Interactive Reaction Path Diagrams for CH₄ plasma chemistry.#

This example uses ipywidgets to create interactive displays of reaction path diagrams from Cantera simulations.

Inspired by https://cantera.org/3.1/examples/python/kinetics/interactive_path_diagram.html.

Requires: cantera >= 3.0.0, matplotlib >= 2.0, ipywidgets, graphviz, scipy

Tags: cantera chemical equilibrium methane CH4 reactor network reaction path analysis

Import the required libraries.#

import cantera as ct
import graphviz
import matplotlib.pyplot as plt
import numpy as np
from adjustText import adjust_text
from scipy import integrate

from rizer.misc.ct_utils import load_goutier2025_mechanism
from rizer.misc.plt_utils import (
    get_reaction_in_latex,
    get_species_color,
    get_species_in_latex,
    get_text,
    set_mpl_style,
)

set_mpl_style(nb_columns=1)
print(f"Using Cantera version: {ct.__version__}")

# Determine if we're running in a Jupyter Notebook. If so, we can enable the interactive
# diagrams. Otherwise, just draw output for a single set of inputs.
try:
    from IPython.core.getipython import get_ipython

    config = get_ipython()
    if config is None:
        raise ImportError("console")

except (ImportError, AttributeError):
    is_interactive = False
else:
    is_interactive = True

if is_interactive:
    from IPython.display import display
    from ipywidgets import interact, widgets

print(
    "Running in interactive mode"
    if is_interactive
    else "Running in non-interactive mode"
)
Using Cantera version: 4.0.0a2
Running in non-interactive mode

Create the gas object and set the initial conditions.#

# Load the Goutier2025 mechanism.
gas = load_goutier2025_mechanism("gas_with_electronic_excited_states")

# Set the initial temperature, pressure and composition.
gas.TPX = 15_000.0, ct.one_atm, "CH4:1.0"

# Set the residence time for the reactor network.
residence_time = 1e-5  # s

# Create an ideal gas constant pressure reactor and set solver tolerances.
reactor = ct.IdealGasConstPressureReactor(gas, energy="off")
reactor_network = ct.ReactorNet([reactor])
reactor_network.atol = 1e-14
reactor_network.rtol = 1e-14
reactor_network.max_time_step = 1e-8

# Plot options.
xlim = (1e-12, residence_time)

# Wanted species.
wanted_species = "C2H5+"

# Store the reactions and reaction equations.
gas_reactions = gas.reactions()
gas_reaction_equations = gas.reaction_equations()

Store time, pressure, temperature and mole fractions.#

# Store the states in a SolutionArray.
states = ct.SolutionArray(gas, 1, extra={"t": [0.0]})
# Store the number of steps.
steps = 0

# Run the simulation.
while reactor_network.time < residence_time:
    reactor_network.step()
    states.append(gas.state, t=reactor_network.time)
    steps += 1


time = states.t  # type: ignore

Plot temperature evolution with time.#

fig, ax = plt.subplots()
ax.plot(
    time,
    states.T,
)
ax.set_ylabel("Temperature [K]")
ax.set_title("Temperature evolution")
ax.set_xlabel("Time [s]")
ax.set_xscale("log")
Temperature evolution

Plot species evolution with time.#

fig, ax = plt.subplots()

species_to_plot = set(["CH4", "H2", "C2H2", "C2H4", "C2H6", "H", wanted_species])
for species in species_to_plot:
    X = states(species).X * 100
    ax.plot(time, X, color=get_species_color(species))

    x = time[np.argmax(X)]
    y = X[np.argmax(X)]
    if x < 1e-11:
        x = 1e-11
        y = X[np.where(time >= 1e-11)[0][0]]

    get_text(
        x,
        y,
        get_species_in_latex(species),
        ax=ax,
        color=get_species_color(species),
    )

ax.set_xlabel("Time [s]")
ax.set_ylabel("Mole fraction [%]")
ax.set_title("Mole fraction evolution with time.")
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlim(xlim)
ax.set_ylim(1e-9, 100)
Mole fraction evolution with time.
(1e-09, 100)

Interactive reaction path diagram.#

When executed as a Jupyter Notebook, the plotted time step, threshold and element can be changed using the slider provided by IPyWidgets.

def plot_reaction_path_diagrams(plot_step, threshold, details, element):
    P = states.P[plot_step]
    T = states.T[plot_step]
    X = states.X[plot_step]
    gas.TPX = T, P, X

    diagram = ct.ReactionPathDiagram(gas, element)
    diagram.threshold = threshold
    diagram.title = f"time = {time[plot_step]:.2g} s"

    diagram.show_details = details
    if is_interactive:
        graph = graphviz.Source(diagram.get_dot())
        display(graph)
    else:
        graph = graphviz.Source(diagram.get_dot(), format="svg")
        return graph


if is_interactive:
    interact(
        plot_reaction_path_diagrams,
        plot_step=widgets.IntSlider(value=100, min=0, max=steps - 1, step=10),
        threshold=widgets.FloatSlider(value=0.1, min=0.001, max=0.4, step=0.01),
        details=widgets.ToggleButton(),
        element=widgets.Dropdown(
            options=gas.element_names,
            value="C",
            description="Element",
            disabled=False,
        ),
    )
    diagram = ""
else:
    # For non-interactive use, just draw the diagram for a specified time step.
    diagram = plot_reaction_path_diagrams(
        plot_step=520, threshold=0.1, details=False, element="C"
    )


class PlotGraphviz:
    # See https://stackoverflow.com/questions/65008861/capturing-graphviz-figures-in-sphinx-gallery.
    def __init__(self, dot_string):
        self.dot_string = dot_string

    def _repr_html_(self):
        return graphviz.Source(self.dot_string)._repr_image_svg_xml()


PlotGraphviz(str(diagram))
<__main__.PlotGraphviz object at 0x7f49556cf8c0>

Interactive plot of instantaneous fluxes.#

Find reactions containing the species of interest, meaning that the species is either a product or a reactant of the reaction.

Net rates of progress of reactions containing interested species.#

The following cell calculates net rates of progress of reactions containing the species of interest and stores them.

# Some reactions may appear twice, one for forward and one for backward reaction,
# like "e- + CH4 => e- + e- + CH4+" and "e- + e- + CH4+ => e- + CH4"
# Get the pairs of indices of each reaction.
reaction_indices_pairs = []

for i, reaction in enumerate(gas_reactions):
    if " <=> " in reaction.equation:
        continue
    for j, reaction_2 in enumerate(gas_reactions):
        if " <=> " in reaction_2.equation:
            continue
        if (j, i) in reaction_indices_pairs:
            continue

        reactants_2, products_2 = reaction_2.equation.split(" => ")
        reverse_reaction_from_2 = " => ".join([products_2, reactants_2])
        if reaction.equation == reverse_reaction_from_2:
            reaction_indices_pairs.append((i, j))
            break

# Check that the number of reactions computed is correct.
nb_reactions_computed = 2 * len(reaction_indices_pairs) + len(
    [r for r in gas_reactions if " <=> " in r.equation]
)
assert len(gas_reaction_equations) == nb_reactions_computed

# Compute the production rates of the wanted species.
production_rates_wanted_species = (
    states.net_rates_of_progress  # type: ignore
    * wanted_species_stoichiometry
)


for i, j in reaction_indices_pairs:
    # Update the net rates of progress of the forward reactions.
    production_rates_wanted_species[:, i] += production_rates_wanted_species[:, j]
    # Set the net rates of progress of the backward reactions to 0.
    production_rates_wanted_species[:, j] = 0.0

# Net rates of progress [kmol/m^3/s]
wanted_species_production_rates_array = (
    production_rates_wanted_species[:, wanted_species_reaction_indices]  # type: ignore
)


# To avoid the complexity of reading the graph, for each reaction:
# - If the extremum of the net rate of progress is positive,
#   and the wanted species is a product, do nothing.
# - If the extremum of the net rate of progress is positive,
#   and the wanted species is a reactant, inverse the equation.
# - If the extremum of the net rate of progress is negative,
#   and the wanted species is a product, inverse the equation
# - If the extremum of the net rate of progress is negative,
#   and the wanted species is a reactant, do nothing.
#
# To understand the logic, see the following example:
# The reaction "C2H5+ + e- <=> C2H4 + H" is implemented via:
#   (1) C2H5+ + e- => C2H4  + H
#   (2) C2H4  + H  => C2H5+ + e-
# The formation of C2H5+ is given by:
#   d[C2H5+]/dt = - k1 [C2H5+] [e-] + k2 [C2H4] [H]
# With `net_rates_of_progress`, the following quantities are computed:
#   (1) k1 [C2H5+] [e-]
#   (2) k2 [C2H4] [H]
# `wanted_species_stoichiometry` is giving '-1' for (1) and '+1' for (2).
# Thus, `net_rates_of_progress * wanted_species_stoichiometry` gives the
# expected result.
# If we instead choose to implement "C2H4 + H <=> C2H5+ + e" via:
#   (1') C2H4  + H  => C2H5+ + e-
#   (2') C2H5+ + e- => C2H4  + H
# Then, d[C2H5+]/dt = - k2' [C2H5+] [e-] + k1' [C2H4] [H]
# And since k1'=k2 and k2'=k1, we still get the same production rate,
# but the reading is simpler (with the logic above).

for i, production_rate in enumerate(wanted_species_production_rates_array.T):
    production = production_rate[np.argmax(np.abs(production_rate))]

    reaction_idx = wanted_species_reaction_indices[i]

    if production > 0:
        if wanted_species in gas_reactions[reaction_idx].products:
            continue
    else:
        if wanted_species in gas_reactions[reaction_idx].reactants:
            continue
    equation = gas_reaction_equations[reaction_idx]
    equation = equation.replace(" => ", " <=> ")
    reactants, products = equation.split(" <=> ")
    equation = " <=> ".join([products, reactants])
    gas_reaction_equations[reaction_idx] = equation

Create the instantaneous flux plot. When executed as a Jupyter Notebook, the threshold for annotating of reaction strings can be changed using the slider provided by IPyWidgets.

total_wanted_species_production_rates = np.sum(
    wanted_species_production_rates_array, axis=1
)


def plot_instantaneous_fluxes(annotation_cutoff):
    fig, ax = plt.subplots()
    texts = []
    for i, wanted_species_production_rate in enumerate(
        wanted_species_production_rates_array.T
    ):
        peak_index = np.argmax(np.abs(wanted_species_production_rate))
        peak_time = time[peak_index]
        peak_wanted_species_production = wanted_species_production_rate[peak_index]
        reaction_string = gas_reaction_equations[wanted_species_reaction_indices[i]]

        ax.plot(time, wanted_species_production_rate)

        if abs(peak_wanted_species_production) > annotation_cutoff:
            texts.append(
                get_text(
                    x=peak_time,
                    y=peak_wanted_species_production,
                    text=get_reaction_in_latex(
                        reaction_string, force_double_arrow=True
                    ),
                    ax=ax,
                )
            )

    ax.plot(time, total_wanted_species_production_rates, "k--")
    if abs(total_wanted_species_production_rates[-1]) > annotation_cutoff:
        texts.append(
            get_text(
                x=time[-1],
                y=total_wanted_species_production_rates[-1],
                text=f"Total {get_species_in_latex(wanted_species)} production",
                ax=ax,
                color="k",
            )
        )

    ax.set_xlabel("Time (s)")
    ax.set_ylabel(f"Net rates of {get_species_in_latex(wanted_species)}")
    ax.set_xscale("log")
    ax.set_xlim(xlim)
    adjust_text(texts, avoid_self=False)
    plt.show()


if is_interactive:
    interact(
        plot_instantaneous_fluxes,
        annotation_cutoff=widgets.FloatSlider(value=0.1, min=0.1, max=200, steps=100),
    )
else:
    plot_instantaneous_fluxes(annotation_cutoff=0.1)
plot reaction path diagram

Interactive plot of integrated fluxes.#

When executed as a Jupyter Notebook, the threshold for annotating of reaction strings can be changed using the slider provided by iPyWidgets.

# Integrate fluxes over time.
integrated_fluxes = integrate.cumulative_trapezoid(
    wanted_species_production_rates_array,
    time,
    axis=0,
    initial=0,
)

# Sum of all integrated fluxes.
total_integrated_fluxes = np.sum(integrated_fluxes, axis=1)


def plot_integrated_fluxes(integrated_fluxes, annotation_cutoff):
    fig, ax = plt.subplots()

    texts = []

    for i, wanted_species_production in enumerate(integrated_fluxes.T):
        total_wanted_species_production = wanted_species_production[-1]

        equation = gas_reaction_equations[wanted_species_reaction_indices[i]]

        ax.plot(time, wanted_species_production)

        if abs(total_wanted_species_production) > annotation_cutoff:
            texts.append(
                get_text(
                    x=float(1e-4),
                    y=float(total_wanted_species_production),
                    text=get_reaction_in_latex(equation, force_double_arrow=True),
                    ax=ax,
                )
            )

    ax.plot(time, total_integrated_fluxes, "k--")
    if abs(total_wanted_species_production) > annotation_cutoff:
        texts.append(
            get_text(
                x=float(1e-4),
                y=float(total_integrated_fluxes[-1]),
                text=f"Total integrated flux: {total_integrated_fluxes[-1]:.2g}",
                ax=ax,
                color="k",
            )
        )

    ax.set_xlabel("Time (s)")
    ax.set_ylabel("Integrated net rates of progress")
    ax.set_title(
        f"Cumulative {get_species_in_latex(wanted_species)} formation/decomposition"
    )
    ax.set_xscale("log")
    ax.set_xlim(xlim)
    adjust_text(texts, avoid_self=False)
    plt.show()


if is_interactive:
    interact(
        plot_integrated_fluxes,
        annotation_cutoff=widgets.FloatLogSlider(
            value=1e-5, min=-8, max=-4, base=10, step=0.1
        ),
        integrated_fluxes=widgets.fixed(integrated_fluxes),
    )
else:
    plot_integrated_fluxes(integrated_fluxes=integrated_fluxes, annotation_cutoff=1e-5)
Cumulative $\mathregular{C_{2}H_{5}^{+}}$ formation/decomposition

Total running time of the script: (0 minutes 5.174 seconds)