Tutorial: Parallel Transport vs Corotating Frame on a Circle#
A circle in the \(xy\)-plane is a special curve: the Frenet–Serret frame, the Bishop (parallel-transport) frame, and a plain rotation by \(\tau\) all produce the same moving triad. This tutorial builds all three from scratch and shows they agree numerically.
Why they coincide. On a planar circle with constant curvature \(\kappa\) and zero torsion, the Frenet–Serret angular velocity equals the parallel-transport angular velocity, which in turn equals the constant rate of a rigid rotation about \(+z\). In general these three differ, but for a circle they collapse to a single thing.
>>> import equinox as eqx
>>> import jax.numpy as jnp
>>> import numpy as np
>>> import unxt as u
>>> import coordinax.charts as cxc
>>> import coordinax.frames as cxf
>>> import coordinax.transforms as cxfm
>>> import coordinaxs.curveframes as cxfc
Step 1: Define the Circle#
A unit-radius circle in the \(xy\)-plane, parameterised by \(\tau\) in seconds:
>>> def circle(tau: u.Q) -> u.Q:
... t = tau.ustrip("s")
... return u.Q(jnp.stack([jnp.cos(t), jnp.sin(t), jnp.zeros_like(t)]), "km")
...
>>> circle(u.Q(0.0, "s"))
Q([1., 0., 0.], 'km')
Step 2: Build the Three Frames#
2a — Frenet–Serret frame#
The Frenet–Serret frame has axes \((\mathbf{T}, \mathbf{N}, \mathbf{B})\) computed from the curve’s first and second derivatives:
>>> fs_frame = cxfc.FrenetSerretFrame.from_curve(cxf.alice, circle, "s")
2b — Bishop (parallel-transport) frame#
The Bishop frame \((\mathbf{T}, \mathbf{U}_1, \mathbf{U}_2)\) is obtained by parallel-transporting an initial normal along the curve. We choose the initial normal to match the Frenet–Serret normal at \(\tau = 0\), which for this circle is \(\mathbf{N}_0 = (-1, 0, 0)\):
>>> bishop_frame = cxfc.BishopFrame.from_curve(
... cxf.alice,
... circle,
... "s",
... normal_0=jnp.array([-1.0, 0.0, 0.0]),
... )
2c — Corotating frame via Translate | Rotate#
A rigid rotation by angle \(\tau\) about the \(z\)-axis, centred on \(\gamma(\tau)\), is defined without any curve-frame machinery — just a Translate and a Rotate:
>>> def neg_gamma(tau: u.Q) -> u.Q:
... """Translate by -gamma(tau)."""
... return cxc.cdict(-circle(tau), cxc.cart3d)
...
>>> def R_z(tau: u.Q) -> jnp.ndarray:
... """Rotation matrix for angle tau about +z.
... This is the same matrix R = [T; N; B] that the
... Frenet-Serret frame produces for this particular circle.
... """
... t = tau.ustrip("s")
... ct, st = jnp.cos(t), jnp.sin(t)
... return jnp.array([[-st, ct, 0.0], [-ct, -st, 0.0], [0.0, 0.0, 1.0]])
...
A real `equinox.Module` builder, rather than `TimeDep.from_(lambda ...)`, keeps this differentiable/vmappable and avoids the recompile-per-closure cost of wrapping a bare function (see [Writing a Builder](../../../docs/guides/transforms.md#writing-a-builder)):
```pycon
>>> class Corotate(eqx.Module):
... """tau -> Translate(-gamma(tau)) | Rotate(R_z(tau))."""
... def __call__(self, tau: u.Q) -> cxfm.Composed:
... return cxfm.Translate(neg_gamma(tau), chart=cxc.cart3d) | cxfm.Rotate(R_z(tau))
...
>>> xop = cxfm.TimeDep(Corotate())
>>> corot_frame = cxf.TransformedReferenceFrame(cxf.alice, xop)
Step 3: Compare the Rotation Matrices#
At any \(\tau\), all three frames should produce the same \(3 \times 3\) rotation matrix \(R(\tau)\). Let’s check at several values:
>>> taus = [u.Q(0.0, "s"), u.Q(0.5, "s"), u.Q(1.0, "s"), u.Q(jnp.pi, "s")]
Extract the rotation matrix from each transform:
>>> for tau in taus:
... R_fs = jnp.stack(
... [
... fs_frame.xop.builder.tangent(tau).value,
... fs_frame.xop.builder.normal(tau).value,
... fs_frame.xop.builder.binormal(tau).value,
... ]
... )
... R_bp = jnp.stack(
... [
... bishop_frame.xop.builder.tangent(tau).value,
... bishop_frame.xop.builder.normal1(tau).value,
... bishop_frame.xop.builder.normal2(tau).value,
... ]
... )
... R_co = R_z(tau)
... np.testing.assert_allclose(R_fs, R_co, atol=1e-6)
... np.testing.assert_allclose(R_bp, R_co, atol=1e-5)
...
All three rotation matrices agree to numerical precision.
Step 4: Transform a Point Through Each Frame#
Pick a test point and a specific \(\tau\):
>>> tau = u.Q(1.0, "s")
>>> p = u.Q([2.0, 0.5, 0.3], "km")
Apply the forward transform through each frame:
>>> op_fs = cxf.frame_transition(cxf.alice, fs_frame)
>>> op_bp = cxf.frame_transition(cxf.alice, bishop_frame)
>>> op_co = cxf.frame_transition(cxf.alice, corot_frame)
>>> p_fs = cxfm.act(op_fs, tau, p)
>>> p_bp = cxfm.act(op_bp, tau, p)
>>> p_co = cxfm.act(op_co, tau, p)
All three give the same result:
>>> np.testing.assert_allclose(p_fs.value, p_co.value, atol=1e-6)
>>> np.testing.assert_allclose(p_bp.value, p_co.value, atol=1e-5)
Step 5: Round-Trip Through Each Frame#
Going forward then backward should recover the original point:
>>> op_fs_inv = cxf.frame_transition(fs_frame, cxf.alice)
>>> op_bp_inv = cxf.frame_transition(bishop_frame, cxf.alice)
>>> op_co_inv = cxf.frame_transition(corot_frame, cxf.alice)
>>> p_rt_fs = cxfm.act(op_fs_inv, tau, p_fs)
>>> p_rt_bp = cxfm.act(op_bp_inv, tau, p_bp)
>>> p_rt_co = cxfm.act(op_co_inv, tau, p_co)
>>> np.testing.assert_allclose(p_rt_fs.value, p.value, atol=1e-6)
>>> np.testing.assert_allclose(p_rt_bp.value, p.value, atol=1e-5)
>>> np.testing.assert_allclose(p_rt_co.value, p.value, atol=1e-6)
Step 6: Why They Agree — and When They Don’t#
For any planar curve with constant curvature \(\kappa\) and zero torsion \(\tau_{\text{geom}} = 0\):
The Frenet–Serret rotation rate equals \(\kappa\) (constant),
parallel transport has no torsion-induced drift, and
a rigid rotation at rate \(\kappa\) about the curve normal to the plane matches exactly.
For a helix (constant \(\kappa > 0\), constant \(\tau_{\text{geom}} \ne 0\)), the Frenet–Serret and Bishop frames differ — the FS normal \(\mathbf{N}\) rotates relative to the parallel-transported \(\mathbf{U}_1\). A rigid-rotation frame would need a more complex axis to match either.
Let’s verify on a helix that the three frames disagree:
>>> def helix(tau: u.Q) -> u.Q:
... t = tau.ustrip("s")
... return u.Q(jnp.stack([jnp.cos(t), jnp.sin(t), t]), "km")
...
>>> fs_helix = cxfc.FrenetSerretBuilder(helix, "s")
>>> bp_helix = cxfc.BishopBuilder(
... helix,
... "s",
... normal_0=jnp.array([-1.0, 0.0, 0.0]),
... )
>>> tau_h = u.Q(1.0, "s")
>>> T_fs = fs_helix.tangent(tau_h).value
>>> N_fs = fs_helix.normal(tau_h).value
>>> U1_bp = bp_helix.normal1(tau_h).value
>>> assert not np.allclose(
... N_fs, U1_bp, atol=1e-3
... ), "On a helix the FS normal and Bishop normal should differ!"
Summary#
Quantity |
Circle (\(\kappa\) const, \(\tau_\text{geom} = 0\)) |
Helix (\(\kappa\) const, \(\tau_\text{geom} \ne 0\)) |
|---|---|---|
Frenet–Serret \(R(\tau)\) |
\(=\) Rotation by \(\kappa\tau\) about \(\hat{z}\) |
FS-specific |
Bishop \(R(\tau)\) |
same as FS |
differs from FS by torsion drift |
Rigid rotation |
same as FS |
does not match either |
The circle is the unique case where all three coincide — a useful sanity check and a gateway to understanding the differences that emerge on more complex curves.