Parallel Transport Along a Curve on a Surface¶

We parallel transport along a nice, yet non-trivial, curve.

Here's we implement this fundamental operation in differential geometry on a surface. First we gather all imports & plot a surface.

In [292]:
# import necessary libraries
import plotly.graph_objects as go  # main interactive plotting library
import numpy as np # numerical operations
import os # operating system interactions
import plotly.io as pio # plotly input/output (saving, rendering)
import plotly.offline as pyo # plotly offline mode (exporting to html)
import sympy as sym # symbolic mathematics for derivatives
# import math # mathematical functions
# to reference variables within markdown
from myst_nb import glue
import matplotlib.pyplot as plt # some simple plots
# function to give components of curve
# use a scipy ODE solver
from scipy.integrate import solve_ivp

# Initialize Plotly offline mode, so we can display plots of the notebook in the .html file
pio.renderers.default = 'notebook'
pyo.init_notebook_mode()

# surface parameters ---
R = 1.0  # Radius of the surface/torus
# express the angles in terms of t
t_sym = sym.symbols('t')
phi_t_expr = 1-t_sym/np.pi
phi_t = sym.lambdify(t_sym, phi_t_expr, 'numpy')
theta_t_expr = t_sym  
theta_t = sym.lambdify(t_sym, theta_t_expr, 'numpy')

# --- 1.5 Curve Parameter ---
theta_0 = np.pi / 4 # Constant polar angle for the curve

# --- 2. Generate surface Surface Data 
phi = np.linspace(0, 2 * np.pi, 100)
theta = np.linspace(0, 2*np.pi, 100)
phi_sym, theta_sym = sym.symbols('phi, theta')
phi_grid, theta_grid = np.meshgrid(phi, theta)

# numeric parametrizations for surfaces - - - - - - - - - - - - -
# -- sphere --
x_surface_num = R * np.sin(phi_grid) * np.cos(theta_grid)
y_surface_num = R * np.sin(phi_grid) * np.sin(theta_grid)
z_surface_num = R * np.cos(phi_grid)

# -- torus --
x_surface_num = (R + 0.3 * np.cos(phi_grid)) * np.cos(theta_grid)
y_surface_num = (R + 0.3 * np.cos(phi_grid)) * np.sin(theta_grid)
z_surface_num = 0.3 * np.sin(phi_grid)

# symbolic parametrizations for surfaces - - - - - - - - - - - - -
# -- sphere --
x_surface = R * sym.sin(phi_sym) * sym.cos(theta_sym)
y_surface = R * sym.sin(phi_sym) * sym.sin(theta_sym)
z_surface = R * sym.cos(phi_sym)

# -- torus --
x_surface = (R + 0.3 * sym.cos(phi_sym)) * sym.cos(theta_sym)
y_surface = (R + 0.3 * sym.cos(phi_sym)) * sym.sin(theta_sym)
z_surface = 0.3 * sym.sin(phi_sym)

x_surface_func = sym.lambdify((phi_sym, theta_sym), x_surface, 'numpy')
y_surface_func = sym.lambdify((phi_sym, theta_sym), y_surface, 'numpy')
z_surface_func = sym.lambdify((phi_sym, theta_sym), z_surface, 'numpy')


# Create the surface Surface Trace
surface_trace = go.Surface(
    x=x_surface_num, y=y_surface_num, z=z_surface_num,
    colorscale='Earth',  # Uniform blue color
    opacity=0.6,
    showscale=False,  # Hide the color bar
    name='surface'
)

fig0 = go.Figure(data=[surface_trace])
fig0.update_layout(
    title='Parallel Transport of a Tangent Vector along a Curve in a surface',
    scene=dict(
        xaxis_title='X',
        yaxis_title='Y',
        zaxis_title='Z',
        # Enforce cube-like aspect ratio (important for spherical geometry)
        aspectmode='data',
        # Set the axis ranges
        xaxis=dict(range=[scale*xmin-buffer, scale*xmax+buffer]),
        yaxis=dict(range=[scale*ymin-buffer, scale*ymax+buffer]),
        zaxis=dict(range=[scale*zmin-buffer, scale*zmax+buffer]),
    ),
    margin=dict(l=0, r=0, b=0, t=30)
)
fig0.show()

Tangent & Normal Vectors¶

In [293]:
# symbolic partial derivatives
dx_dphi_surface   = sym.diff(x_surface, phi_sym)
dy_dphi_surface   = sym.diff(y_surface, phi_sym)
dz_dphi_surface   = sym.diff(z_surface, phi_sym)
dx_dtheta_surface = sym.diff(x_surface, theta_sym)
dy_dtheta_surface = sym.diff(y_surface, theta_sym)
dz_dtheta_surface = sym.diff(z_surface, theta_sym)

# unit tangent symbolic expressions (normalize using symbolic sqrt)
v_phi_surface   = 1/sym.sqrt(  dx_dphi_surface**2 + dy_dphi_surface**2   + dz_dphi_surface**2)
v_theta_surface = 1/sym.sqrt(dx_dtheta_surface**2 + dy_dtheta_surface**2 + dz_dtheta_surface**2)

# symbolic unit tangent components dx/dphi, dy/dphi, dz/dphi
T_x_phi_surface = v_phi_surface * dx_dphi_surface
T_y_phi_surface = v_phi_surface * dy_dphi_surface
T_z_phi_surface = v_phi_surface * dz_dphi_surface

# symbolic unit tangent components dx/dtheta, dy/dtheta, dz/dtheta
T_x_theta_surface = v_theta_surface * dx_dtheta_surface
T_y_theta_surface = v_theta_surface * dy_dtheta_surface
T_z_theta_surface = v_theta_surface * dz_dtheta_surface

# numeric functions for unit tangent components dx/dphi, dy/dphi, dz/dphi
T_x_phi_func = sym.lambdify((phi_sym, theta_sym), T_x_phi_surface, 'numpy')
T_y_phi_func = sym.lambdify((phi_sym, theta_sym), T_y_phi_surface, 'numpy')
T_z_phi_func = sym.lambdify((phi_sym, theta_sym), T_z_phi_surface, 'numpy')

# numeric functions for unit tangent components dx/dtheta, dy/dtheta, dz/dtheta
T_x_theta_func = sym.lambdify((phi_sym, theta_sym), T_x_theta_surface, 'numpy')
T_y_theta_func = sym.lambdify((phi_sym, theta_sym), T_y_theta_surface, 'numpy')
T_z_theta_func = sym.lambdify((phi_sym, theta_sym), T_z_theta_surface, 'numpy')

# tangent basis vectors
T_theta = sym.Matrix([T_x_theta_surface, T_y_theta_surface, T_z_theta_surface])
T_phi = sym.Matrix([T_x_phi_surface, T_y_phi_surface, T_z_phi_surface])

# numeric functions for tangent basis vectors
T_theta_func = sym.lambdify((phi_sym, theta_sym), T_theta, 'numpy')
T_phi_func = sym.lambdify((phi_sym, theta_sym), T_phi, 'numpy')

# Evaluate tangent vectors on grid
Tx_theta= T_x_theta_func(phi_grid, theta_grid)
Ty_theta= T_y_theta_func(phi_grid, theta_grid)
Tz_theta= T_z_theta_func(phi_grid, theta_grid)*np.ones(phi_grid.shape)

# Evaluate tangent vectors on grid
Tx_phi= T_x_phi_func(phi_grid, theta_grid)
Ty_phi= T_y_phi_func(phi_grid, theta_grid)
Tz_phi= T_z_phi_func(phi_grid, theta_grid)*np.ones(phi_grid.shape)

# Symbolic normal vector components
Nx_sym = T_y_phi_surface * T_z_theta_surface - T_z_phi_surface * T_y_theta_surface
Ny_sym = T_z_phi_surface * T_x_theta_surface - T_x_phi_surface * T_z_theta_surface
Nz_sym = T_x_phi_surface * T_y_theta_surface - T_y_phi_surface * T_x_theta_surface

# Normal vector is cross product of tangents --  --  --  --  --  --  --  --  --  --
Nx_num = Ty_phi * Tz_theta - Tz_phi * Ty_theta
Ny_num = Tz_phi * Tx_theta - Tx_phi * Tz_theta
Nz_num = Tx_phi * Ty_theta - Ty_phi * Tx_theta

# normalize
norm_N = np.sqrt(Nx_num**2 + Ny_num**2 + Nz_num**2)
Nx_num = Nx_num / norm_N
Ny_num = Ny_num / norm_N
Nz_num = Nz_num / norm_N
N = sym.Matrix([Nx_sym, Ny_sym, Nz_sym])
# make a numeric function for N
N_func = sym.lambdify((phi_sym, theta_sym), N, 'numpy')

# verify orthogonality
dot_product_1 = Tx_phi * Nx_num + Ty_phi * Ny_num + Tz_phi * Nz_num
dot_product_2 = Tx_theta * Nx_num + Ty_theta * Ny_num + Tz_theta * Nz_num
print("Max dot product (T_phi . N): ", np.max(np.abs(dot_product_1)))
print("Max dot product (T_theta . N): ", np.max(np.abs(dot_product_2)))

# verify orthogonality at a point
print('should be zero = ',np.dot(N_func(1,1).flatten(),T_theta_func(1,1).flatten()))
print('should be zero = ',np.dot(N_func(3,1).flatten(),T_phi_func(3,1).flatten()))

# time parameter
t_sym = sym.symbols('t')

# express n(t) in terms of t
n_t_expr = sym.Matrix([Nx_sym.subs({phi_sym:phi_t_expr, theta_sym:theta_t_expr}),
                       Ny_sym.subs({phi_sym:phi_t_expr, theta_sym:theta_t_expr}),
                       Nz_sym.subs({phi_sym:phi_t_expr, theta_sym:theta_t_expr})])
n_t_fn = sym.lambdify(t_sym, n_t_expr, 'numpy')

# verify orthogonality at a random t
rng = np.random.default_rng()
random_t = rng.uniform(low=0, high=2*np.pi)
print('should be zero = ',np.dot(n_t_fn(random_t).flatten(),
                                T_theta_func(phi_t(random_t),theta_t(random_t)).flatten()))

# create fast numeric function for n(t)
def n_fast(t):
    return np.asarray(n_t_fn(t)).astype(float).flatten()

# matrix of n'(t): differentiating n_t_expr wrt t_sym
n_t_prime_expr = sym.Matrix([sym.diff(n_t_expr[0], t_sym),
                             sym.diff(n_t_expr[1], t_sym),
                             sym.diff(n_t_expr[2], t_sym)])

# create fast numeric function for n'(t)
n_t_prime_fn = sym.lambdify(t_sym, n_t_prime_expr, 'numpy')
def n_prime_fast(t):
    return np.asarray(n_t_prime_fn(t)).astype(float).flatten()

# extract initial point coordinates

x0 = x_surface_func(phi_t(0), theta_t(0))
y0 = y_surface_func(phi_t(0), theta_t(0))
z0 = z_surface_func(phi_t(0), theta_t(0))

# extract initial tangent vectors
tx0 = T_phi_func(phi_t(0), theta_t(0))[0][0]
ty0 = T_phi_func(phi_t(0), theta_t(0))[1][0]  
tz0 = T_phi_func(phi_t(0), theta_t(0))[2][0]

tangents_trace_1 = go.Cone(
    x=[x0], y=[y0], z=[z0], # Start point
    u=[tx0],v=[ty0], w=[tz0], # vector components
    sizemode='absolute',
    sizeref=.1, # Adjust arrow size
    colorscale=[[0, 'blue'], [1, 'yellow']],
    name='Initial Vector',
    showscale=False
)

# extract initial tangent vectors
tx1 = T_theta_func(phi_t(0), theta_t(0))[0][0]
ty1 = T_theta_func(phi_t(0), theta_t(0))[1][0]  
tz1 = T_theta_func(phi_t(0), theta_t(0))[2][0]

tangents_trace_2 = go.Cone(
    x=[x0], y=[y0], z=[z0], # Start point
    u=[tx1],v=[ty1], w=[tz1], # vector components
    sizemode='absolute',
    sizeref=.1, # Adjust arrow size
    colorscale=[[0, 'yellow'], [1, 'yellow']],
    name='Initial Vector',
    showscale=False
)

# extract initial normal vectors
nx0 = n_fast(0)[0]
ny0 = n_fast(0)[1]  
nz0 = n_fast(0)[2]

# normal vector
normal_trace = go.Cone(
    x=[x0], y=[y0], z=[z0], # Start point
    u=[-nx0],v=[-ny0], w=[-nz0], # vector components
    sizemode='absolute',
    sizeref=.05, # Adjust arrow size
    colorscale=[[0, 'red'], [1, 'red']],
    name='Normal Vector',
    showscale=False
)

# prepare to plot
xmin, xmax = min(x_surface_num.flatten()), max(x_surface_num.flatten())
ymin, ymax = min(y_surface_num.flatten()), max(y_surface_num.flatten())
zmin, zmax = min(z_surface_num.flatten()), max(z_surface_num.flatten())
buffer = 0.1
scale = 1.5

fig0 = go.Figure(data=[surface_trace, tangents_trace_1,tangents_trace_2,normal_trace])

# Set up the layout
fig0.update_layout(
    title='Parallel Transport of a Tangent Vector along a Curve in a surface',
    scene=dict(
        xaxis_title='X',
        yaxis_title='Y',
        zaxis_title='Z',
        # Enforce cube-like aspect ratio (important for spherical geometry)
        aspectmode='data',
        # Set the axis ranges
        xaxis=dict(range=[scale*xmin-buffer, scale*xmax+buffer]),
        yaxis=dict(range=[scale*ymin-buffer, scale*ymax+buffer]),
        zaxis=dict(range=[scale*zmin-buffer, scale*zmax+buffer]),
    ),
    margin=dict(l=0, r=0, b=0, t=30)
)
fig0.show()
pio.write_html(fig0, 'torus_tangents.html', auto_open=True)
Max dot product (T_phi . N):  1.6653345369377348e-16
Max dot product (T_theta . N):  1.1102230246251565e-16
should be zero =  2.7755575615628914e-17
should be zero =  0.0
should be zero =  5.551115123125783e-17

The derivatives of the surface tangent and normal vectors.¶

In case we wish to plot a vector field later, these will be useful.

In [294]:
# derivative of the unit tangent vector along phi direction
dTx_dphi_surface = sym.diff(T_x_phi_surface, phi_sym)
dTy_dphi_surface = sym.diff(T_y_phi_surface, phi_sym)
dTz_dphi_surface = sym.diff(T_z_phi_surface, phi_sym)
dTx_dtheta_surface = sym.diff(T_x_theta_surface, theta_sym)
dTy_dtheta_surface = sym.diff(T_y_theta_surface, theta_sym)
dTz_dtheta_surface = sym.diff(T_z_theta_surface, theta_sym)

# numeric functions for derivative of unit tangent vector along phi direction
dTx_dphi_func = sym.lambdify((phi_sym, theta_sym), dTx_dphi_surface, 'numpy')
dTy_dphi_func = sym.lambdify((phi_sym, theta_sym), dTy_dphi_surface, 'numpy')
dTz_dphi_func = sym.lambdify((phi_sym, theta_sym), dTz_dphi_surface, 'numpy')

# numeric functions for derivative of unit tangent vector along theta direction
dTx_dtheta_func = sym.lambdify((phi_sym, theta_sym), dTx_dtheta_surface, 'numpy')
dTy_dtheta_func = sym.lambdify((phi_sym, theta_sym), dTy_dtheta_surface, 'numpy')
dTz_dtheta_func = sym.lambdify((phi_sym, theta_sym), dTz_dtheta_surface, 'numpy')

# symbolic derivative of normal vector components
dNx_dphi_surface = sym.diff(Nx_sym, phi_sym)
dNy_dphi_surface = sym.diff(Ny_sym, phi_sym)
dNz_dphi_surface = sym.diff(Nz_sym, phi_sym)
dNx_dtheta_surface = sym.diff(Nx_sym, theta_sym)
dNy_dtheta_surface = sym.diff(Ny_sym, theta_sym)
dNz_dtheta_surface = sym.diff(Nz_sym, theta_sym)

# numeric functions for derivative of normal vector components
dNx_dphi_func = sym.lambdify((phi_sym, theta_sym), dNx_dphi_surface, 'numpy')
dNy_dphi_func = sym.lambdify((phi_sym, theta_sym), dNy_dphi_surface, 'numpy')
dNz_dphi_func = sym.lambdify((phi_sym, theta_sym), dNz_dphi_surface, 'numpy')
dNx_dtheta_func = sym.lambdify((phi_sym, theta_sym), dNx_dtheta_surface, 'numpy')
dNy_dtheta_func = sym.lambdify((phi_sym, theta_sym), dNy_dtheta_surface, 'numpy')
dNz_dtheta_func = sym.lambdify((phi_sym, theta_sym), dNz_dtheta_surface, 'numpy')

Plot Curve & Initial Vector¶

Next we plot the curve we wish to parallel transport over and the initial vector we wish to parallel transport. We display the initial tangent vector as a cone.

In [295]:
# components of the curve
def alpha_x(t):
    # -- sphere curves --
    # return R * sym.sin(theta_0) * sym.cos(t) # og
    # loxodrome return R * sym.cos(1-t/np.pi) * sym.cos(t)
    # nonsimple 5-petal return R * sym.sin(beta) * sym.cos(t)
    # return R * sym.cos(1-t/np.pi) * sym.cos(5*t) # nice loxodrome
    # -- torus curves --
    phi = 1-t/np.pi
    theta = t
    dphi_dt = -1/np.pi
    dtheta_dt = 1
    return (R + 0.3 * sym.cos(phi)) * sym.cos(theta), dphi_dt, dtheta_dt
def alpha_y(t):
    # -- sphere curves --
    # return R * sym.sin(theta_0) * sym.sin(t) # og
    # loxodrome return R * sym.cos(1-t/np.pi) * sym.sin(t)
    # non-simple 5-petal return R * sym.sin(beta) * sym.sin(t)
    # return R * sym.cos(1-t/np.pi) * sym.sin(5*t) # nice loxodrome
    # -- torus curves --
    phi = 1 - t / np.pi
    theta = t
    dphi_dt = -1 / np.pi
    dtheta_dt = 1
    return (R + 0.3 * sym.cos(phi)) * sym.sin(theta), dphi_dt, dtheta_dt

def alpha_z(t):
    # -- sphere curves --
    # return R * sym.cos(theta_0) # og
    # loxodrom return R * sym.sin(1-t/np.pi)
    # non-simple 5-petal return R * sym.cos(beta) # nice loxodrome
    # return R * sym.sin(1-t/np.pi)
    # -- torus curves --
    phi = 1 - t / np.pi
    dphi_dt = -1 / np.pi
    dtheta_dt = 0
    return (0.3 * sym.sin(phi)), dphi_dt, dtheta_dt

# symbolic variable
t = sym.symbols('t')

axt, dphi_dt, dtheta_dt = alpha_x(t)
ayt, _, _ = alpha_y(t)
azt, _, _ = alpha_z(t)

# the curve in numpy
# the numberic (numpy) components of the curve
cx = sym.lambdify(t,axt,'numpy')
cy = sym.lambdify(t,ayt,'numpy')
cz = sym.lambdify(t,azt,'numpy')

# the symbolic derivatives of the components
v_x = sym.diff(axt,t)
v_y = sym.diff(ayt,t)
v_z = sym.diff(azt,t)
v = 1/sym.sqrt(v_x**2 + v_y**2 + v_z**2)

# symbolic unit tangent vector
T_x = v*v_x
T_y = v*v_y
T_z = v*v_z

# numeric unit tangent vector components
Tx = sym.lambdify(t,T_x,'numpy')
Ty = sym.lambdify(t,T_y,'numpy')
Tz = sym.lambdify(t,T_z,'numpy')

# numeric velocity components
vx = sym.lambdify(t,v_x,'numpy')
vy = sym.lambdify(t,v_y,'numpy')
vz = sym.lambdify(t,v_z,'numpy')

# need to compute n(t) = d/dt of v(t)
# should be surface normal, not accelleration vector
acc_x = sym.diff(v_x,t)
acc_y = sym.diff(v_y,t)
acc_z = sym.diff(v_z,t)

## the numeric components of accelleration
nx = sym.lambdify(t,acc_x,'numpy')
ny = sym.lambdify(t,acc_y,'numpy')
nz = sym.lambdify(t,acc_z,'numpy')

# derivative of accelleration
np_x = sym.diff(acc_x,t)
np_y = sym.diff(acc_y,t)
np_z = sym.diff(acc_z,t)

# the numberic (numpy) derivatives of the components of the full
# #accelleration vector
npx = sym.lambdify(t,np_x,'numpy')
npy = sym.lambdify(t,np_y,'numpy')
npz = sym.lambdify(t,np_z,'numpy')

# parameterize curve
s = np.linspace(0, 2 * np.pi, 100)

# for example, the following contains all x-coords of the velocity vector
# print(v_x(s))

# convert the array s to a list before feeding the lambdified function
x_curve = cx(s) 
y_curve = cy(s)
z_curve = cz(s) * np.ones(s.shape[0]) # sometimes cz returns a scalar

# Create the Curve Trace
curve_trace = go.Scatter3d(
    x=x_curve, y=y_curve, z=z_curve,
    mode='lines',
    line=dict(color='red', width=5),
    name=r"Curve"
)

# --- 4. Generate Initial Tangent Vector Data (Yellow) ---
t_point = 0  # Point at phi = 45 degrees
# choose the starting point as a base point
xp = x_curve[0]
yp = y_curve[0]
zp = z_curve[0]


# Scale the vector for to get the right length visualization
scale_factor = 1
norm_Vx = Tx(0) * scale_factor
norm_Vy = Ty(0) * scale_factor
norm_Vz = Tz(0) * scale_factor

# Create the Vector Trace
vector_trace = go.Cone(
    x=[xp], y=[yp], z=[zp], # Start point
    u=[norm_Vx], v=[norm_Vy], w=[norm_Vz], # Vector components
    sizemode='absolute',
    sizeref=0.2, # Adjust arrow size
    colorscale=[[0, 'green'], [1, 'green']],
    name='Initial Vector',
    showscale=False
)



fig0 = go.Figure(data=[surface_trace, tangents_trace_1,tangents_trace_2,normal_trace,
                      curve_trace, vector_trace])
# Set up the layout
fig0.update_layout(
    title='Parallel Transport of a Tangent Vector along a Curve in a surface',
    scene=dict(
        xaxis_title='X',
        yaxis_title='Y',
        zaxis_title='Z',
        # Enforce cube-like aspect ratio (important for spherical geometry)
        aspectmode='data',
        # Set the axis ranges
        xaxis=dict(range=[scale*xmin-buffer, scale*xmax+buffer]),
        yaxis=dict(range=[scale*ymin-buffer, scale*ymax+buffer]),
        zaxis=dict(range=[scale*zmin-buffer, scale*zmax+buffer]),
    ),
    margin=dict(l=0, r=0, b=0, t=30)
)
fig0.show()

Compute parallel transported vector¶

The condition for a vector to be parallel transported is $D_u w = 0$. This in turn gives

$\nabla_v \bar{w} = (\nabla_v \bar{w} \cdot \bar{n}) \bar{n}$ where $v$ is the unit tangenct vector.

Using the shape operator this is equivalent to:

$\nabla_v \bar{w} = (-\bar{w} \cdot \nabla_v \bar{n} ) \bar{n}$

The derivatives are just regular time derivatives and this gives on ODE.

$\bar{w}'(t) = (-\bar{w}(t)) \cdot \bar{n}'(t)) \bar{n}$

To set-up an ODE that matches our given constraints we need to compute $\bar{n}'(t)$ and $\bar{n}(t)$ along the curve.

In [296]:
# plot w0 and n_fast
# Create the Vector Trace
curve_normal_trace = go.Cone(
    x=[xp], y=[yp], z=[zp], # Start point
    u=[n_fast(0)[0]], v=[n_fast(0)[1]], w=[n_fast(0)[2]], # Vector components
    sizemode='absolute',
    sizeref=0.2, # Adjust arrow size
    colorscale=[[0, 'purple'], [1, 'purple']],
    name='Normal Vector',
    showscale=False
)
fig0 = go.Figure(data=[surface_trace, tangents_trace_1,tangents_trace_2,normal_trace,
                      curve_trace, vector_trace,curve_normal_trace])

# Set up the layout
fig0.update_layout(
    title='Parallel Transport of a Tangent Vector along a Curve in a surface',
    scene=dict(
        xaxis_title='X',
        yaxis_title='Y',
        zaxis_title='Z',
        # Enforce cube-like aspect ratio (important for spherical geometry)
        aspectmode='data',
        # Set the axis ranges
        xaxis=dict(range=[scale*xmin-buffer, scale*xmax+buffer]),
        yaxis=dict(range=[scale*ymin-buffer, scale*ymax+buffer]),
        zaxis=dict(range=[scale*zmin-buffer, scale*zmax+buffer]),
    ),
    margin=dict(l=0, r=0, b=0, t=30)
)

fig0.show()

Define the ODE¶

In [297]:
# Initial Conditions
#w0 = np.array([Tx(0),Ty(0),Tz(0)])                # Initial state vector = unit tangent vector but could be anything
w0 = np.array([T_x_phi_func(phi_t(0),theta_t(0)),
               T_y_phi_func(phi_t(0),theta_t(0)),
               T_z_phi_func(phi_t(0),theta_t(0))])              

# visualize w0
x0 = float(x_surface.subs({phi_sym:phi_t(0), theta_sym:theta_t(0)}))
y0 = float(y_surface.subs({phi_sym:phi_t(0), theta_sym:theta_t(0)}))
z0 = float(z_surface.subs({phi_sym:phi_t(0), theta_sym:theta_t(0)}))


w0_trace = go.Cone(
    x=[x0], y=[y0], z=[z0], # Start point
    u=[w0[0]], v=[w0[1]], w=[w0[2]], # Vector components
    sizemode='absolute',
    sizeref=0.2, # Adjust arrow size
    colorscale=[[0, 'orange'], [1, 'orange']],
    name='Initial w Vector',
    showscale=False
)

fig0 = go.Figure(data=[surface_trace, tangents_trace_1,tangents_trace_2,normal_trace,
                      curve_trace, vector_trace,curve_normal_trace, w0_trace])
# Set up the layout
fig0.update_layout(
    title='Parallel Transport of a Tangent Vector along a Curve in a surface',
    scene=dict(
        xaxis_title='X',
        yaxis_title='Y',
        zaxis_title='Z',
        # Enforce cube-like aspect ratio (important for spherical geometry)
        aspectmode='data',
        # Set the axis ranges
        xaxis=dict(range=[scale*xmin-buffer, scale*xmax+buffer]),
        yaxis=dict(range=[scale*ymin-buffer, scale*ymax+buffer]),
        zaxis=dict(range=[scale*zmin-buffer, scale*zmax+buffer]),
    ),
    margin=dict(l=0, r=0, b=0, t=30)
)
fig0.show()

# Define the ODE function
def vector_ode_derivative(t, w, n, n_prime):
    """
    Defines the ODE system w'(t) = -dot(w(t), n'(t)) * n.

    Args:
        t (float): Current time.
        w (np.ndarray): Current state vector w(t).
        n_prime_func (callable): Function returning n'(t).
        n_func (callable): Function returning n(t).

    Returns:
        np.ndarray: The derivative w'(t).
    """
    n_prime_t = n_prime(t)
    n_t = n(t)
    dot_product = -np.dot(w, n_prime_t)
    dw_dt = dot_product * n_t
    return dw_dt

Solve the ODE¶

In [298]:
# constants
k = 2*np.pi                    # Upper bound for time
L = 100                        # number of points to derive a solution
t_span = (0, k)                # Time span (start, end)
t_eval = np.linspace(t_span[0], t_span[1], L) # Points at which to store the solution

# solve the ODE
solution = solve_ivp(vector_ode_derivative, t_span, w0, 
    args=(n_fast,n_prime_fast), t_eval=t_eval, atol=1e-6, rtol=1e-6)

# Time points
time_points = solution.t

# Solution vectors at each time point
w_solution = solution.y.T # Transpose to get w(t) as rows

Incorporate Solution¶

Next, we use the solution in our plots. We use a shaft and cone to display the vector. The shaft part is created using Scatter3D in line mode. Because the way Scatter3D seems to work, we need to great a different trace for each shaft we draw. Because there are many vectors in the vector field solution we have many segments (portions of vectors) to plot. We do that in a loop.

In [299]:
wx = w_solution[:,0]    # the solution x,y,z to the ODE sytem
wy = w_solution[:,1]    
wz = w_solution[:,2]

xpn = cx(time_points) # the new basepoint along curve
ypn = cy(time_points)
zpn = cz(time_points) * np.ones(np.size(xpn))

norm_w = np.sqrt(wx**2 + wy**2 + wz**2) # uniformly scale the solution vex
norm_w= np.where(norm_w == 0, 1.0, norm_w) # avoid division by zero
scale_factor = 1
wx = wx / norm_w * scale_factor
wy = wy / norm_w * scale_factor
wz = wz / norm_w * scale_factor

e = 0.3                            # arrow shaft scale factor
dx = e*wx; dy = e*wy; dz = e*wz;   # arrow shaft vector components

# Build single Scatter3d shaft trace using NaN separators (fast)
n_pts = xpn.size
xs = np.empty(n_pts * 3)
ys = np.empty(n_pts * 3)
zs = np.empty(n_pts * 3)

xs[0::3] = xpn
xs[1::3] = xpn + e * wx
xs[2::3] = np.nan

ys[0::3] = ypn
ys[1::3] = ypn + e * wy
ys[2::3] = np.nan

zs[0::3] = zpn
zs[1::3] = zpn + e * wz
zs[2::3] = np.nan

shaft_trace = go.Scatter3d(
    x=xs, y=ys, z=zs,
    mode='lines',
    line=dict(color='blue', width=2),
    showlegend=False
)

# vectorized dot products (no loop)
axp_all = vx(time_points)
ayp_all = vy(time_points)
azp_all = vz(time_points)
dots = wx[0] * axp_all + wy[0] * ayp_all + wz[0] * azp_all



# list to hold all traces of segments
segment_traces = []   
dots = np.ones(xpn.shape[0])
for i in range(xpn.shape[0]):
    x_start = xpn[i] # the x-coordinates of the i-th basepoints 
    y_start = ypn[i]
    z_start = zpn[i]

    x_end = x_start + e*wx[i] # the x-coords of the i-th endpoints
    y_end = y_start + e*wy[i]
    z_end = z_start + e*wz[i]

    segment_traces.append(go.Scatter3d(
        
        x=[x_start, x_end],
        y=[y_start, y_end],
        z=[z_start, z_end],
        mode='lines', # to plot lines not points
        line=dict(color='blue', width=2),
        showlegend=False
    ))
    # dots[i] = np.dot([wx[i],wy[i],wz[i]],[v_x(i),v_y(i),v_z(i)])
    dots[i] = np.dot([wx[i],wy[i],wz[i]],[w0[0],w0[1],w0[2]])

Angle between transport and velocity¶

The angles between the transported vector and the tangent vector does not remain constant when the path is a loxodrome, but is oscillatory.

In [300]:
plt.title("Angle between PT-vector & Unit Tangent over Time")
plt.plot(time_points, 180/np.pi*np.arccos(dots), color='blue')
# --- 7. Compute the Holonomy of the loop
h = 180/np.pi * 2*np.pi*(1-np.sin(np.pi/2 - theta_0))
mydot = np.dot([wx[-1],wy[-1],wz[-1]],[w0[0],w0[1],w0[2]])
print('dot = ', mydot)
H = np.arccos(mydot)
#plt.text(2,20,
#    r'Holonomy = , %.2f degrees' % (180/np.pi*H), 
#    ha = 'center', va = 'bottom', fontsize=10)
#plt.text(3,30,
#    r'Holonomy Est: R(L) = 2pi(1 - sin(pi/2 - phi))) = , %.2f degrees' % h, 
#    ha = 'center', va = 'bottom', fontsize=10)

plt.show()
dot =  -0.4161459680827364
No description has been provided for this image

The Parallel Transported Vector Field Tip¶

This is done using a Cone from ?graphical-objects. The syntax is self-explanatory. The (1-e) scalar is chosen so that the total length of the cone+shaft equals the scaled length of w.

In [301]:
# vector field
new_new_vector_trace = go.Cone(
    x=xpn + e*wx, y=ypn+e*wy, z=zpn+e*wz, # Start point
    u=(1-e)*wx, v=(1-e)*wy, w=(1-e)*wz, # Vector components
    sizemode='absolute',
    sizeref=.4, # Adjust arrow size
    colorscale='Viridis',
    name='Parallel Transported Vector',
    showscale=False,
    showlegend=True,
    anchor = 'tail',
    opacity = 0.6
)

The accelleration vector¶

In case we might want to see it is here. Just include the trace in the final fig() at the end.

In [302]:
# --- 4.5 Add accelleration vector
time_point = 0  # Point at phi = 45 degrees

# Vector components V (Vx, Vy, Vz)
Vx = -R * np.sin(theta_0) * np.sin(time_point) 
Vy = R * np.sin(theta_0) * np.cos(time_point)
Vz = 0

# Unit Tangent
vector_magnitude = np.sqrt(Vx**2 + Vy**2 + Vz**2)
norm_Vx = Vx / vector_magnitude
norm_Vy = Vy / vector_magnitude
norm_Vz = Vz / vector_magnitude

# Time-derivative of the vector w (in this case = V)
# Full accelleration vector
Vxg = -R * np.sin(theta_0) * np.cos(time_point) 
Vyg = -R * np.sin(theta_0) * np.sin(time_point)
Vzg = 0

# Create the Vector Trace using Cone
new_vector_trace = go.Cone(
    x=[xpn], y=[ypn], z=[zpn], # Start point
    u=[Vxg], v=[Vyg], w=[Vzg], # Vector components
    sizemode='absolute',
    sizeref=0.2, # Adjust arrow size
    colorscale=[[0, 'steelblue'], [1, 'steelblue']],
    name='Accelleration Vector',
    showlegend=True
)

Final Plot Commands¶

The segment_traces is a list of traces and not a trace itself, so needs to be combined as shown (concatenated?) instead of separated by commas.

In [303]:
xmin, xmax = min(x_surface_num.flatten()), max(x_surface_num.flatten())
ymin, ymax = min(y_surface_num.flatten()), max(y_surface_num.flatten())
zmin, zmax = min(z_surface_num.flatten()), max(z_surface_num.flatten())
buffer = 0.1
scale = 1.5

# --- 5. Create Figure and Layout ---
fig = go.Figure(data=[surface_trace, curve_trace, vector_trace, new_new_vector_trace]+segment_traces)
#fig.update_zaxes(range = [zmin, zmax])

# efficient fig = go.Figure(data=[surface_trace, curve_trace, vector_trace, new_new_vector_trace,shaft_trace])
# for trouleshooting
#fig = go.Figure(data=[surface_trace] + segment_traces)

# Set up the layout
fig.update_layout(
    title='Parallel Transport of a Tangent Vector along a Curve in a surface',
    scene=dict(
        xaxis_title='X',
        yaxis_title='Y',
        zaxis_title='Z',
        # Enforce cube-like aspect ratio (important for spherical geometry)
        aspectmode='data',
        # Set the axis ranges
        xaxis=dict(range=[scale*xmin-buffer, scale*xmax+buffer]),
        yaxis=dict(range=[scale*ymin-buffer, scale*ymax+buffer]),
        zaxis=dict(range=[scale*zmin-buffer, scale*zmax+buffer]),
    ),
    margin=dict(l=0, r=0, b=0, t=30)
)

fig.show()

# --- 7. Compute the Holonomy of the loop in case of a sphere
#h = 180/np.pi * 2*np.pi*(1-np.sin(np.pi/2 - theta_0))
#print('holonomy = R(L) = 2pi(1 - sin(pi/2 - phi))) = ', h)
#glue("holonomy", h)

Compute the Holonomy of the Loop¶

The holonomy is {glue:}holonomy

In [304]:
# Define a path in a known writable location (e.g., user's home directory)
output_directory = os.path.expanduser("~/g/teaching/ma541/python/") # Or any other writable path
output_file_path = os.path.join(output_directory, "surface_pt.html")

try:
    #pio.renderers.default = "jupyterlab" # or "notebook", "iframe", "vscode"
    pio.renderers.default = "notebook" # "iframe", "vscode"
    pio.write_html(fig, output_file_path, auto_open=True)
    print(f"Plotly figure saved successfully to: {output_file_path}")
except OSError as e:
    print(f"Error saving Plotly figure: {e}")
    print("Please ensure you have write permissions to the specified directory.")
# Save the figure as an HTML file

# - - - - run in terminal to convert notebook to html with dark theme
# jupyter nbconvert --execute --to html /path/to/examplej.ipynb --HTMLExporter.theme=dark
Plotly figure saved successfully to: /Users/josh/g/teaching/ma541/python/surface_pt.html

¶