codex/millikan-photoelectric-apparatus.py

millikan-photoelectric-apparatus.py
"""Functional reconstruction of selected parts of Millikan's 1916 apparatus.

Python 3.14, Matplotlib 3.10. Run from _media; emits matching PNG to CWD.
Based on Physical Review 7, 355–388, Figs. 1–2 and sections 2–3.
Successive operations are separated; geometry is deliberately rearranged.
"""

from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import Circle, FancyBboxPatch, Rectangle


def main():
    fig, ax = plt.subplots(figsize=(12, 6.8), layout="constrained")
    fig.patch.set_facecolor("white")
    ax.set(xlim=(0, 12), ylim=(0, 6.8))
    ax.axis("off")
    ax.text(6, 6.5, "Millikan’s photoelectric apparatus: three successive operations",
            ha="center", fontsize=17, weight="bold")
    ax.text(6, 6.12, "Functional schematic from the 1916 paper • rearranged geometry • not to scale",
            ha="center", fontsize=11, color="0.35")

    def box(x, y, w, h, text, color="#edf4f8", size=12):
        ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.08",
                                   facecolor=color, edgecolor="#506773", linewidth=1.3))
        ax.text(x + w / 2, y + h / 2, text, ha="center", va="center", fontsize=size)

    def arrow(start, end, color="#506773", style="->"):
        ax.annotate("", xy=end, xytext=start,
                    arrowprops={"arrowstyle": style, "color": color, "lw": 1.8})

    # Operation 1: external magnetic manipulation without opening the vessel.
    box(0.25, 3.35, 3.2, 2.25, "", size=12)
    ax.text(1.85, 5.32, "1  Prepare surface", ha="center", fontsize=14, weight="bold")
    ax.add_patch(Circle((1.17, 4.35), 0.57, color="#d9e1e5", ec="#506773"))
    ax.add_patch(Rectangle((1.45, 4.19), 0.45, 0.32, color="#c15c16"))
    ax.plot([2.12, 2.5], [4.10, 4.65], color="0.25", lw=5)
    ax.text(1.1, 3.57, "Wheel", fontsize=11, ha="center")
    ax.text(2.65, 4.78, "Knife", fontsize=11, ha="center")
    ax.text(1.85, 3.92, "Na or Li", fontsize=11, ha="center")
    arrow((3.55, 4.5), (4.08, 4.5))

    # Operation 2: collector contains gauze plus outer solid copper shell.
    box(4.18, 3.35, 3.45, 2.25, "")
    ax.text(5.9, 5.32, "2  Illuminate and collect", ha="center", fontsize=14, weight="bold")
    ax.add_patch(Rectangle((4.8, 3.85), 2.25, 1.02, fill=False, ec="#8b4b26", lw=3))
    ax.add_patch(Rectangle((4.98, 4.02), 1.88, 0.68, fill=False, ec="#8b4b26", lw=1.5, ls="--"))
    ax.add_patch(Rectangle((5.12, 4.2), 0.17, 0.3, color="#c15c16"))
    arrow((5.35, 4.35), (6.6, 4.35), "#116c99")
    ax.text(5.95, 4.45, "electrons", fontsize=10, ha="center", color="#116c99")
    ax.text(5.9, 3.58, "Gauze + outer copper collector", fontsize=10, ha="center")
    box(4.18, 1.95, 3.45, 0.95, "Electrometer: charge in 30 s\nRepeat at different voltages", size=11)
    arrow((6.85, 3.82), (6.85, 2.98))
    arrow((7.72, 4.5), (8.25, 4.5))

    # Operation 3 is an independent displacement-null measurement.
    box(8.35, 3.35, 3.4, 2.25, "")
    ax.text(10.05, 5.32, "3  Check contact offset", ha="center", fontsize=14, weight="bold")
    ax.add_patch(Rectangle((8.85, 4.03), 0.16, 0.7, color="#c15c16"))
    ax.add_patch(Rectangle((9.6, 4.03), 0.13, 0.7, color="#8b4b26"))
    arrow((9.62, 4.9), (10.2, 4.9), style="<->")
    ax.text(10.65, 4.38, "Move copper\ntest plate", ha="center", va="center", fontsize=11)
    ax.text(10.05, 3.58, "Adjust voltage to displacement null", ha="center", fontsize=10)
    box(8.35, 1.95, 3.4, 0.95, "Certified voltmeter\nIndependent voltage scale", size=12)
    arrow((10.05, 3.28), (10.05, 2.98))
    box(0.25, 1.95, 3.2, 0.95, "Mercury lamp\nMonochromator + filters", size=12)
    arrow((3.52, 2.45), (4.98, 4.18), "#c15c16")
    ax.text(3.84, 3.02, "light", rotation=48, fontsize=11, color="#a14812")
    ax.text(6, 1.32, "Vacuum vessel: surface operations proceed without opening it", ha="center", fontsize=12)
    ax.text(6, 0.81, "Readout chain: voltage scan → current endpoint → frequency slope h/e",
            ha="center", fontsize=13, color="#116c99")
    ax.text(6, 0.32, "Separate contact measurement is needed to interpret the threshold", ha="center", fontsize=12)
    fig.savefig(Path(__file__).stem + ".png", dpi=110, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()