codexfranck-hertz-repeated-loss-spacing-fit.py
franck-hertz-repeated-loss-spacing-fit.py
"""Original illustrative curve and synthetic fit, not measured data or transport.
Python 3.14, matplotlib 3.10.7, numpy 2.3.5. Run from _media.
"""
from math import erf, sqrt
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def cdf(values):
return np.array([.5 * (1 + erf(float(v) / sqrt(2))) for v in values])
def collection(voltage, spacing=4.9, offset=1.2, retarder=1.0, sigma=.18):
"""Convolve the periodically extended accepted intervals with Gaussian jitter."""
x = np.asarray(voltage) - offset
total = np.zeros_like(x)
for k in range(-5, 15):
total += cdf((x - k * spacing - retarder) / sigma)
total -= cdf((x - (k + 1) * spacing) / sigma)
return total
def main():
plt.rcParams.update({"font.size": 11, "figure.facecolor": "white"})
fig = plt.figure(figsize=(11, 6))
grid = fig.add_gridspec(2, 2, width_ratios=[1.23, 1], height_ratios=[2, 1],
left=.075, right=.975, bottom=.12, top=.84, wspace=.3, hspace=.13)
curve = fig.add_subplot(grid[:, 0])
fit = fig.add_subplot(grid[0, 1])
residual = fig.add_subplot(grid[1, 1], sharex=fit)
fig.suptitle("Repeated loss sets a spacing; collection and offsets set the phase", fontsize=15, y=.97)
fig.text(.5, .9, "Chosen analytical curve and independent synthetic positions — no experimental data",
ha="center", color="#555555", fontsize=11)
voltage = np.linspace(4.5, 28, 3200)
curve.plot(voltage, collection(voltage), color="#226b98", lw=2)
centers = 1.2 + np.arange(1, 6) * 4.9 + .5
for v in centers:
curve.axvline(v, color="#b8c4cb", lw=.8, ls=":", zorder=0)
curve.annotate("", (centers[2], .48), (centers[1], .48),
arrowprops={"arrowstyle": "<->", "color": "#943e6b", "lw": 1.8})
curve.text((centers[1] + centers[2]) / 2, .53, "4.9 V", ha="center", color="#943e6b")
curve.text(21.5, .24, "Trough center\n" + r"$V_c+nd+V_r/2$", ha="center",
bbox={"facecolor": "white", "edgecolor": "none", "alpha": .93, "pad": 3})
curve.set(xlim=(4.5, 28), ylim=(-.04, 1.08), xlabel="Accelerating voltage V (V)",
ylabel=r"Collected fraction $I/I_0$", title="Gaussian-smoothed reset + retarder rule")
curve.text(.02, .93, r"$V_r=1.0$ V; $\sigma=0.18$ V", transform=curve.transAxes, fontsize=10)
n = np.arange(1, 6)
positions = np.array([6.62, 11.46, 16.48, 21.30, 26.14])
slope, intercept = np.polyfit(n, positions, 1)
errors = positions - intercept - slope * n
fit.plot(n, intercept + slope * n, color="#217349", lw=1.7, label="Least squares line")
fit.scatter(n, positions, color="#943e6b", s=32, zorder=3, label="Synthetic troughs")
fit.set(ylabel="Trough voltage (V)", title="Fit positions, retain an intercept")
fit.legend(loc="upper left", frameon=False, fontsize=9)
fit.text(.96, .12, f"d = {slope:.3f} V\nb = {intercept:.3f} V", ha="right",
transform=fit.transAxes, color="#217349", fontsize=11)
fit.tick_params(labelbottom=False)
residual.axhline(0, color="#777777", lw=1)
residual.vlines(n, 0, errors, color="#943e6b", lw=1.5)
residual.scatter(n, errors, color="#943e6b", s=24, zorder=3)
residual.set(xlim=(.7, 5.3), ylim=(-.1, .105), xticks=n,
xlabel="Successive trough index n", ylabel="Residual (V)")
for ax in [curve, fit, residual]:
ax.spines[["top", "right"]].set_visible(False)
fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=140, facecolor="white")
plt.close(fig)
if __name__ == "__main__":
main()