Skip to content
The Lumenary

Math laboratory

Geodesic spreading, conjugate points and Clairaut's relation

Integrates geodesic equations by Runge-Kutta on the unit sphere and the hyperbolic half-plane and measures how fast two geodesics from one point separate: the ratio distance/angle follows sin t and sinh t, the Jacobi fields of curvature +1 and -1, with refocusing at t = pi on the sphere. Then checks Clairaut's conserved quantity along a geodesic on a torus.

Back to the labNumerical approximation
Geodesic spreading, conjugate points and Clairaut's relationPythonNumerical approximationComputationally tested
"""Geodesics, the spreading of nearby geodesics, and Clairaut's relation.

Part 1 integrates the geodesic equations (with their Christoffel symbols) on
the unit sphere and on the hyperbolic upper half-plane, for two geodesics
leaving the same point at a small angle delta. The distance between them
after time t, divided by delta, is compared with the Jacobi-field prediction
sin t (curvature +1), t (curvature 0) and sinh t (curvature -1). On the sphere
the geodesics meet again at t = pi: the conjugate point.

Part 2 integrates a geodesic on a torus of revolution and checks Clairaut's
relation: rho^2 du/dt (rho = distance from the axis) stays constant, and the
geodesic never enters the region where rho is smaller than that constant.
Runge-Kutta integration: numerical evidence.
"""

import math

def rk4(rhs, state, dt, steps):
    """Integrate state' = rhs(state) with the classical Runge-Kutta method."""
    for _ in range(steps):
        k1 = rhs(state)
        k2 = rhs([s + dt / 2 * k for s, k in zip(state, k1)])
        k3 = rhs([s + dt / 2 * k for s, k in zip(state, k2)])
        k4 = rhs([s + dt * k for s, k in zip(state, k3)])
        state = [s + dt / 6 * (a + 2 * b + 2 * c + d)
                 for s, a, b, c, d in zip(state, k1, k2, k3, k4)]
    return state

def sphere_rhs(s):
    """Geodesic equations on the sphere in (theta, phi): state (th, ph, th', ph')."""
    th, ph, dth, dph = s
    return [dth, dph, math.sin(th) * math.cos(th) * dph**2,
            -2 * math.cos(th) / math.sin(th) * dth * dph]

def half_plane_rhs(s):
    """Geodesic equations of (dx^2 + dy^2)/y^2: x'' = 2x'y'/y, y'' = (y'^2 - x'^2)/y."""
    x, y, dx, dy = s
    return [dx, dy, 2 * dx * dy / y, (dy**2 - dx**2) / y]

def sphere_distance(a, b):
    """Great-circle distance between points given in (theta, phi)."""
    def xyz(p):
        return (math.sin(p[0]) * math.cos(p[1]), math.sin(p[0]) * math.sin(p[1]),
                math.cos(p[0]))
    dot = sum(u * v for u, v in zip(xyz(a), xyz(b)))
    cross = math.sqrt(max(0.0, 1 - dot * dot))
    return math.atan2(cross, dot)

def half_plane_distance(a, b):
    """Hyperbolic distance: cosh d = 1 + |a - b|^2 / (2 y_a y_b)."""
    sq = (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
    return math.acosh(1 + sq / (2 * a[1] * b[1]))

def spread(model, t, delta=1e-4, dt=1e-3):
    """Distance at time t between unit-speed geodesics at angle delta, / delta."""
    steps = max(1, round(t / dt))
    step = t / steps
    # Both start points have an orthonormal coordinate frame: the equator of
    # the sphere (theta = pi/2) and the point (0, 1) of the half-plane.
    if model == "sphere":
        rhs, dist, start = sphere_rhs, sphere_distance, (math.pi / 2, 0.0)
    else:
        rhs, dist, start = half_plane_rhs, half_plane_distance, (0.0, 1.0)
    ends = []
    for angle in (0.3, 0.3 + delta):
        state = [start[0], start[1], math.cos(angle), math.sin(angle)]
        ends.append(rk4(rhs, state, step, steps)[:2])
    return dist(ends[0], ends[1]) / delta

def torus_rhs(big, small):
    """Geodesic equations on the torus metric (R + r cos v)^2 du^2 + r^2 dv^2."""
    def rhs(s):
        u, v, du, dv = s
        rho = big + small * math.cos(v)
        drho = -small * math.sin(v)
        return [du, dv, -2 * drho / rho * du * dv, rho * drho / small**2 * du**2]
    return rhs

def clairaut_run(big=2.0, small=1.0, angle=1.2, t=30.0, dt=1e-3):
    """Return (initial rho^2 u', max deviation, min rho visited) along a geodesic."""
    rho0 = big + small
    state = [0.0, 0.0, math.cos(angle) / rho0, math.sin(angle) / small]
    rhs = torus_rhs(big, small)
    c0 = rho0**2 * state[2]
    worst, min_rho = 0.0, rho0
    for _ in range(round(t / 0.5)):
        state = rk4(rhs, state, dt, round(0.5 / dt))
        rho = big + small * math.cos(state[1])
        worst = max(worst, abs(rho**2 * state[2] - c0))
        min_rho = min(min_rho, rho)
    return c0, worst, min_rho

def main():
    print("1. Two geodesics from one point at angle delta = 1e-4:")
    print("   t      sphere d/delta   sin t      half-plane d/delta   sinh t")
    for t in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, math.pi]:
        a, b = spread("sphere", t), spread("half-plane", t)
        print(f"   {t:4.2f}   {a:12.6f}   {math.sin(t):9.6f}   "
              f"{b:14.6f}       {math.sinh(t):9.6f}")
    print("   Flat plane: d/delta = t exactly. At t = pi the sphere's geodesics")
    print("   meet again at the antipode (a conjugate point).")
    print()
    c0, worst, min_rho = clairaut_run()
    print("2. A geodesic on the torus (R = 2, r = 1) for time 30:")
    print(f"   Clairaut constant rho^2 u' = {c0:.10f}")
    print(f"   largest deviation along the geodesic: {worst:.1e}")
    print(f"   smallest rho visited: {min_rho:.4f} >= |constant| = {abs(c0):.4f}")
    print()
    print("Epistemic status: numerical integration (Runge-Kutta, step 0.001) of")
    print("the geodesic equations. The agreement with sin t and sinh t is evidence")
    print("for the Jacobi equation J'' + K J = 0; the conservation in part 2 is")
    print("evidence for Clairaut's theorem, which the lesson proves.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in