codex/chemical-potential-particle-transfer.py

chemical-potential-particle-transfer.py
"""Show ideal-gas redistribution as minimization of Helmholtz free energy.

Amounts are macroscopic moles, not a drawing of twelve individual particles.
Writes chemical-potential-particle-transfer.png in the current working directory.
Tested with Python 3.14.4, Matplotlib 3.10.7, and NumPy 2.3.5.
"""

from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import numpy as np


def draw_compartments(ax, amounts, title, equilibrium=False):
    blue, orange = "#236a9e", "#b6532b"
    ax.set(xlim=(0, 1), ylim=(0, 1))
    ax.axis("off")
    ax.set_title(title, fontsize=13, pad=8)
    for left, amount, name, color in zip([0.03, 0.55], amounts, ["A", "B"], [blue, orange]):
        ax.add_patch(Rectangle((left, 0.29), 0.42, 0.45,
                               facecolor="#eef4f8" if name == "A" else "#faf0e9",
                               edgecolor=color, lw=2))
        ax.text(left + 0.21, 0.56, f"{name}: {amount} mol", ha="center",
                fontsize=14, color=color)
        ax.text(left + 0.21, 0.38, "Volume V", ha="center", fontsize=11)
    ax.plot([0.45, 0.55], [0.51, 0.51], lw=5, color="#666666")
    ax.plot([0.50, 0.50], [0.51, 0.78], lw=1.5, color="#666666")
    ax.text(0.50, 0.88, "Open valve", ha="center", fontsize=11)
    if equilibrium:
        ax.text(0.5, 0.09, r"$p_A=p_B$ and $\mu_{{\rm m},A}=\mu_{{\rm m},B}$",
                ha="center", fontsize=12, color="#267747")
    else:
        ax.text(0.5, 0.09, r"Initially: A $\longrightarrow$ B   ($p_A=3p_B$)",
                ha="center", fontsize=12)


def main():
    blue, orange, green = "#236a9e", "#b6532b", "#267747"
    x = np.linspace(0.0, 1.0, 701)
    # The endpoint limit of y log(y) is zero; keep logarithms finite there.
    excess = (x * np.log(np.maximum(x, 1e-15))
              + (1 - x) * np.log(np.maximum(1 - x, 1e-15)) + np.log(2))
    slope_x = np.linspace(0.008, 0.992, 700)
    slope = np.log(slope_x / (1 - slope_x))
    initial = 0.75
    initial_excess = initial * np.log(initial) + (1 - initial) * np.log(1 - initial) + np.log(2)
    plt.rcParams.update({"font.size": 12, "figure.facecolor": "white",
                         "axes.facecolor": "white", "savefig.facecolor": "white"})
    fig, axes = plt.subplots(2, 2, figsize=(9.2, 7.0), dpi=120,
                             gridspec_kw={"height_ratios": [0.90, 1.25]})
    fig.subplots_adjust(left=0.09, right=0.97, bottom=0.15, top=0.82,
                        hspace=0.38, wspace=0.33)
    fig.suptitle("Matter exchange follows a free-energy slope", fontsize=16, y=0.97)
    fig.text(0.5, 0.90, "Same ideal gas • fixed temperature • equal rigid volumes • total amount 12 mol",
             ha="center", fontsize=12)
    draw_compartments(axes[0, 0], [9, 3], "Initial allocation")
    draw_compartments(axes[0, 1], [6, 6], "Final equilibrium", equilibrium=True)
    left, right = axes[1]
    left.plot(x, excess, color=blue, lw=2.4)
    left.scatter([0.5, initial], [0, initial_excess], s=50, color=[green, orange], zorder=5)
    left.annotate("6 mol / 6 mol", xy=(0.5, 0), xytext=(0.38, 0.27),
                  ha="center", color=green, fontsize=11,
                  arrowprops={"arrowstyle": "->", "color": green})
    left.annotate("9 mol / 3 mol", xy=(initial, initial_excess), xytext=(0.77, 0.44),
                  ha="center", color=orange, fontsize=11,
                  arrowprops={"arrowstyle": "->", "color": orange})
    left.set(title="Excess above the equal-allocation minimum",
             ylabel=r"$[F(x)-F(1/2)]/(n_{\rm tot}RT)$", ylim=(-0.04, 0.73))
    left.annotate("", xy=(0.58, 0.02), xytext=(0.71, 0.09),
                  arrowprops={"arrowstyle": "->", "color": orange, "lw": 1.8})
    right.plot(slope_x, slope, color=blue, lw=2.4)
    right.axhline(0, lw=1, color="#777777")
    right.axvline(0.5, lw=1, ls="--", color=green)
    right.scatter([0.5, initial], [0, np.log(3)], s=50, color=[green, orange], zorder=5)
    right.annotate(r"Initially: $\ln 3>0$" + "\nDecrease the amount in A",
                   xy=(initial, np.log(3)), xytext=(0.53, 3.5), ha="center", fontsize=11,
                   color=orange, arrowprops={"arrowstyle": "->", "color": orange},
                   bbox={"facecolor": "white", "edgecolor": "none", "pad": 2})
    right.text(0.25, -3.4, "Increase the\namount in A", ha="center", color=blue, fontsize=11)
    right.set(title="Slope for increasing the amount in A",
              ylabel=r"$(dF/dn_A)/(RT)=\ln[x/(1-x)]$", ylim=(-5, 5))
    for ax in [left, right]:
        ax.set(xlim=(0, 1), xlabel=r"Fraction in A: $x=n_A/n_{\rm tot}$")
        ax.set_xticks([0, 0.25, 0.5, 0.75, 1])
        ax.grid(alpha=0.2)
        ax.spines[["top", "right"]].set_visible(False)
        ax.title.set_fontsize(11)
    fig.text(0.5, 0.035, "At the minimum, the free-energy slope and the chemical-potential difference both vanish.",
             ha="center", fontsize=11)
    fig.savefig(Path.cwd() / Path(__file__).with_suffix(".png").name,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()