Arrhenius Plot for Reaction Kinetics — Pygal

An Arrhenius plot displays ln(k) versus 1/T to determine the activation energy of a chemical reaction from experimental rate constant data. The Arrhenius equation predicts a linear relationship on this transformed scale, where the slope equals -Ea/R (activation energy divided by the gas constant). This visualization is fundamental in physical chemistry and chemical engineering for characterizing reaction kinetics and comparing catalytic performance.

Arrhenius Plot for Reaction Kinetics rendered with Pygal

Python source (Pygal)

""" anyplot.ai
line-arrhenius: Arrhenius Plot for Reaction Kinetics
Library: pygal 3.1.3 | Python 3.13.14
Quality: 87/100 | Updated: 2026-06-24
"""

import os
import re
import sys


# Script filename shadows the installed 'pygal' package when run as 'python pygal.py';
# dropping the script directory from sys.path lets the real package resolve.
sys.path.pop(0)

import cairosvg
import numpy as np
import pygal
from pygal.style import Style
from scipy import stats


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — canonical positions 1→8, first series always #009E73
IMPRINT_PALETTE = (
    "#009E73",  # brand green  — position 1
    "#C475FD",  # lavender     — position 2
    "#4467A3",  # blue         — position 3
    "#BD8233",  # ochre        — position 4
    "#AE3030",  # matte red    — position 5
    "#2ABCCD",  # cyan         — position 6
    "#954477",  # rose         — position 7
    "#99B314",  # lime         — position 8
)

# Data — first-order decomposition reaction rate constants spanning 300–600 K
temperature_K = np.array([300, 330, 360, 400, 440, 480, 520, 560, 600])
np.random.seed(42)
activation_energy = 75000  # J/mol (75 kJ/mol)
R = 8.314  # gas constant J/(mol·K)
pre_exponential = 1.0e12  # s⁻¹
rate_constant_k = pre_exponential * np.exp(-activation_energy / (R * temperature_K))
rate_constant_k *= np.exp(np.random.normal(0, 0.25, len(temperature_K)))

# Arrhenius transformed coordinates
inv_T = 1000.0 / temperature_K  # 1000/T (×10⁻³ K⁻¹) for readable x-axis
ln_k = np.log(rate_constant_k)

# Linear regression: ln(k) = ln(A) − Ea/R × (1/T)
slope, intercept, r_value, p_value, std_err = stats.linregress(inv_T, ln_k)
r_squared = r_value**2
Ea_extracted = -slope * R * 1000  # factor of 1000 accounts for the 1000/T scaling

# Smooth regression line — 80 points, slightly extended beyond data
x_pad = 0.04
inv_T_fit = np.linspace(float(min(inv_T)) - x_pad, float(max(inv_T)) + x_pad, 80)
ln_k_fit = slope * inv_T_fit + intercept

# Y-axis range tight to data
y_floor = int(np.floor(float(min(ln_k))))
y_ceil = int(np.ceil(float(max(ln_k))))
y_labels_list = list(range(y_floor, y_ceil + 1, 2))
if y_labels_list[-1] < y_ceil:
    y_labels_list.append(y_ceil)

# Title fontsize — scaled from 67-char baseline (44 chars here, so ratio=1.0)
title = "line-arrhenius · python · pygal · anyplot.ai"
n = len(title)
ratio = 67 / n if n > 67 else 1.0
title_font_size = max(44, round(66 * ratio))

# Style — Imprint palette + theme-adaptive chrome
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT_PALETTE,
    title_font_size=title_font_size,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
    opacity=0.92,
    opacity_hover=1.0,
    title_font_family="sans-serif",
    label_font_family="sans-serif",
    major_label_font_family="sans-serif",
    legend_font_family="sans-serif",
    value_font_family="sans-serif",
)

# Chart — canonical 3200×1800 landscape canvas
chart = pygal.XY(
    style=custom_style,
    width=3200,
    height=1800,
    title=title,
    x_title="1000/T (K⁻¹)",
    y_title="ln(k)",
    show_dots=True,
    dots_size=12,
    show_x_guides=False,
    show_y_guides=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=3,
    legend_box_size=24,
    truncate_legend=-1,
    margin=40,
    margin_top=70,
    margin_bottom=140,
    margin_left=140,
    margin_right=60,
    tooltip_fancy_mode=True,
    tooltip_border_radius=8,
    x_value_formatter=lambda x: f"{x:.2f}",
    y_value_formatter=lambda y: f"{y:.1f}",
    range=(y_floor - 0.5, y_ceil + 0.5),
    xrange=(float(min(inv_T) - 0.1), float(max(inv_T) + 0.1)),
    y_labels=y_labels_list,
    y_labels_major_every=1,
    print_values=False,
    show_minor_x_labels=False,
    css=[
        "file://style.css",
        "file://graph.css",
        "inline:"
        ".axis > .line { stroke: transparent !important; } "
        ".plot .background { rx: 10; ry: 10; } "
        ".legends .legend text { font-weight: 500; } "
        ".title { font-weight: 600; letter-spacing: 1px; }",
    ],
)

# X-axis labels: 1000/T value with corresponding temperature in parentheses
x_label_temps = np.array([300, 360, 440, 520, 600])
x_label_positions = sorted(1000.0 / x_label_temps)
chart.x_labels = [float(x) for x in x_label_positions]
chart.x_labels_major = [float(x) for x in x_label_positions]
chart.x_label_rotation = 0
chart.x_value_formatter = lambda x: f"{x:.2f} ({int(round(1000.0 / x))} K)"

# Regression fit line — smooth, no dots
fit_points = [
    {"value": (float(x), float(y)), "label": f"Fit: ln(k) = {slope:.2f} × (1000/T) + {intercept:.2f}"}
    for x, y in zip(inv_T_fit, ln_k_fit, strict=False)
]
chart.add(
    f"Linear Fit (R² = {r_squared:.3f})",
    fit_points,
    show_dots=False,
    stroke_style={"width": 4, "linecap": "round", "linejoin": "round"},
)

# Experimental data points — ochre markers with rich tooltips
data_points = [
    {"value": (float(x), float(y)), "label": f"T = {int(t)} K\nk = {k:.3e} s⁻¹\nln(k) = {y:.2f}\n1000/T = {x:.3f}"}
    for x, y, t, k in zip(inv_T, ln_k, temperature_K, rate_constant_k, strict=False)
]
chart.add("Experimental Data", data_points, stroke=False, dots_size=16)

# Activation energy — visible dot + label at regression line midpoint
mid_x = float(np.median(inv_T))
mid_y = float(slope * mid_x + intercept)
ea_r = -slope * 1000  # Ea/R in K (accounts for 1000/T axis scaling)
chart.add(
    f"Eₐ = {Ea_extracted / 1000:.1f} kJ/mol  (Eₐ/R = {ea_r:.0f} K)",
    [{"value": (mid_x, mid_y), "label": f"Eₐ/R = {ea_r:.0f} K"}],
    dots_size=8,
    stroke=False,
    print_labels=True,
)

# Render SVG and inject Ea/R slope annotation as a text element on the chart
svg_bytes = chart.render()
svg_str = svg_bytes.decode("utf-8")

# Locate the annotation dot (dots_size=8 → r="8") and place label next to it
dot_match = re.search(r'<circle cx="([^"]+)" cy="([^"]+)" r="8"', svg_str)
if dot_match:
    cx, cy = float(dot_match.group(1)), float(dot_match.group(2))
    annotation = (
        f'<text x="{cx + 70:.0f}" y="{cy - 90:.0f}" '
        f'font-family="sans-serif" font-size="44" '
        f'fill="{INK}" font-weight="500">'
        f"Eₐ/R = {ea_r:.0f} K</text>"
    )
    svg_str = svg_str.replace("</svg>", annotation + "\n</svg>")

annotated_svg = svg_str.encode("utf-8")
cairosvg.svg2png(bytestring=annotated_svg, write_to=f"plot-{THEME}.png")
chart.render_to_file(f"plot-{THEME}.html")

Part of Arrhenius Plot for Reaction Kinetics on anyplot.ai.

Other implementations