coordinaxs.curveframes Specification#

This document is the normative specification for coordinaxs.curveframes.

coordinaxs.curveframes is subordinate to docs/spec.md. If behavior differs, the root spec is authoritative.


The Math#

Curves in Euclidean 3-Space#

A smooth parameterized curve is a smooth map

\[ \boldsymbol{\gamma} : \Lambda \to \mathbb{R}^3, \qquad \tau \mapsto \boldsymbol{\gamma}(\tau), \]

where \(\Lambda \subseteq \mathbb{R}\) is an open interval and \(\tau\) is a smooth evolution parameter (arc length, time, proper time, etc.). The curve is regular when \(\boldsymbol{\gamma}'(\tau) \neq 0\) for all \(\tau \in \Lambda\).

Frenet–Serret Frame#

For a regular curve \(\boldsymbol{\gamma}\) with non-vanishing curvature, the Frenet–Serret frame attaches an oriented orthonormal triad \((\mathbf{T}, \mathbf{N}, \mathbf{B})\) to each point:

\[ \mathbf{T}(\tau) = \frac{\boldsymbol{\gamma}'(\tau)} {\lVert\boldsymbol{\gamma}'(\tau)\rVert}, \]
\[ \mathbf{N}(\tau) = \frac{\boldsymbol{\gamma}''(\tau) - \bigl(\boldsymbol{\gamma}''(\tau) \cdot \mathbf{T}(\tau)\bigr)\, \mathbf{T}(\tau)} {\bigl\lVert \boldsymbol{\gamma}''(\tau) - \bigl(\boldsymbol{\gamma}''(\tau) \cdot \mathbf{T}(\tau)\bigr)\, \mathbf{T}(\tau) \bigr\rVert}, \]
\[ \mathbf{B}(\tau) = \mathbf{T}(\tau) \times \mathbf{N}(\tau). \]

Symbol

Name

Definition

\(\mathbf{T}\)

Tangent

Unit tangent: \(\boldsymbol{\gamma}'/\lVert\boldsymbol{\gamma}'\rVert\)

\(\mathbf{N}\)

Normal

Unit principal normal: Gram–Schmidt rejection of \(\boldsymbol{\gamma}''\) onto \(\mathbf{T}\), then normalised

\(\mathbf{B}\)

Binormal

\(\mathbf{T} \times \mathbf{N}\) (right-handed completion)

Properties. For every \(\tau\):

  1. Orthonormality: \(\mathbf{T} \cdot \mathbf{N} = \mathbf{T} \cdot \mathbf{B} = \mathbf{N} \cdot \mathbf{B} = 0\) and \(\lVert\mathbf{T}\rVert = \lVert\mathbf{N}\rVert = \lVert\mathbf{B}\rVert = 1\).

  2. Right-handedness: \(\mathbf{B} = \mathbf{T} \times \mathbf{N}\).

  3. Singularity: The frame is undefined when the curvature \(\kappa(\tau) = 0\) (i.e.\ the curve is locally straight).

Curvature and torsion#

The two invariants of the apparatus are

\[ \kappa = \frac{\lVert\boldsymbol{\gamma}' \times \boldsymbol{\gamma}''\rVert} {\lVert\boldsymbol{\gamma}'\rVert^3}, \qquad \tau_g = \frac{(\boldsymbol{\gamma}' \times \boldsymbol{\gamma}'') \cdot \boldsymbol{\gamma}'''} {\lVert\boldsymbol{\gamma}' \times \boldsymbol{\gamma}''\rVert^2}. \]

They differ in where they are defined, and the difference is not cosmetic. \(\kappa \ge 0\) is defined wherever the curve is regular, including at an inflection and along a straight segment — it simply reads zero. The frame is undefined at exactly those points, because \(\mathbf{N}\) has no direction there, so curvature answers where rotation_matrix refuses.

\(\tau_g\) is not so forgiving: its denominator is \((\kappa\lVert\boldsymbol{\gamma}'\rVert^3)^2\), so it degenerates precisely where \(\kappa = 0\) and is refused there.

On a planar curve \(\tau_g = 0\) wherever it is defined, and \(\kappa = \lvert\kappa_s\rvert\) for the signed curvature of the signed planar frame.

Frenet–Serret Transform#

The Frenet–Serret frame defines a \(\tau\)-dependent rigid-body transform (translation + rotation) between the ambient Cartesian frame and the curve-attached frame.

Forward Transform#

At each \(\tau\), define the rotation matrix

\[\begin{split} R(\tau) = \begin{pmatrix} \mathbf{T}(\tau)^T \\ \mathbf{N}(\tau)^T \\ \mathbf{B}(\tau)^T \end{pmatrix} \in SO(3). \end{split}\]

The forward transform maps an ambient point \(\mathbf{p}\) to curve-frame coordinates:

\[ \mathbf{p}' = R(\tau)\bigl(\mathbf{p} - \boldsymbol{\gamma}(\tau)\bigr). \]

Inverse Transform#

Since \(R \in SO(3)\), we have \(R^{-1} = R^T\). The inverse transform maps curve-frame coordinates back to the ambient frame:

\[ \mathbf{p} = R^T(\tau)\,\mathbf{p}' + \boldsymbol{\gamma}(\tau). \]

Double-inverse identity. Because \((R^T)^T = R\):

\[ \bigl(F^{-1}\bigr)^{-1} = F. \]

Applying the Transform#

A FrenetSerretBuilder does not store \(\boldsymbol{\gamma}\), \(\mathbf{T}\), \(\mathbf{N}\), \(\mathbf{B}\) as separate fields. It stores the curve \(\boldsymbol{\gamma}\) itself (and tau_unit), and builds the rigid-body transform on demand:

\[ F(\tau) = \mathrm{Translate}\bigl(-\boldsymbol{\gamma}(\tau)\bigr)\;\big|\;\mathrm{Rotate}\bigl(R(\tau)\bigr), \]

evaluated left-to-right (translate, then rotate), so applying \(F(\tau)\) to a point \(\mathbf{p}\) gives exactly the forward-transform formula:

\[ \text{act}(F(\tau), \mathbf{p}) = R(\tau)\bigl(\mathbf{p} - \boldsymbol{\gamma}(\tau)\bigr). \]

The FrenetSerretBuilder itself is wrapped in a coordinax.transforms.TimeDep, TimeDep(F), which is what act(op, tau, p) actually dispatches on: act calls F(tau) to evaluate the rigid-body transform, then applies it.

Inversion is generic, not builder-specific. TimeDep(F).inverse does not construct a second FrenetSerretBuilder with swapped fields; it wraps F in a pointwise-inverse combinator whose __call__(tau) returns F(tau).inverse. Inverting the composed Translate(-\gamma) | Rotate(R) reverses order and inverts each factor:

\[ F(\tau)^{-1} = \mathrm{Rotate}\bigl(R(\tau)\bigr)^{-1} \;\big|\; \mathrm{Translate}\bigl(-\boldsymbol{\gamma}(\tau)\bigr)^{-1} = \mathrm{Rotate}\bigl(R^T(\tau)\bigr) \;\big|\; \mathrm{Translate}\bigl(\boldsymbol{\gamma}(\tau)\bigr), \]

which applied to \(\mathbf{p}'\) (rotate, then translate) gives exactly \(R^T(\tau)\,\mathbf{p}' + \boldsymbol{\gamma}(\tau)\) — the inverse transform above. Because this combinator is an involution, TimeDep(F).inverse.inverse.builder is F — no closure chain accumulates no matter how many times .inverse is taken.

Frenet–Serret Reference Frame#

A Frenet–Serret reference frame \(\mathcal{F}_\gamma\) is a curve-attached reference frame defined relative to an ambient base frame \(\mathcal{B}\). At each parameter value \(\tau\), the frame is centred at \(\boldsymbol{\gamma}(\tau)\) with oriented axes \((\mathbf{T}, \mathbf{N}, \mathbf{B})\).

The frame transition from the base frame to the curve frame is the forward Frenet–Serret transform:

\[ \mathcal{B} \xrightarrow{F(\tau)} \mathcal{F}_\gamma. \]

The reverse transition is its inverse:

\[ \mathcal{F}_\gamma \xrightarrow{F^{-1}(\tau)} \mathcal{B}. \]

Composition with other frames. Given an arbitrary frame \(\mathcal{A}\) with a known transition to \(\mathcal{B}\), the transition from \(\mathcal{A}\) to \(\mathcal{F}_\gamma\) composes:

\[ \mathcal{A} \to \mathcal{F}_\gamma = (\mathcal{A} \to \mathcal{B}) \circ (\mathcal{B} \to \mathcal{F}_\gamma). \]

The evolution parameter \(\tau\) is not stored on the frame object. It is supplied at evaluation time when the frame transition operator is applied to coordinates via act(op, tau, x).

Bishop Frame#

The Bishop frame (also called the rotation-minimising frame or parallel-transport frame) attaches an orthonormal triad \((\mathbf{T}, \mathbf{U}_1, \mathbf{U}_2)\) to each point of a regular curve \(\boldsymbol{\gamma}(\tau)\). Unlike the Frenet–Serret frame, it is well-defined even when the curvature vanishes (\(\kappa = 0\)), because it does not depend on \(\boldsymbol{\gamma}''\).

Definition via Parallel Transport#

Given a regular curve with unit tangent \(\mathbf{T}(\tau) = \boldsymbol{\gamma}'/\lVert\boldsymbol{\gamma}'\rVert\), choose an initial orthonormal pair \(\mathbf{U}_1(\tau_0), \mathbf{U}_2(\tau_0)\) in the normal plane at \(\tau_0\) (i.e.\ perpendicular to \(\mathbf{T}(\tau_0)\)).

The Bishop frame vectors \(\mathbf{U}_1(\tau), \mathbf{U}_2(\tau)\) are the unique solutions of the parallel-transport ODE:

\[ \frac{d\mathbf{U}_i}{d\tau} = -\bigl(\mathbf{U}_i \cdot \mathbf{T}'\bigr)\,\mathbf{T}, \qquad i \in \{1, 2\}, \]

subject to the initial conditions \(\mathbf{U}_i(\tau_0) = \mathbf{U}_{i,0}\).

Equivalently, at each point the derivative of \(\mathbf{U}_i\) is its projection onto the tangent direction, negated — this keeps \(\mathbf{U}_i\) perpendicular to \(\mathbf{T}\) while minimising rotation.

Choosing the Initial Normal#

When the user does not supply an explicit initial normal, one is selected automatically via Gram–Schmidt: pick the standard basis vector \(\mathbf{e}_k\) (\(k \in \{0,1,2\}\)) that is least aligned with \(\mathbf{T}(\tau_0)\), i.e.
\(k = \arg\min_j\, |\mathbf{T}(\tau_0) \cdot \mathbf{e}_j|\). Then project out the tangent component and normalise:

\[ \mathbf{U}_{1,0} = \frac{\mathbf{e}_k - (\mathbf{e}_k \cdot \mathbf{T}_0)\,\mathbf{T}_0} {\lVert \mathbf{e}_k - (\mathbf{e}_k \cdot \mathbf{T}_0)\,\mathbf{T}_0 \rVert}, \qquad \mathbf{U}_{2,0} = \mathbf{T}_0 \times \mathbf{U}_{1,0}. \]

Properties#

For every \(\tau\):

  1. Orthonormality: \(\mathbf{T} \cdot \mathbf{U}_1 = \mathbf{T} \cdot \mathbf{U}_2 = \mathbf{U}_1 \cdot \mathbf{U}_2 = 0\) and \(\lVert\mathbf{T}\rVert = \lVert\mathbf{U}_1\rVert = \lVert\mathbf{U}_2\rVert = 1\).

  2. Right-handedness: \(\mathbf{U}_2 = \mathbf{T} \times \mathbf{U}_1\).

  3. Non-singularity: The frame is defined for every regular curve, including at points where \(\kappa = 0\). (In contrast, the Frenet–Serret normal is singular at such points.)

  4. Rotation-minimising: The angular velocity of the frame about the tangent is zero — the normal-plane vectors do not twist around \(\mathbf{T}\).

Relation to the Frenet–Serret Frame#

When \(\kappa(\tau) \neq 0\), a Bishop frame and the Frenet–Serret frame are related by a \(\tau\)-dependent rotation \(\theta(\tau)\) in the normal plane:

\[\begin{split} \begin{pmatrix} \mathbf{N} \\ \mathbf{B} \end{pmatrix} = \begin{pmatrix} \cos\theta & \sin\theta \\ -\sin\theta & \cos\theta \end{pmatrix} \begin{pmatrix} \mathbf{U}_1 \\ \mathbf{U}_2 \end{pmatrix}. \end{split}\]

The Bishop frame is the unique frame in this family for which \(d\theta/d\tau = 0\) (no torsion-induced twist).

Signed Planar Frame#

The signed planar frame attaches an orthonormal triad \((\mathbf{T}, \mathbf{N}, \mathbf{B})\) to each point of a regular planar curve \(\boldsymbol{\gamma}(\tau)\) lying in the plane normal to a fixed unit vector \(\hat{n}\):

\[ \mathbf{T} = \frac{\boldsymbol{\gamma}'}{\|\boldsymbol{\gamma}'\|}, \qquad \mathbf{N} = \frac{\hat{n} \times \mathbf{T}}{\|\hat{n} \times \mathbf{T}\|}, \qquad \mathbf{B} = \mathbf{T} \times \mathbf{N}. \]

\(\mathbf{B} = \hat{n}\) on a planar curve, which is the only case this frame is defined for; it is written as \(\mathbf{T} \times \mathbf{N}\) rather than as \(\hat{n}\) because that is what the implementation computes, and computing it is what keeps the triad exactly orthonormal for a curve that drifts slightly off the plane.

Unlike the Frenet–Serret frame it is defined where the curvature vanishes, because \(\mathbf{N}\) is not derived from \(\boldsymbol{\gamma}''\): the gauge comes from the ambient plane. Unlike the Bishop frame it needs neither an initial normal nor an ODE solve, at the cost of requiring a plane.

The signed curvature is

\[ \kappa_s = \frac{(\boldsymbol{\gamma}' \times \boldsymbol{\gamma}'') \cdot \hat{n}}{\|\boldsymbol{\gamma}'\|^3}, \]

fixed in sign by \(d\mathbf{T}/ds = \kappa_s \mathbf{N}\). It is positive where the curve turns towards \(\mathbf{N}\), negative where it turns away, and passes smoothly through zero at an inflection. Where the Frenet–Serret frame is also defined, \(\mathbf{N}\) agrees with the Frenet normal up to a sign and \(|\kappa_s| = \kappa\).

\(\hat{n}\) is supplied, not inferred: every quantity derivable from \(\boldsymbol{\gamma}''\) vanishes precisely at the points this frame exists to cover. A curve whose tangent leaves the plane normal to \(\hat{n}\) is refused, at a tolerance of \(\sqrt{\varepsilon}\) in the working precision, rather than silently projected.

Bishop Transform#

The Bishop frame defines a \(\tau\)-dependent rigid-body transform (translation + rotation) between the ambient Cartesian frame and the curve-attached frame, with the same algebraic structure as the Frenet–Serret transform.

Forward Transform#

At each \(\tau\), define the rotation matrix

\[\begin{split} R(\tau) = \begin{pmatrix} \mathbf{T}(\tau)^T \\ \mathbf{U}_1(\tau)^T \\ \mathbf{U}_2(\tau)^T \end{pmatrix} \in SO(3). \end{split}\]

The forward transform maps an ambient point \(\mathbf{p}\) to curve-frame coordinates:

\[ \mathbf{p}' = R(\tau)\bigl(\mathbf{p} - \boldsymbol{\gamma}(\tau)\bigr). \]

Inverse Transform#

Since \(R \in SO(3)\), \(R^{-1} = R^T\) and the inverse transform is:

\[ \mathbf{p} = R^T(\tau)\,\mathbf{p}' + \boldsymbol{\gamma}(\tau). \]

Double-inverse identity. \((R^T)^T = R\), so \(\bigl(B^{-1}\bigr)^{-1} = B\).

Applying the Transform#

Exactly as for FrenetSerretBuilder (Curvature and torsion): a BishopBuilder stores the curve \(\boldsymbol{\gamma}\) (and tau_unit, tau_0, normal_0), not \(\mathbf{T}, \mathbf{U}_1, \mathbf{U}_2\) as separate fields, and builds

\[ B(\tau) = \mathrm{Translate}\bigl(-\boldsymbol{\gamma}(\tau)\bigr)\;\big|\;\mathrm{Rotate}\bigl(R(\tau)\bigr), \qquad R = [\mathbf{T};\,\mathbf{U}_1;\,\mathbf{U}_2] \text{ (rows)} \]

on demand, wrapped in TimeDep(B). The uniform act formula is identical in form to the Frenet–Serret case:

\[ \text{act}(B(\tau), \mathbf{p}) = R(\tau)\bigl(\mathbf{p} - \boldsymbol{\gamma}(\tau)\bigr). \]

Inversion is the same generic TimeDep pointwise-inverse combinator described for FrenetSerretBuilder — there is no separate BishopBuilder for the inverse direction, and TimeDep(B).inverse.inverse.builder is B.

Bishop Reference Frame#

A Bishop reference frame \(\mathcal{B}_\gamma\) is a curve-attached reference frame defined relative to an ambient base frame \(\mathcal{B}\). At each parameter value \(\tau\), the frame is centred at \(\boldsymbol{\gamma}(\tau)\) with oriented axes \((\mathbf{T}, \mathbf{U}_1, \mathbf{U}_2)\).

Frame transitions and composition rules are identical in structure to the Frenet–Serret case:

\[ \mathcal{B} \xrightarrow{B(\tau)} \mathcal{B}_\gamma, \qquad \mathcal{B}_\gamma \xrightarrow{B^{-1}(\tau)} \mathcal{B}. \]

The evolution parameter \(\tau\) is not stored on the frame object. It is supplied at evaluation time via act(op, tau, x).


The Software#

Overview#

The public API lives under coordinaxs.curveframes (typically imported as import coordinaxs.curveframes as cxfc).

Symbol

Kind

Description

AbstractParallelTransportFrame

abstract

Base class for curve-attached orthonormal frames

AbstractCurveFrameBuilder

abstract

equinox.Module builder ABC: tau -> Translate(-gamma) | Rotate(R)

FrenetSerretBuilder

@final

Builder for the \((\mathbf{T},\mathbf{N},\mathbf{B})\) triad

FrenetSerretFrame

@final

Frenet–Serret curve-attached reference frame

BishopBuilder

@final

Builder for the \((\mathbf{T},\mathbf{U}_1,\mathbf{U}_2)\) triad

BishopFrame

@final

Bishop (rotation-minimising) curve frame

SignedPlanarBuilder

@final

Builder for the signed planar \((\mathbf{T},\mathbf{N},\mathbf{B})\) triad

SignedPlanarFrame

@final

Signed planar curve frame; planar curves only

Every curve frame is built from a coordinax.transforms.TimeDep wrapping one of these builders — the same single mechanism for time dependence used everywhere else in coordinax.transforms (see TimeDep in the root spec). AbstractCurveFrameBuilder is an equinox.Module, so every field is a genuine pytree leaf: differentiable and vmap-able, including the curve’s own parameters when the curve is itself an equinox.Module.

!!! info AbstractParallelTransportFrame

Abstract base class for curve-attached orthonormal frames in 3D.

Inherits from `coordinax.frames.AbstractTransformedReferenceFrame[FrameT]` and therefore carries three fields:

- `base_frame : FrameT` — the ambient reference frame relative to which the curve frame is defined.
- `xop : TimeDep` — the forward transform (base frame → curve frame), wrapping an `AbstractCurveFrameBuilder`.
- `xop_inv : TimeDep` — the pre-computed inverse of `xop` (curve frame → base frame).

`AbstractParallelTransportFrame` is **not instantiable directly**; concrete subclasses (e.g. `FrenetSerretFrame`, `BishopFrame`, `SignedPlanarFrame`) must be `@final`.

Because `AbstractParallelTransportFrame` IS-A `AbstractTransformedReferenceFrame`, the generic `frame_transition` dispatches registered for `AbstractTransformedReferenceFrame` apply automatically. No additional frame-transition dispatches are needed for concrete curve-frame subclasses.

The evolution parameter $\tau$ is **not** stored on the frame object. It is supplied at evaluation time when a frame-transition operator is applied to coordinates via `act(op, tau, x)`.

!!! info AbstractCurveFrameBuilder

Abstract `equinox.Module` base class for curve-frame builders: `tau -> Translate(-gamma) | Rotate(R)`. This is what a `TimeDep` wraps; it is not itself a `coordinax.transforms.AbstractTransform`.

Fields (declared `eqx.AbstractVar`, defined by concrete subclasses):

- `curve : Callable[[Any], Any]` — the curve $\gamma \mapsto \boldsymbol{\gamma}(\gamma)$, mapping a parameter `Quantity` to a Cartesian 3-vector `Quantity`. A pytree leaf: make `curve` itself an `equinox.Module` to get differentiable/vmappable curve parameters.
- `tau_unit : unxt.AbstractUnit | None` — physical unit of the curve parameter. **Static**: it selects the differentiation units, not a numeric value. `None` (the default) reads the unit off the parameter the builder is called with -- the station when one is pinned, otherwise the call-time $\tau$. Declare it for a curve that reads its argument's `.value` rather than converting, or for a raw (unitless) parameter.
- `gamma : Any` — an optional *fixed* curve parameter. When `None` (the default), $\tau$ itself is the curve parameter — the classic moving-frame usage. When set, the frame sits at the fixed point $\boldsymbol{\gamma}(\gamma)$ and is $\tau$-independent: a frame *field* along the curve, differentiable and `vmap`-able in `gamma`.

Methods:

- `rotation_matrix(tau) -> Array` — **abstract**, implemented by concrete subclasses; the $3\times3$ rotation matrix $R$ whose rows are the frame vectors.
- `__call__(tau) -> Composed` — builds `Translate(-gamma(param)) | Rotate(rotation_matrix(tau))`, where `param` is `tau` or the fixed `station`.
- `location(tau)`, `tangent(tau)` — convenience accessors; `location` evaluates $\boldsymbol{\gamma}$ at the resolved parameter, `tangent` returns row 0 of `rotation_matrix(tau)`.

!!! info FrenetSerretBuilder

A `@final` subclass of `AbstractCurveFrameBuilder` computing the $(\mathbf{T}, \mathbf{N}, \mathbf{B})$ triad.

Fields (in addition to the ABC's `curve`, `tau_unit`, `station`): none — `FrenetSerretBuilder` adds no fields beyond the base class.

- `curve : Callable[[Any], Any]` — the constructing curve.
- `tau_unit : unxt.AbstractUnit | None` — unit of the curve parameter, used by `unxt.experimental.jacfwd` to compute unit-correct derivatives. Static. `None` (the default) reads the unit off the parameter the builder is called with -- the station when one is pinned, otherwise the call-time $\tau$. Declare it for a curve that reads its argument's `.value` rather than converting, or for a raw (unitless) parameter.
- `station : Any` — optional fixed curve parameter (a leaf); see `AbstractCurveFrameBuilder`.

`rotation_matrix(tau)` computes $R = [\mathbf{T}; \mathbf{N}; \mathbf{B}]$: unit-aware first and second derivatives of `curve` via `unxt.experimental.jacfwd`, then $\mathbf{T} = \gamma'/\lVert\gamma'\rVert$, Gram–Schmidt rejection of $\gamma''$ onto $\mathbf{T}$ normalised to give $\mathbf{N}$, and $\mathbf{B} = \mathbf{T}\times\mathbf{N}$.

Convenience accessors: `normal(tau)` (row 1), `binormal(tau)` (row 2); `location(tau)`, `tangent(tau)` are inherited from `AbstractCurveFrameBuilder`.

Invariants: `curvature(tau) -> Quantity[1/length]` and `torsion(tau) -> Quantity[1/length]`. Both are separate accessors so `rotation_matrix` never pays for them; `curvature` costs the same two `jacfwd` passes as `rotation_matrix`, `torsion` a third. `curvature` is **unguarded** — it is defined wherever the curve is regular, including where the frame is not — while `torsion` is guarded at $\kappa = 0$, where its denominator vanishes.

Constructed directly — `FrenetSerretBuilder(curve, tau_unit=None, station=None)` — there is no `from_curve`/`from_` classmethod on the builder; that convenience lives on `FrenetSerretFrame`.

JAX compatibility: `FrenetSerretBuilder` is an `equinox.Module`, so it is a valid pytree. `curve`, `station` are dynamic leaves (differentiable, `vmap`-able); `tau_unit` is static. `rotation_matrix` and `__call__` operate on scalar $\tau$; batching is via `jax.vmap`. A plain `jax.jit` cannot hash a builder holding array leaves (e.g. an `equinox.Module` curve with array fields, or a `station`); use `eqx.filter_jit` in that case.

`act` dispatches on `TimeDep(FrenetSerretBuilder(...))`, not on the builder directly — see {ref}`TimeDep <software-spec-transforms-timedep>` in the root spec. `act(TimeDep(F), tau, x)` evaluates `F(tau)` and applies the resulting `Composed` transform.

!!! info FrenetSerretFrame

A `@final` subclass of `AbstractParallelTransportFrame[FrameT]` representing a Frenet–Serret curve-attached reference frame.

Fields (all inherited):

- `base_frame : FrameT` — the ambient reference frame (e.g. `Alice()`).
- `xop : TimeDep` — the $\tau$-dependent rigid-body transform from the base frame to the curve frame, wrapping a `FrenetSerretBuilder`.
- `xop_inv : TimeDep` — its pre-computed inverse, `xop.inverse`.

At evaluation time, the evolution parameter $\tau$ is passed via `act(op, tau, x)`, not stored on the frame.

Constructors:

- `FrenetSerretFrame(base_frame, xop, xop_inv)` — direct construction from a base frame and a `TimeDep`-wrapped `FrenetSerretBuilder` (forward and inverse).
- `from_curve(base_frame, curve, /, tau_unit=None, *, station=None)` — convenience constructor that builds `FrenetSerretBuilder(curve, tau_unit, station)`, wraps it in `TimeDep`, and sets `xop_inv = xop.inverse`.

Frame transitions:

- Handled entirely by the generic `AbstractTransformedReferenceFrame` dispatches in `coordinax.frames`.
- `frame_transition(base, fs_frame)` returns `(base → base_frame) | xop`.
- `frame_transition(fs_frame, base)` returns `xop_inv | (base_frame → base)`.
- `frame_transition(fs_frame_1, fs_frame_2)` composes through both base frames.

Usage pattern:

```python
import jax.numpy as jnp
import unxt as u
import coordinax.frames as cxf
import coordinax.transforms as cxfm
import coordinaxs.curveframes as cxfc


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)]), "m")


fs_frame = cxfc.FrenetSerretFrame.from_curve(cxf.Alice(), circle, "s")
op = cxf.frame_transition(cxf.Alice(), fs_frame)
tau = u.Q(0.0, "s")
p_ambient = u.Q(jnp.array([1.0, 0.0, 0.0]), "m")
p_curve = cxfm.act(op, tau, p_ambient)
```

!!! info BishopBuilder

A `@final` subclass of `AbstractCurveFrameBuilder` computing the $(\mathbf{T}, \mathbf{U}_1, \mathbf{U}_2)$ triad via parallel transport.

Fields (the ABC's `curve`, `tau_unit`, `station`, plus two more):

- `curve : Callable[[Any], Any]` — the constructing curve.
- `tau_unit : unxt.AbstractUnit | None` — unit of the curve parameter. Static. `None` (the default) reads the unit off the parameter the builder is called with -- the station when one is pinned, otherwise the call-time $\tau$. Declare it for a curve that reads its argument's `.value` rather than converting, or for a raw (unitless) parameter.
- `station : Any` — optional fixed curve parameter (a leaf); see `AbstractCurveFrameBuilder`.
- `tau_0 : unxt.AbstractQuantity | None` — reference parameter where the initial frame is defined (a leaf). `None` is resolved to `Q(0.0, tau_unit)` by `__post_init__` when the unit is declared, and at call time when it is being inferred. Only the declared case leaves it a pytree leaf; pass `tau_0` explicitly to differentiate through it otherwise.
- `normal_0 : Any` — initial $\mathbf{U}_{1,0}$ (dimensionless 3-vector, a leaf), or `"auto"` for the world-axis rule (Gram–Schmidt); **required**.
- `diffeqsolver : diffraxtra.DiffEqSolver` — the whole `diffrax` configuration (solver, step-size controller, adjoint, step budget) in one **static** field (hashable, contributes no array leaves, so the builder's pytree stays about the curve). Defaults below.

`rotation_matrix(tau)` computes $R = [\mathbf{T}; \mathbf{U}_1; \mathbf{U}_2]$: $\mathbf{T}$ from the unit-aware first derivative of `curve`; $\mathbf{U}_1$ by solving the parallel-transport ODE $d\mathbf{U}_1/d\tau = -(\mathbf{U}_1\cdot\mathbf{T}')\,\mathbf{T}$ from `tau_0` to the resolved parameter via the `diffeqsolver` (defaulting to `DiffEqSolver(Tsit5(), PIDController(rtol=1e-10, atol=1e-10), DirectAdjoint(), max_steps=16384)`); $\mathbf{U}_2 = \mathbf{T}\times\mathbf{U}_1$.

The solve is reparametrized onto $s \in [0, 1]$ with $\tau(s) = \tau_0 + s\,(\tau - \tau_0)$, so $\tau$ enters through the vector field rather than the integration bound. This makes backward transport ($\tau < \tau_0$) and the degenerate $\tau = \tau_0$ fall out of the same expression, and — critically — keeps $d/d\tau$ of the solve non-zero at $\tau = \tau_0$. `DirectAdjoint` is the default because it is the only `diffrax` adjoint differentiable in *both* modes, and forward mode is what `act` on tangent data and `act_jet` need. A caller may substitute another adjoint; `RecursiveCheckpointAdjoint` and `ForwardMode` each drop one of the two modes, and `BacksolveAdjoint` drops both (its backwards solve cannot carry the reparametrised right-hand side's closed-over $\Delta\tau$ and $\tau_0$, so it raises `CustomVJPException` regardless of how the curve is written) — see the guide's *Configuring the Solve* table. Because `DiffEqSolver`'s own field defaults come off `diffrax.diffeqsolve`'s signature (adjoint `RecursiveCheckpointAdjoint`), the documented way to change one knob is `dataclasses.replace` on the builder's existing `diffeqsolver`, which preserves the other three; `equinox.tree_at` reaches into the `DiffEqSolver` but not through the builder, whose field is static and so not a leaf.

Convenience accessors: `normal1(tau)` (row 1), `normal2(tau)` (row 2); `location(tau)`, `tangent(tau)` inherited.

`rotation_matrices(taus)` evaluates a **1-D batch** of parameters from a *single* ODE solve, using `diffrax.SaveAt` on the already-rescaled $s \in [0, 1]$ interval. It returns `(N, 3, 3)`, matching `rotation_matrix` element for element, and is the preferred route when every $\tau$ lies on one side of `tau_0` — the gain scales with $N$ (~2.9x at 8 parameters, ~8.9x at 64, jitted). Every $\tau$ **must** share a side of `tau_0`: the transport marches outward in one monotonic sweep, so a straddling set is refused via `equinox.error_if` rather than silently split. Use `jax.vmap(rotation_matrix)` when the parameters straddle `tau_0`, when the batch is higher-rank, or on `FrenetSerretBuilder`, which has no batched accessor. A pinned `station` makes every $\tau$ name the same frame, so a batched $\tau$ is accepted there and answered by one solve.

Constructed directly — `BishopBuilder(curve, tau_unit=None, station=None, tau_0=None, normal_0=None, diffeqsolver=DiffEqSolver(Tsit5(), PIDController(1e-10, 1e-10), DirectAdjoint(), max_steps=16384))`. The `None` default on `normal_0` is a dataclass declaration, not a permitted value: `__check_init__` rejects it, so the argument is **required** — a dimensionless 3-vector, or `"auto"` for the world-axis rule. Omitting it raises, because the seed is the n-plane gauge and nothing in the curve fixes it. In practice: `BishopBuilder(curve, "s", normal_0="auto")`, or `BishopBuilder(curve, "s", normal_0=jnp.asarray([0.0, 0.0, 1.0]))`. There is no `from_curve`/`from_` classmethod on the builder; that convenience lives on `BishopFrame`.

JAX compatibility: same as `FrenetSerretBuilder` — `curve`, `station`, `tau_0`, `normal_0` are dynamic leaves; `tau_unit` and `diffeqsolver` are static. `rotation_matrix` and `__call__` operate on scalar $\tau$; batching is via `jax.vmap` or, for a same-side batch, `rotation_matrices`. A plain `jax.jit` cannot hash a builder holding array leaves; use `eqx.filter_jit`.

`act` dispatches on `TimeDep(BishopBuilder(...))`, identically to `FrenetSerretBuilder`.

!!! info BishopFrame

A `@final` subclass of `AbstractParallelTransportFrame[FrameT]` representing a Bishop (rotation-minimising) curve-attached reference frame.

Fields (all inherited):

- `base_frame : FrameT` — the ambient reference frame (e.g. `Alice()`).
- `xop : TimeDep` — the $\tau$-dependent rotation-minimising transform from the base frame to the curve frame, wrapping a `BishopBuilder`.
- `xop_inv : TimeDep` — its pre-computed inverse, `xop.inverse`.

Constructors:

- `BishopFrame(base_frame, xop, xop_inv)` — direct construction.
- `from_curve(base_frame, curve, /, tau_unit=None, *, station=None, tau_0=None, normal_0)` — `normal_0` is keyword-only and **required**, so omission is caught by the call signature — convenience constructor that builds `BishopBuilder(curve, tau_unit, station, tau_0, normal_0)`, wraps it in `TimeDep`, and sets `xop_inv = xop.inverse`.

Frame transitions:

- Handled by the generic `AbstractParallelTransportFrame` dispatches (same as FrenetSerretFrame).

Usage pattern:

```python
import jax.numpy as jnp
import unxt as u
import coordinax.frames as cxf
import coordinax.transforms as cxfm
import coordinaxs.curveframes as cxfc


def curve(tau: u.Q) -> u.Q:
    t = tau.ustrip("s")
    return u.Q(jnp.stack([jnp.cos(t), jnp.sin(t), 0.3 * t]), "m")


b_frame = cxfc.BishopFrame.from_curve(cxf.Alice(), curve, "s", normal_0="auto")
op = cxf.frame_transition(cxf.Alice(), b_frame)
tau = u.Q(0.0, "s")
p_ambient = u.Q(jnp.array([1.0, 0.0, 0.0]), "m")
p_curve = cxfm.act(op, tau, p_ambient)
```

!!! info SignedPlanarBuilder

A `@final` `equinox.Module` subclass of `AbstractCurveFrameBuilder` carrying the signed planar apparatus.

Constructed directly — `SignedPlanarBuilder(curve, tau_unit=None, station=None, plane_normal=None)` — there is no `from_curve`/`from_` classmethod on the builder; that convenience lives on `SignedPlanarFrame`.

`plane_normal` is a pytree leaf, so it is differentiable and vmappable; `None` means the z-axis, and the vector need not be normalised.

Beyond the shared accessors it provides `signed_curvature(tau) -> Quantity[1/length]` and `curvature_vector(tau) -> Quantity[1/length]`. Unlike `FrenetSerretBuilder.curvature`, `signed_curvature` carries a sign and so passes smoothly through an inflection rather than only reaching zero there; on a planar curve the two agree in magnitude.

`act` dispatches on `TimeDep(SignedPlanarBuilder(...))`, identically to `FrenetSerretBuilder`.

!!! info SignedPlanarFrame

A `@final` subclass of `AbstractParallelTransportFrame[FrameT]` representing a signed planar curve-attached reference frame.

- `base_frame : FrameT` — the ambient reference frame.
- `xop : TimeDep` — the $\tau$-dependent transform from the base frame to the curve frame, wrapping a `SignedPlanarBuilder`.
- `xop_inv : TimeDep` — its inverse.

`frame_transition` needs no registration of its own: the dispatches are on `AbstractParallelTransportFrame`.