codex/catenoid-soap-film-between-rings.py

catenoid-soap-film-between-rings.py
"""Generate an ideal catenoid and two profiles at L/R=0.60.

Python 3.14, matplotlib 3.10.7, numpy 2.3.5. Run from _media.
Geometry is dimensionless and represents no measured film.
"""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


def bisect_root(left, right):
    def residual(u):
        return u / np.cosh(u) - 0.60

    for _ in range(80):
        midpoint = (left + right) / 2
        if residual(left) * residual(midpoint) <= 0:
            right = midpoint
        else:
            left = midpoint
    return (left + right) / 2


def main():
    plt.rcParams.update({"font.size": 12, "figure.facecolor": "white"})
    half_gap = 0.60
    wide_u, thin_u = bisect_root(0.01, 1.199), bisect_root(1.201, 6)
    wide_a, thin_a = half_gap / wide_u, half_gap / thin_u
    fig = plt.figure(figsize=(10.5, 5.3))
    surface = fig.add_subplot(121, projection="3d")
    z, phi = np.meshgrid(np.linspace(-half_gap, half_gap, 49), np.linspace(0, 2 * np.pi, 81))
    r = wide_a * np.cosh(z / wide_a)
    surface.plot_surface(r * np.cos(phi), r * np.sin(phi), z, color="#80c8d5", alpha=0.48, linewidth=0)
    angle = np.linspace(0, 2 * np.pi, 241)
    for height in [-half_gap, half_gap]:
        surface.plot(np.cos(angle), np.sin(angle), np.full_like(angle, height), color="#333333", lw=2.8)
    surface.plot(wide_a * np.cos(angle), wide_a * np.sin(angle), np.zeros_like(angle), color="#a35425", lw=2.2)
    surface.quiver(wide_a, 0, 0, 0.36, 0, 0, color="#a35425", arrow_length_ratio=0.22)
    surface.text(wide_a + 0.25, 0, 0.10, "$N$", color="#a35425")
    surface.set(xlabel="$x/R$", ylabel="$y/R$", zlabel="$z/R$", title="Wide-waist catenoid")
    surface.set_box_aspect((2, 2, 1.2))
    surface.set_zticks([-0.6, 0, 0.6])
    surface.view_init(elev=18, azim=-57)
    profile = fig.add_subplot(122)
    zz = np.linspace(-half_gap, half_gap, 301)
    for a, style, label, color in [(wide_a, "-", "Wide waist", "#227b94"), (thin_a, "--", "Thin waist", "#b65a27")]:
        profile.plot(zz, a * np.cosh(zz / a), style, color=color, lw=2.6, label=f"{label}: a/R={a:.3f}")
    profile.scatter([-half_gap, half_gap], [1, 1], c="#333333", s=55, zorder=5)
    profile.text(-0.57, 1.045, "Ring", ha="center")
    profile.text(0.57, 1.045, "Ring", ha="center")
    profile.axvline(0, lw=0.8, color="#aaaaaa")
    profile.set(xlabel="Axial position $z/R$", ylabel="Profile radius $r/R$", xlim=(-0.67, 0.67), ylim=(0, 1.13), title="Same boundary, two stationary profiles")
    profile.legend(loc="lower center", fontsize=11, frameon=False)
    profile.grid(alpha=0.2)
    profile.spines[["top", "right"]].set_visible(False)
    fig.suptitle(r"$r(z)=a\cosh(z/a)$    •    $L/R=0.60$    •    $H=0$ on both profiles", fontsize=15, y=0.98)
    fig.text(0.5, 0.035, "Ideal mid-surface geometry; equal rings of radius R at z = ±L", ha="center", fontsize=12)
    fig.subplots_adjust(left=0.02, right=0.97, top=0.84, bottom=0.18, wspace=0.28)
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=120, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()