codex/conical-pendulum-geometry.py

conical-pendulum-geometry.py
"""Generate an original ideal conical-pendulum schematic, not measured data.

Tested with Python 3.14.4, matplotlib 3.10.7, and numpy 2.3.5.
Run from _media with MPLCONFIGDIR=/tmp/codex-wiki-matplotlib.
The matching PNG basename is written to the current working directory.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Arc, Circle
import numpy as np


def arrow(ax, start, end, color, dashed=False):
    ax.annotate("", xy=end, xytext=start, arrowprops={
        "arrowstyle": "-|>", "color": color, "lw": 2.2,
        "linestyle": "--" if dashed else "-", "mutation_scale": 16,
    })


def main():
    plt.rcParams.update({"font.size": 12, "figure.facecolor": "white"})
    blue, orange, green, gray = "#235d9f", "#c65b16", "#167146", "#747b82"
    theta = np.deg2rad(40)
    radius, drop = np.sin(theta), np.cos(theta)
    fig, axes = plt.subplots(1, 3, figsize=(11.6, 4.8))
    fig.subplots_adjust(left=0.035, right=0.98, bottom=0.20, top=0.80, wspace=0.32)
    fig.suptitle("Conical pendulum: geometry and forces", fontsize=18, y=0.97)
    fig.text(0.5, 0.885, r"Illustrative ideal geometry: $\theta=40^\circ$; no measured data", ha="center")
    for ax in axes:
        ax.set_aspect("equal")
        ax.axis("off")

    ax = axes[0]
    ax.set_title("Side view", fontsize=14, pad=12)
    ax.set_xlim(-0.78, 0.88)
    ax.set_ylim(-0.31, 1.08)
    ax.plot([-0.17, 0.17], [drop, drop], color="black", lw=4)
    ax.plot([0, 0], [0, drop], "--", color=gray, lw=1.4)
    ax.plot([-radius, radius], [0, 0], color=gray, lw=1.6)
    ax.plot([0, -radius], [drop, 0], "--", color=gray, lw=1.2)
    ax.plot([0, radius], [drop, 0], color=blue, lw=2.5)
    ax.add_patch(Circle((radius, 0), 0.035, color=blue, zorder=5))
    ax.add_patch(Arc((0, drop), 0.38, 0.38, theta1=270, theta2=310, color="black"))
    ax.text(0.085, drop - 0.24, r"$\theta$", ha="center")
    ax.text(0.43, 0.44, r"$\ell$", color=blue, fontsize=15)
    ax.text(0.0, drop + 0.085, "fixed pivot", ha="center", fontsize=11)
    ax.annotate("", xy=(-0.20, drop), xytext=(-0.20, 0), arrowprops={"arrowstyle": "<->", "color": gray})
    ax.text(-0.26, drop / 2, r"$h$", ha="right", va="center", fontsize=15)
    ax.annotate("", xy=(radius, -0.15), xytext=(0, -0.15), arrowprops={"arrowstyle": "<->", "color": gray})
    ax.text(radius / 2, -0.24, r"$r$", ha="center", fontsize=15)
    ax.text(-0.06, -0.08, "centre", ha="right", fontsize=10)

    ax = axes[1]
    ax.set_title("Top view", fontsize=14, pad=12)
    ax.set_xlim(-0.85, 1.0)
    ax.set_ylim(-0.9, 0.9)
    ax.add_patch(Circle((0, 0), radius, fill=False, color=blue, lw=2))
    ax.plot(0, 0, "o", color="black", ms=4)
    ax.add_patch(Circle((radius, 0), 0.035, color=blue, zorder=5))
    arrow(ax, (radius, 0), (radius, 0.53), blue)
    ax.text(radius + 0.09, 0.38, r"$v$", color=blue, fontsize=15)
    arrow(ax, (radius, 0), (0.10, 0), green)
    ax.text(0.36, -0.17, r"$a=\omega^2r$", color=green, ha="center", fontsize=11)
    q = radius / np.sqrt(2)
    ax.plot([0, -q], [0, q], "--", color=gray)
    ax.text(-q / 2 - 0.08, q / 2, r"$r$", fontsize=15)
    ax.text(0, -0.79, "One full turn takes period P", ha="center", fontsize=11)

    ax = axes[2]
    ax.set_title("Forces on the bob", fontsize=14, pad=12)
    ax.set_xlim(-1.4, 0.57)
    ax.set_ylim(-1.3, 1.35)
    tx = -np.tan(theta)
    arrow(ax, (0, 0), (tx, 1), blue)
    arrow(ax, (0, 0), (0, -1), orange)
    arrow(ax, (0, 0), (tx, 0), green, dashed=True)
    ax.plot([tx, 0, 0], [1, 1, 0], ":", color=gray, lw=1.5)
    ax.plot(0, 0, "o", color="black", ms=5)
    ax.text(tx - 0.04, 1.11, r"$T$", color=blue, fontsize=15)
    ax.text(0.1, -0.75, r"$mg$", color=orange, fontsize=15)
    ax.text(0.09, 0.54, r"$T\cos\theta$" + "\n" + r"$=mg$", va="center", fontsize=11)
    ax.text(-0.63, -0.27, "net inward force\n" + r"$T\sin\theta=m\omega^2r$", color=green, ha="center", va="top", fontsize=11)
    ax.add_patch(Arc((0, 0), 0.6, 0.6, theta1=90, theta2=130, color=gray))
    ax.text(-0.17, 0.34, r"$\theta$", fontsize=12)
    fig.text(0.5, 0.10, r"$r=\ell\sin\theta$     $h=\ell\cos\theta$     Solid force arrows: tension and weight; dashed: their resultant", ha="center", fontsize=12)
    fig.savefig(Path(__file__).with_suffix(".png").name, dpi=110, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()