codexcoulomb-force-distance.py
coulomb-force-distance.py
"""Draw Coulomb force vectors with computed inverse-square lengths.
Tested with Python 3.14, NumPy 2.3.5 and Matplotlib 3.10.7.
The output is an opaque white PNG in the current working directory.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
def force_on_second(first, second, q1, q2, k=1.0):
"""Use the displacement from the first charge to the second."""
displacement = np.asarray(second, dtype=float) - np.asarray(first, dtype=float)
distance = np.linalg.norm(displacement)
if distance == 0:
raise ValueError("Distinct point-charge positions are required")
return k * q1 * q2 * displacement / distance**3
def main():
separations = np.array([1.0, 2.0, 3.0])
ratios = separations**-2
np.testing.assert_allclose(ratios, [1.0, 0.25, 1.0 / 9.0])
positive, negative, force_color = "#b53d35", "#216baf", "#243d4b"
fig, axes = plt.subplots(1, 2, figsize=(11.0, 5.6), dpi=120)
for ax, q2, heading in zip(
axes, [1.0, -1.0], ["Like charges: repulsion", "Unlike charges: attraction"]
):
for index, (distance, ratio) in enumerate(zip(separations, ratios)):
y = 2.0 - index
left = np.array([0.0, y])
right = np.array([distance, y])
force2 = force_on_second(left, right, 1.0, q2)
force1 = force_on_second(right, left, q2, 1.0)
np.testing.assert_allclose(force1, -force2)
np.testing.assert_allclose(np.linalg.norm(force2), ratio)
ax.plot([0, distance], [y, y], color="#c9d2d8", linewidth=1.2, zorder=1)
ax.scatter([0, distance], [y, y], s=410,
color=[positive, positive if q2 > 0 else negative],
edgecolor="white", linewidth=1.4, zorder=5)
ax.text(0, y, "+q", color="white", ha="center", va="center", fontsize=10, zorder=6)
ax.text(distance, y, "+q" if q2 > 0 else "−q", color="white",
ha="center", va="center", fontsize=10, zorder=6)
# Offset arrows from the symbols vertically so their tails and full
# computed lengths remain visible even at the smallest force.
for charge, force, offset in [(left, force1, 0.28), (right, force2, 0.52)]:
start = charge + np.array([0, offset])
ax.plot([charge[0], start[0]], [y + 0.11, start[1]],
color="#9baab5", linewidth=0.9, linestyle=":", zorder=1)
end = start + 1.3 * force
ax.annotate("", xy=end, xytext=start,
arrowprops={"arrowstyle": "->", "color": force_color,
"lw": 2.2, "shrinkA": 0, "shrinkB": 0,
"mutation_scale": 10})
label = ["1", "1/4 = 0.250", "1/9 ≈ 0.111"][index]
ax.text(distance / 2, y - 0.24, r"$r=R$" if distance == 1 else rf"$r={int(distance)}R$",
ha="center", va="top", fontsize=11)
ax.text(1.0, y - 0.53, rf"$|F|/F_0$ = {label}", ha="center", fontsize=11)
ax.set(xlim=(-1.55, 3.9), ylim=(-0.8, 2.95))
ax.set_aspect("equal")
ax.axis("off")
ax.set_title(heading, fontsize=13, pad=10)
fig.suptitle("Coulomb force: direction from signs, magnitude from distance", fontsize=15, y=0.97)
fig.text(0.5, 0.105, r"$F_0=kq^2/R^2$ Equal and opposite forces on each pair; one common arrow scale.",
ha="center", fontsize=11)
fig.text(0.5, 0.055, r"Displacement from 1 to 2: $\mathbf{r}=\mathbf{r}_2-\mathbf{r}_1$ (rightward here).",
ha="center", fontsize=11)
fig.subplots_adjust(left=0.025, right=0.985, bottom=0.18, top=0.83, wspace=0.16)
fig.savefig(Path.cwd() / Path(__file__).with_suffix(".png").name,
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()