import numpy as np
import matplotlib.pyplot as plt
import scipy.integrate
import scipy.optimize
from sympy import sin, cos, symbols, lambdify, Matrix, vector
In this post, I try to find and illustrate geodesics on 2-D curved surfaces that are embedded in 3-D space. Due to a specific question from a friend of mine, the main goal is to find closed geodesics on a Möbius strip.
A geodesic is a curve in the surface with no transversal acceleration, i.e. a straight line. E.g. the shortest path between two points is a geodesic. Any path connecting the two points is a geodesic, if and only if a rope, which is aligned along this curve on the surface, does not change its shape under tension.
We follow the definitions and formalism in https://de.wikipedia.org/wiki/Christoffelsymbole. We assume to have a parametrization of the curved surface which embed them into 3-D euclidean space by the function \[X: (u,v) \rightarrow (x,y,z) \in \mathbb R^3.\]
Christoffel symbols \(\Gamma^a_{bc}\) can be calculated with the identities \[\begin{align} \frac{\partial^2 X}{\partial u^2} &= \Gamma^1_{11} \frac{\partial X}{\partial u} + \Gamma^2_{11}\frac{\partial X}{\partial v} + h_{11} N\,,\\[0.5em] \frac{\partial^2 X}{\partial u \partial v} &= \Gamma^1_{12} \frac{\partial X}{\partial u} + \Gamma^2_{12}\frac{\partial X}{\partial v} + h_{12} N\,,\\[0.5em] \frac{\partial^2 X}{\partial v \partial u} &= \Gamma^1_{21} \frac{\partial X}{\partial u} + \Gamma^2_{21}\frac{\partial X}{\partial v} + h_{21} N\,,\\[0.5em] \frac{\partial^2 X}{\partial v^2} &= \Gamma^1_{22} \frac{\partial X}{\partial u} + \Gamma^2_{22}\frac{\partial X}{\partial v} + h_{22} N\,. \end{align}\]
Given a curve \(\gamma(t) = X(u(t), v(t))\) on the surface, and using the definition \(u_1=u\) and \(u_2=v\), the tangential (in-plane) acceleration can be writen as \[(\ddot\gamma)^\top = \left(\ddot u_1 + \sum_{i,j = 1}^2 \Gamma^1_{ij} \dot u_i \dot u_j \right)\frac{\partial X}{\partial u_1} + \left(\ddot u_2 + \sum_{i,j=1}^2 \Gamma^2_{ij} \dot u_i \dot u_j \right)\frac{\partial X}{\partial u_2}.\] For a geodesic, we expect this to acceleration vanish. Therefore \[\ddot u_k + \sum_{i,j = 1}^2 \Gamma^k_{ij} \dot u_i \dot u_j = 0, \quad \text{for } k=1,2. \] This differential equation of the curves parameters \(u(t), v(t)\) can be solved to find a geodesic curve.
In the follwing implementation, a semi-analytic approach is chosen: The surface parametrization and its derivates are symbolically treated, the evaluation of Christoffel symbols and solving of the geodaetic differential equation is performed numerically. This is in our view the most flexibel and robust way, as it doesn’t require inversion of analytic functions.
Helper Functions
def plot_surface(surface, line=None):
= plt.figure(figsize=(13,13))
fig = fig.add_subplot(projection='3d')
ax
= (np.squeeze(c) for c in surface) # unpack the coordinates X,Y,Z
surface *surface, alpha=0.2)
ax.plot_wireframe(
- lb for lb, ub in (getattr(ax, f'get_{a}lim')() for a in 'xyz')])
ax.set_box_aspect([ub
if line is not None:
= (np.squeeze(c) for c in line)
line *line, color='red', linewidth=5)
ax.plot(return ax
def geodesic(X, u_symbol, v_symbol, y0, tmax=10, solver_event=None):
"""Solve the geodesic equation given the surface X and return the curves parameters."""
def Christoffel_Gamma(u, v):
"""calculates the Christoffel symbol value at point (u,v). Needs first- and
second-order derivatives of euclidean X coordantes (x,y,z) with respect to the
plane parameters / inner coordinates (u,v)"""
# second derivates
= dX_du2_num(u, v)
dX_du2 = dX_dv2_num(u, v)
dX_dv2 = dX_dudv_num(u, v)
dX_dudv
# first-order derivates and normal vector matrix
= dX_du_num(u, v)
dX_du = dX_dv_num(u, v)
dX_dv = np.cross(dX_du, dX_dv, axis=0) # normal vector
N = np.hstack([dX_du, dX_dv, N])
dX_matrix
# express second-order derivative in first-derivatives -> Christoffel symbol
= np.linalg.solve(dX_matrix, dX_du2)[0:2] # = \Gamma^k_uu, k=u or v
gamma_uu = np.linalg.solve(dX_matrix, dX_dudv)[0:2] # = \Gamma^k_uv, k=u or v
gamma_uv = np.linalg.solve(dX_matrix, dX_dv2)[0:2] # = \Gamma^k_vv, k=u or v
gamma_vv
return (gamma_uu, gamma_uv, gamma_vv)
def geodesic_equation(t, y):
"""Implements geodesic equation for ODE solver. Second-order eqn. are mapped to
system of first-order differential equations: dy/dt = geodesic_eqn(t, y)
t: parameter of line
y: = (u, u', v, v') with plane coordinates (u,v) and their derivatves
"""
= y # state variables
u, dudt, v, dvdt
= Christoffel_Gamma(u, v)
gamma_uu, gamma_uv, gamma_vv
# Geodesic equation, see [https://de.wikipedia.org/wiki/Christoffelsymbole]:
# $$ u_k'' = - \sum_ij gamma^k_ij * u'_i * u'_j for all k $$
# we use coordinates (u,v), these map to u_k with: u_1 = u, u_2 = v
= -(gamma_uu * dudt**2 + 2 * gamma_uv * dudt * dvdt + gamma_vv * dvdt**2)
du_dt2
# return vector with derivatives (u', u'', v', v'')
= np.array([dudt, du_dt2[0,0], dvdt, du_dt2[1,0]])
derivates return derivates
= u_symbol
u = v_symbol
v
# symbolic first- and second-order derivatives
= X.diff(u)
dX_du = X.diff(v)
dX_dv = X.diff(u,u)
dX_du2 = X.diff(u, v)
dX_dudv = X.diff(v, v)
dX_dv2
# ... and their numerical functions
= lambdify((u, v), X, 'numpy')
X_num = lambdify((u, v), dX_du, 'numpy')
dX_du_num = lambdify((u, v), dX_dv, 'numpy')
dX_dv_num = lambdify((u, v), dX_du2, 'numpy')
dX_du2_num = lambdify((u, v), dX_dudv, 'numpy')
dX_dudv_num = lambdify((u, v), dX_dv2, 'numpy')
dX_dv2_num
= scipy.integrate.solve_ivp(geodesic_equation, [0, tmax], y0, dense_output=True,
sol =solver_event)
events
# return the solver object if events were provided, otherwise only return function
if solver_event:
return sol
else:
return lambda t: sol.sol(t)[[0,2]] # return u and v coordinate tuple only
Flat Plane in Polar Coordinates
Test the geodesics calculation on a flat surface defined with polar coordinates. We expect straight line :)
= symbols('u v') # u = radius, v = angle phi
u, v
# Express euclidean coordinates (x,y,z) in terms of plane parameters (u,v)
= Matrix([u * cos(v),
X * sin(v),
u * (cos(v) + sin(v))])
u
# first-order derivatives
= X.diff(u)
dX_du = X.diff(v)
dX_dv
# ... and their numerical functions
= lambdify((u, v), X, 'numpy')
X_num = lambdify((u, v), dX_du, 'numpy')
dX_du_num = lambdify((u, v), dX_dv, 'numpy')
dX_dv_num
X
\(\displaystyle \left[\begin{matrix}u \cos{\left(v \right)}\\u \sin{\left(v \right)}\\u \left(\sin{\left(v \right)} + \cos{\left(v \right)}\right)\end{matrix}\right]\)
Just for fun, show plane, a curve and the phi and r coordinate basis vectors:
# calculate surface points for certain coordinate range
= np.meshgrid(np.linspace(0, 1, 100),
u_grid, v_grid 0, 2*np.pi, 100))
np.linspace(= X_num(u_grid, v_grid)
surface
= X_num(0.5, np.linspace(0, 2*np.pi, 100)) # define arbitrary line
line
# show the basis vector for the local tangent plane at some points
= np.meshgrid(np.linspace(0, 1, 5),
u_grid, v_grid 0, 2*np.pi, 20))
np.linspace(= X_num(u_grid, v_grid)
points = dX_du_num(u_grid, v_grid) /10
vec = dX_dv_num(u_grid, v_grid) /10
vec2
= plot_surface(surface, line)
ax *(c for c in points), *(v for v in vec), color='green')
ax.quiver3D(*(c for c in points), *(v for v in vec2), color='red'); ax.quiver3D(
For sure, the red line is not a geodesic, so let’s try to turn on the machinery…
# solve the geodesic equation:
= 3 # maximum length of line
tmax = [.5, -.5, 0, 1] # initial coordinates and derivatives (u, u', v, v'), determines direction and starting point
y0 = geodesic(X, u, v, y0, tmax)
g
# show it
= np.linspace(0,tmax,100)
t 0], g(t)[1])); plot_surface(surface, X_num(g(t)[
It looks like we have a straight line, ie. a geodesic in the flat plane! :-D
Let’s have a look at the (u,v) coordinates of the geodesic. After all, they probably look more interesting…
= plt.figure(figsize=(10, 6))
fig = fig.add_subplot()
ax 0], g(t)[1])
ax.plot(g(t)['r')
ax.set_xlabel('phi')
ax.set_ylabel('Geodesic on Flat Plane in Polar Coordinates'); plt.title(
Sphere
= symbols('u v') # u = phi, v = angle theta
u, v
# Euclidean Mapping (u,v) -> (x,y,z)
= 1
R0 = Matrix([R0 * cos(u)*sin(v),
X * sin(u)*sin(v),
R0 * cos(v)])
R0
= lambdify((u, v), X, 'numpy')
X_num
# calculate surface points for certain coordinate range
= np.meshgrid(np.linspace(0, 2*np.pi, 100),
u_grid, v_grid 0, np.pi, 100))
np.linspace(= X_num(u_grid, v_grid) surface
= 10 # maximum length of line
tmax = [1, 1, np.pi/2+.7, 0] # initial u, u', v, v', determines direction and starting point
y0 = geodesic(X, u, v, y0, tmax)
g
# show it
= np.linspace(0, tmax, 100)
t = plot_surface(surface, X_num(g(t)[0], g(t)[1])) _
Looks like a great circle, i.e. a geodesic. Check!
Torus
= symbols('u v')
u, v = 1, 0.25
R0, R1 = Matrix([R0 * cos(u) + R1 * cos(u) * cos(v),
X * sin(u) + R1 * sin(u) * cos(v),
R0 * sin(v)])
R1
= lambdify((u, v), X, 'numpy')
X_num
# calculate surface points
= np.meshgrid(np.linspace(0, 2*np.pi, 100),
u_grid, v_grid 0, 2*np.pi, 100))
np.linspace(= X_num(u_grid, v_grid) surface
%matplotlib inline
= 7
tmax = [0, 0, 0, 1] # point towards v coordinate
y0 = geodesic(X, u, v, y0, tmax)
g
= np.linspace(0, tmax, 100)
t = plot_surface(surface, X_num(g(t)[0], g(t)[1]))
ax
# a different geodesic: point towards u coordinate only
= [0, 1, 0, 0]
y0 = geodesic(X, u, v, y0, tmax)
g
= X_num(g(t)[0], g(t)[1])
line = (np.squeeze(c) for c in line)
line *line, color='red', linewidth=5)
ax.plot(
# a third geodesic: inclined in both parameters
= [0, .1, 0, 1]
y0 =35
tmax= geodesic(X, u, v, y0, tmax)
g
= np.linspace(0, tmax, 300)
t = X_num(g(t)[0], g(t)[1])
line = (np.squeeze(c) for c in line)
line *line, color='green', linewidth=5) ax.plot(
[<mpl_toolkits.mplot3d.art3d.Line3D at 0x7f625415bbe0>]
Looks like geodesics one would expect… Check! And now to the serious stuff:
Cone
TODO, might look nice…
Möbius Strip
= symbols('u v')
u, v
= 1
R = Matrix([(R + u * cos(0.5 * v)) * cos(v),
X + u * cos(0.5 * v)) * sin(v),
(R * sin(0.5 * v)])
u
= lambdify((u, v), X, 'numpy')
X_num
# calculate surface points
= np.meshgrid(np.linspace(-1, 1, 100),
u_grid, v_grid 0, 2*np.pi, 100))
np.linspace(= X_num(u_grid, v_grid)
surface
X
\(\displaystyle \left[\begin{matrix}\left(u \cos{\left(0.5 v \right)} + 1\right) \cos{\left(v \right)}\\\left(u \cos{\left(0.5 v \right)} + 1\right) \sin{\left(v \right)}\\u \sin{\left(0.5 v \right)}\end{matrix}\right]\)
Closed Loop Geodesic
We don’t want to find any geodesic, but a closed one. Therefore we need some preparations. Starting the geodesic at a point (u,v), we need to find the good direction to shoot if off, in order to hit the same point after roundtrip again:
= 5 # max t parameter = max geodesic length
tmax = 0 # starting point u
u_start = 0 # ... and v
v_start
# define end coordinates which correspond to a closed loop
= - u_start # Möbius strip: u -> -u after one turn
u_end_target = v_start + 2*np.pi # one single turn v_end_target
def closed_loop_metric(du_dt):
"""Returns a measure of how good we got a closed loop given a du/dt starting direction."""
def u_crossing_event(t, y):
"""Used in scipy.integrate.solve_ivp(event=) of geodesics() function,
used to detect target u values -> potential closed loop."""
return (y[0] - u_end_target)
= [u_start, du_dt, v_start, 1] # fixed dv/dt=1 in order to go along strip
y0 = geodesic(X, u, v, y0 , tmax, solver_event=u_crossing_event)
g
#print(g.y_events)
if len(g.y_events[0]) == 0:
return 100
# find v in events of u_end_target crossings, which is closest to v_end_target
= np.argmin(abs(g.y_events[0][:, 2] - v_end_target))
closest_idx = g.y_events[0][closest_idx, [0,2]]
u_end, v_end
return (u_end_target - u_end)**2 + (v_end_target - v_end)**2
With that, we can searching optimal du/dt direction. (Set some starting point in the neighbourhood, which can be ound by studying geodesic (u,v) plots manually)
= scipy.optimize.minimize_scalar(closed_loop_metric, bracket=[-.7, -.8])
minimizer print(minimizer)
= minimizer.x du_dt_closedloop
fun: 1.858207977514734e-14
message: '\nOptimization terminated successfully;\nThe returned value satisfies the termination criteria\n(using xtol = 1.48e-08 )'
nfev: 23
nit: 18
success: True
x: -1.5117115896978943
Let’s have a look at this curve with the found du/dt value:
= [u_start, du_dt_closedloop, v_start, 1] # as defined in closed_loop_metric()
y0 = geodesic(X, u, v, y0, tmax) g
%matplotlib inline
= plt.figure()
fig = fig.add_subplot()
ax 'v')
ax.set_xlabel('u')
ax.set_ylabel(
# start and end point
'o', color='red')
ax.plot([v_start, v_end_target], [u_start, u_end_target],
= np.linspace(0, tmax, 100)
t = (g(t)[1], g(t)[0])
line *line); ax.plot(
= plot_surface(surface, X_num(g(t)[0], g(t)[1])) _
While we arrived at the same starting pint, the geodesics cuts itself at an angle and is not closed.
Closed Geodesics, Take II
Experimenting with the initial values showed that (u,v)=(0,\(\pi\)) might yield better results:
= 5 # max t parameter = max geodesic length
tmax = 0 # starting point u
u_start = np.pi # ... and v
v_start
# define end coordinates which correspond to a closed loop
= - u_start # Möbius strip: u -> -u after one turn
u_end_target = v_start + 2*np.pi # one single turn v_end_target
Search optimal direction. ( See Experiments section below for chosen starting points 0.5, 0.51)
= scipy.optimize.minimize_scalar(closed_loop_metric, bracket=[0.5, 0.51])
minimizer print(minimizer)
= minimizer.x du_dt_closedloop
fun: 4.479991192779563e-13
message: '\nOptimization terminated successfully;\nThe returned value satisfies the termination criteria\n(using xtol = 1.48e-08 )'
nfev: 25
nit: 17
success: True
x: 0.6641064541349162
= [u_start, du_dt_closedloop, v_start, 1] # as defined in closed_loop_metric()
y0 = geodesic(X, u, v, y0, tmax) g
= plt.figure()
fig = fig.add_subplot()
ax 'v')
ax.set_xlabel('u')
ax.set_ylabel(
# start and end point
'o', color='red')
ax.plot([v_start, v_end_target], [u_start, u_end_target],
= np.linspace(0, tmax, 100)
t = (g(t)[1], g(t)[0])
line *line); ax.plot(
= plot_surface(surface, X_num(g(t)[0], g(t)[1])) _
Here we arrived at a properly closed geodesic on the Möbius strip. Other starting value for u_start might yield others as well.
Triple Twisted Strip
Three times twisted strip
= symbols('u v')
u, v
= 1
R = Matrix([(R + u * cos(1.5 * v)) * cos(v),
X + u * cos(1.5 * v)) * sin(v),
(R * sin(1.5 * v)])
u
= lambdify((u, v), X, 'numpy')
X_num
# calculate surface points
= np.meshgrid(np.linspace(-.5, .5, 100),
u_grid, v_grid 0, 2*np.pi, 100))
np.linspace(= X_num(u_grid, v_grid)
surface
X
\(\displaystyle \left[\begin{matrix}\left(u \cos{\left(1.5 v \right)} + 1\right) \cos{\left(v \right)}\\\left(u \cos{\left(1.5 v \right)} + 1\right) \sin{\left(v \right)}\\u \sin{\left(1.5 v \right)}\end{matrix}\right]\)
Search a closed geodesic:
= 6 # max t parameter = max geodesic length
tmax = 0 # starting point u
u_start = np.pi/2/1.5 # ... and v
v_start
# define end coordinates which correspond to a closed loop
= - u_start # Möbius strip: u -> u after one turn
u_end_target = v_start + 2*np.pi # one single turn v_end_target
Search optimal direction.
= scipy.optimize.minimize_scalar(closed_loop_metric, bracket=[0.2, 0.3])
minimizer print(minimizer)
= minimizer.x du_dt_closedloop
fun: 7.121210237517629e-11
message: '\nOptimization terminated successfully;\nThe returned value satisfies the termination criteria\n(using xtol = 1.48e-08 )'
nfev: 34
nit: 30
success: True
x: 0.31788575631551347
= [u_start, du_dt_closedloop, v_start, 1] # as defined in closed_loop_metric()
y0 = geodesic(X, u, v, y0, tmax) g
= plt.figure()
fig = fig.add_subplot()
ax 'v')
ax.set_xlabel('u')
ax.set_ylabel(
# start and end point
'o', color='red')
ax.plot([v_start, v_end_target], [u_start, u_end_target],
= np.linspace(0, tmax, 100)
t = (g(t)[1], g(t)[0])
line *line); ax.plot(
= plot_surface(surface, X_num(g(t)[0], g(t)[1])) _
We successfully arrived at the closed geodesics! let’s export the data:
Doubly Twisted Strip
= symbols('u v')
u, v
= 1
R = Matrix([(R + u * cos(1 * v)) * cos(v),
X + u * cos(1 * v)) * sin(v),
(R * sin(1 * v)])
u
= lambdify((u, v), X, 'numpy')
X_num
# calculate surface points
= np.meshgrid(np.linspace(-.5, .5, 100),
u_grid, v_grid 0, 2*np.pi, 100))
np.linspace(= X_num(u_grid, v_grid)
surface
X
\(\displaystyle \left[\begin{matrix}\left(u \cos{\left(v \right)} + 1\right) \cos{\left(v \right)}\\\left(u \cos{\left(v \right)} + 1\right) \sin{\left(v \right)}\\u \sin{\left(v \right)}\end{matrix}\right]\)
Closed Geodesic - Take I
= 6 # max t parameter = max geodesic length
tmax = 0 # starting point u
u_start = np.pi # ... and v
v_start
# define end coordinates which correspond to a closed loop
= u_start # non-Möbius strip: u -> u after one turn
u_end_target = v_start + 2*np.pi # one single turn v_end_target
Search optimal direction.
= scipy.optimize.minimize_scalar(closed_loop_metric, bracket=[0.5, 0.51])
minimizer print(minimizer)
= minimizer.x du_dt_closedloop
fun: 1.9317193557273604e-12
message: '\nOptimization terminated successfully;\nThe returned value satisfies the termination criteria\n(using xtol = 1.48e-08 )'
nfev: 24
nit: 17
success: True
x: 0.5568187609997342
= [u_start, du_dt_closedloop, v_start, 1] # as defined in closed_loop_metric()
y0 = geodesic(X, u, v, y0, tmax) g
= plt.figure()
fig = fig.add_subplot()
ax 'v')
ax.set_xlabel('u')
ax.set_ylabel(
# start and end point
'o', color='red')
ax.plot([v_start, v_end_target], [u_start, u_end_target],
= np.linspace(0, tmax, 100)
t = (g(t)[1], g(t)[0])
line *line); ax.plot(
These (u,v) coordinates show that we dont have a closed geodesic. New try…
Closed Geodesic - Take II
= 6 # max t parameter = max geodesic length
tmax = 0 # starting point u
u_start = np.pi/2 # ... and v
v_start
# define end coordinates which correspond to a closed loop
= u_start # non-Möbius strip: u -> u after one turn
u_end_target = v_start + 2*np.pi # one single turn v_end_target
Search optimal direction.
= scipy.optimize.minimize_scalar(closed_loop_metric, bracket=[0.3, .31])
minimizer print(minimizer)
= minimizer.x du_dt_closedloop
fun: 1.1346961251873202e-13
message: '\nOptimization terminated successfully;\nThe returned value satisfies the termination criteria\n(using xtol = 1.48e-08 )'
nfev: 25
nit: 18
success: True
x: 0.4467328464205437
= [u_start, du_dt_closedloop, v_start, 1] # as defined in closed_loop_metric()
y0 = geodesic(X, u, v, y0, tmax) g
= plt.figure()
fig = fig.add_subplot()
ax 'v')
ax.set_xlabel('u')
ax.set_ylabel(
# start and end point
'o', color='red')
ax.plot([v_start, v_end_target], [u_start, u_end_target],
= np.linspace(0, tmax, 100)
t = (g(t)[1], g(t)[0])
line *line); ax.plot(
= plot_surface(surface, X_num(g(t)[0], g(t)[1])) _
We successfully arrived at a closed geodesic on the doubly twisted strip!