codex/refraction-apparent-depth-angle-dependence.py

refraction-apparent-depth-angle-dependence.py
"""Exact axis intercepts of outgoing rays from a submerged object.

Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
Run from _media. The two rays represent different viewing directions;
they do not enter one eye. Model indices: water 4/3 and air 1.
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Arc


def main():
    plt.rcParams.update({"font.size": 12, "axes.titlesize": 14})
    fig, (ax, curve) = plt.subplots(1, 2, figsize=(11.8, 6.5), layout="constrained",
                                   gridspec_kw={"width_ratios": [1.2, 1]})
    d, ratio = 12.0, 0.75
    colors = ["#176b92", "#bd5125"]
    ax.axhspan(-13, 0, color="#edf6fb")
    ax.axhline(0, color="#333333", lw=1.8)
    ax.axvline(0, color="#666666", ls=":", lw=1.5)
    ax.axhline(-9, color="#666666", ls="-.", lw=1)
    ax.scatter([0], [-d], color="black", s=42, zorder=5)
    ax.text(0.3, -12.55, "Object: 12 cm deep", va="center", fontsize=11)
    ax.text(0.3, -9.55, "Near-normal depth: 9 cm", fontsize=10, color="#444444")
    for angle, color in zip([30, 60], colors):
        theta = np.radians(angle)
        water = np.arcsin(ratio * np.sin(theta))
        x = d * np.tan(water)
        depth = x / np.tan(theta)
        end = np.array([x + 4.0 * np.tan(theta), 4.0])
        ax.plot([0, x], [-d, 0], color=color, lw=2)
        ax.annotate("", xy=end, xytext=(x, 0), arrowprops={"arrowstyle": "-|>", "color": color, "lw": 2})
        ax.plot([0, x], [-depth, 0], color=color, ls="--", lw=1.7)
        ax.plot([x, x], [-2, 2.9], color="#777777", ls=":", lw=1)
        ax.add_patch(Arc((x, 0), 2.4, 2.4, theta1=90-angle, theta2=90, color=color, lw=1.3))
        ax.text(x + 0.24, 1.65, rf"${angle}^\circ$", color=color)
        ax.text(x + 0.3, -2.1, rf"${np.degrees(water):.4f}^\circ$", color=color, fontsize=10)
        ax.scatter([0], [-depth], color=color, s=35, zorder=6)
        ax.text(-0.4, -depth, f"{depth:.4f} cm", color=color, ha="right", va="center", fontsize=11)
        curve.scatter([angle], [depth], color=color, s=48, zorder=5)
    ax.text(-3.9, 3.1, "Air  n = 1", fontsize=11)
    ax.text(11.0, -11.7, "Water  n = 4/3", fontsize=11)
    ax.set(xlim=(-4.7, 18.1), ylim=(-13.25, 4.5), aspect="equal",
           xlabel="Horizontal position x (cm)", ylabel="Height z (cm)",
           title="Actual rays and backward extensions")
    angles = np.linspace(0, 85, 500)
    theta = np.radians(angles)
    depths = d * ratio * np.cos(theta) / np.sqrt(1 - ratio**2 * np.sin(theta)**2)
    curve.plot(angles, depths, color="#333333", lw=2, label="Exact axis-intercept depth")
    curve.axhline(9, color="#666666", ls="-.", lw=1.6, label="Near-normal approximation")
    for angle, color in zip([30, 60], colors):
        depth = d * ratio * np.cos(np.radians(angle)) / np.sqrt(1-ratio**2*np.sin(np.radians(angle))**2)
        curve.annotate(f"{depth:.4f} cm", (angle, depth), xytext=(6, -20), textcoords="offset points", color=color, fontsize=11)
    curve.set(xlim=(0, 85), ylim=(0, 12.5), xlabel="Outgoing air angle from normal (degrees)",
              ylabel="Depth below interface (cm)", title="Depth depends on viewing direction")
    curve.set_xticks([0, 15, 30, 45, 60, 75])
    curve.grid(alpha=0.2)
    curve.legend(loc="upper right", fontsize=10)
    fig.suptitle("Apparent depth at a flat water–air boundary", fontsize=17)
    fig.savefig(Path(__file__).stem + ".png", dpi=120, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()