codex/hubble-law-and-redshift.py

hubble-law-and-redshift.py
"""Generate hubble-law-and-redshift.png in the current directory.

Use: make _media/hubble-law-and-redshift.png
Tested with Python 3.14.4, Matplotlib 3.10.7, NumPy 2.3.5.
H0 = 70 km/s/Mpc is stipulated, not fitted or a measured consensus value.
The left panel calculates instantaneous recession rates and low-z estimates;
the right panel independently illustrates wavelength doubling, not velocity.
"""

import matplotlib.pyplot as plt
import numpy as np


def main():
    plt.rcParams.update({"font.size": 12, "savefig.facecolor": "white"})
    hubble, c = 70.0, 299792.458
    distances = np.array([10.0, 100.0, 200.0])
    rates = hubble * distances
    approx_z = rates / c
    fig, (left, right) = plt.subplots(1, 2, figsize=(10.8, 5.4), facecolor="white")
    fig.subplots_adjust(left=0.085, right=0.975, bottom=0.19, top=0.79, wspace=0.35)
    fig.suptitle("Expansion rates and wavelength stretching", fontsize=19, y=0.975)
    fig.text(0.5, 0.906, "Computed illustrations: no observed galaxy catalogue", ha="center", fontsize=12)
    left.set_title(r"Local example: stipulated $H_0=70$ km/s/Mpc", fontsize=12, pad=14)
    line_distance = np.linspace(0, 220, 221)
    left.plot(line_distance, hubble * line_distance, color="#286b96", lw=2.5)
    left.scatter(distances, rates, s=55, color="#bf5928", zorder=4)
    for distance, rate, z, offset in zip(distances, rates, approx_z,
                                        ((14, 12), (10, -44), (-100, -45))):
        left.annotate(f"{rate:,.0f} km/s\n" + rf"$z\approx {z:.6f}$",
                      xy=(distance, rate), xytext=offset, textcoords="offset points",
                      fontsize=11, color="#833e1c",
                      bbox={"facecolor": "white", "edgecolor": "none", "pad": 2})
    left.set(xlim=(0, 225), ylim=(0, 16600), xticks=[0, 50, 100, 150, 200],
             yticks=[0, 4000, 8000, 12000, 16000],
             xlabel="Present proper distance d (Mpc)",
             ylabel=r"Instantaneous recession rate $H_0d$ (km/s)")
    left.grid(alpha=0.2)
    left.spines[["top", "right"]].set_visible(False)
    left.text(0.03, 0.95, r"$z\approx H_0d/c$ only at low redshift", transform=left.transAxes,
              va="top", fontsize=11)

    right.set_title(r"Scale factor doubles: $a_e=0.5\ \to\ a_0=1$", fontsize=12, pad=14)
    position = np.linspace(0, 2300, 1600)
    for baseline, wavelength, color, label in (
            (2.1, 500, "#286b96", r"Emitted: $\lambda_e=500$ nm"),
            (0.6, 1000, "#bf5928", r"Received: $\lambda_0=1000$ nm")):
        right.axhline(baseline, color="#ccd3da", lw=0.8)
        right.plot(position, baseline + 0.3 * np.cos(2 * np.pi * position / wavelength),
                   lw=2.5, color=color)
        right.text(0, baseline + 0.52, label, fontsize=12, color=color)
        # Consecutive crests have exactly the labelled wavelength separation.
        right.annotate("", xy=(1500 if wavelength == 500 else 2000, baseline - 0.52),
                       xytext=(1000, baseline - 0.52),
                       arrowprops={"arrowstyle": "<->", "color": color, "lw": 1.6})
        right.text(1250 if wavelength == 500 else 1500, baseline - 0.74,
                   f"{wavelength} nm", ha="center", color=color, fontsize=11)
    right.set(xlim=(-50, 2350), ylim=(-0.6, 3.1))
    right.axis("off")
    right.text(0.5, -0.10, r"$1+z=\lambda_0/\lambda_e=2$, so $z=1$",
               transform=right.transAxes, ha="center", fontsize=13)
    fig.text(0.5, 0.055, "Left: redshift estimates are approximate. Right: wavelength diagram is schematic; z = 1 is not a speed.",
             ha="center", fontsize=11)
    fig.savefig("hubble-law-and-redshift.png", dpi=120, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()