Skip to content

Trajectories

shinro.trajectories

Reference path generators for smooth point-to-point motion.

Provides polynomial and harmonic trajectory generators that compute position, velocity, and acceleration profiles from boundary conditions. All generators support arbitrary N-dimensional positions via numpy broadcasting.

Available generators: CubicPolynomial — 3rd-order, position + velocity continuity QuinticPolynomial — 5th-order, position + velocity + acceleration continuity Lissajous — odd-harmonic rest-to-rest figure, orientable via R BezierCurve — Bernstein curve over an arbitrary control-point list BSpline — Cox–de Boor B-spline over a knot vector and control polygon CatmullRom — C¹ cubic Hermite through an ordered waypoint list Akima — overshoot-resistant C¹ waypoint spline CubicSpline — natural C² waypoint spline MinSnapPolynomial — 7th-order (minimum-snap) point-to-point CircularArc — analytic circular / helical arc

Sampling helpers: sample_schedule / sample_segments — stacked (steps, N) position/velocity/ acceleration schedules (the derivatives config opt-in uses them).

Motion limits: limit_trajectory / time_scale_factor — uniform (constant) time scaling: the same path traversed k times slower, for per-component velocity / acceleration limits (TimeScaled). s_curve_limit / s_curve_horizon — min-jerk (S-curve) rest-to-rest time law that also bounds jerk (SCurveScaled).


CubicPolynomial

Bases: TrajectoryGenerator

3rd-order polynomial trajectory generator.

Generates smooth point-to-point trajectories using a cubic polynomial:

\[ p(t) = a_0 + a_1 t + a_2 t^2 + a_3 t^3 \]

The coefficients are computed in closed form (no matrix solve) from boundary conditions on position and velocity at both ends. Acceleration is continuous but NOT constrained at boundaries (use QuinticPolynomial if acceleration constraints are needed).

Supports arbitrary N-dimensional positions via element-wise operations.

Parameters:

Name Type Description Default
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/cubic_polynomial.py
def __init__(self, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()

generate

generate(start_position, end_position, duration: float, start_vel, end_vel)

Compute cubic polynomial coefficients from boundary conditions.

Closed-form solution:

\[ a_0 &= p_0 \\ a_1 &= v_0 \\ a_2 &= \frac{3\Delta p - T(2v_0 + v_f)}{T^2} \\ a_3 &= \frac{-2\Delta p + T(v_0 + v_f)}{T^3} \]

where \(\Delta p = p_f - p_0\) and \(T =\) duration.

Parameters:

Name Type Description Default
start_position

Initial position vector (N,).

required
end_position

Final position vector (N,).

required
duration float

Total trajectory time in seconds.

required
start_vel

Initial velocity vector (N,).

required
end_vel

Final velocity vector (N,).

required
Source code in src/shinro/trajectories/cubic_polynomial.py
def generate(
    self,
    start_position,
    end_position,
    duration: float,
    start_vel,
    end_vel,
):
    """Compute cubic polynomial coefficients from boundary conditions.

    Closed-form solution:

    .. math::

        a_0 &= p_0 \\\\
        a_1 &= v_0 \\\\
        a_2 &= \\frac{3\\Delta p - T(2v_0 + v_f)}{T^2} \\\\
        a_3 &= \\frac{-2\\Delta p + T(v_0 + v_f)}{T^3}

    where :math:`\\Delta p = p_f - p_0` and :math:`T =` duration.

    Args:
        start_position: Initial position vector (N,).
        end_position: Final position vector (N,).
        duration: Total trajectory time in seconds.
        start_vel: Initial velocity vector (N,).
        end_vel: Final velocity vector (N,).
    """
    self.a0 = start_position
    self.a1 = start_vel
    a2_numerator = 3 * (end_position - start_position) - duration * (2 * start_vel + end_vel)
    self.a2 = a2_numerator / (duration ** 2)

    a3_numerator = -2 * (end_position - start_position) + duration * (start_vel + end_vel)
    self.a3 = a3_numerator / (duration ** 3)

    self.duration = duration

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to [0, duration]).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each of

shape matching the input dimensions (N,).

Source code in src/shinro/trajectories/cubic_polynomial.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to [0, duration]).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each of
        shape matching the input dimensions (N,).
    """
    t = self.bk.clip(t, 0, self.duration)
    pos = self.a0 + self.a1 * t + self.a2 * t ** 2 + self.a3 * t ** 3
    vel = self.a1 + 2 * self.a2 * t + 3 * self.a3 * t ** 2
    acc = 2 * self.a2 + 6 * self.a3 * t
    return pos, vel, acc

from_config classmethod

from_config(config, backend: ArrayBackend | None = None) -> Any

Create a waypoint schedule from a TOML config dict or CubicSegmentsConfig.

Config fields

dt: Time step. segments: List of segment dicts, each with: - duration: Segment duration (s). - start: Start position list. - end: End position list. - start_vel: Optional start velocity (default: zeros). - end_vel: Optional end velocity (default: zeros).

Parameters:

Name Type Description Default
config

TOML config dict or CubicSegmentsConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description
Any

Array of shape (total_steps, N) with position waypoints, or — when

Any

derivatives is true — a dict of (total_steps, N) arrays

Any

under "position", "velocity", and "acceleration".

Source code in src/shinro/trajectories/cubic_polynomial.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None) -> Any:
    """Create a waypoint schedule from a TOML config dict or :class:`CubicSegmentsConfig`.

    Config fields:
        dt: Time step.
        segments: List of segment dicts, each with:
            - duration: Segment duration (s).
            - start: Start position list.
            - end: End position list.
            - start_vel: Optional start velocity (default: zeros).
            - end_vel: Optional end velocity (default: zeros).

    Args:
        config: TOML config dict or CubicSegmentsConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        Array of shape (total_steps, N) with position waypoints, or — when
        ``derivatives`` is true — a dict of ``(total_steps, N)`` arrays
        under ``"position"``, ``"velocity"``, and ``"acceleration"``.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    segs = strict_from_list(CubicSegmentConfig, cfg.segments, "cubic_segments.segment")
    segments = []
    for seg in segs:
        p0 = bk.array(seg.start)
        pf = bk.array(seg.end)
        start_vel = bk.array(seg.start_vel if seg.start_vel is not None else [0.0] * len(seg.start))
        end_vel = bk.array(seg.end_vel if seg.end_vel is not None else [0.0] * len(seg.end))
        traj = cls(backend=bk)
        traj.generate(p0, pf, seg.duration, start_vel, end_vel)
        segments.append((traj, seg.duration))
    sampled = sample_segments(segments, cfg.dt, order=2 if cfg.derivatives else 0)
    return sampled if cfg.derivatives else sampled["position"]

QuinticPolynomial

Bases: TrajectoryGenerator

5th-order polynomial trajectory generator.

Generates smooth point-to-point trajectories using a quintic polynomial:

\[ p(t) = a_5 t^5 + a_4 t^4 + a_3 t^3 + a_2 t^2 + a_1 t + a_0 \]

Enforces position, velocity, AND acceleration constraints at both start and end (6 boundary conditions → 6 coefficients). When all velocities and accelerations are zero (rest-to-rest), this reduces to the minimum-jerk trajectory:

\[ p(s) = p_0 + (p_f - p_0)(10s^3 - 15s^4 + 6s^5), \quad s = t/T \]

Solves a 6x6 Vandermonde-like linear system via bk.solve(). Supports arbitrary N-dimensional positions — the right-hand side is stacked as (6, N) and solved once.

Parameters:

Name Type Description Default
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/quintic_polynomial.py
def __init__(self, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()

generate

generate(start_position, end_position, duration: float, start_vel=None, end_vel=None, start_acc=None, end_acc=None)

Compute quintic polynomial coefficients by solving the 6x6 system.

Solves \(M c = b\) where:

\[ M = \begin{bmatrix} 0 & 0 & 0 & 0 & 0 & 1 \\ T^5 & T^4 & T^3 & T^2 & T & 1 \\ 0 & 0 & 0 & 0 & 1 & 0 \\ 5T^4 & 4T^3 & 3T^2 & 2T & 1 & 0 \\ 0 & 0 & 0 & 2 & 0 & 0 \\ 20T^3 & 12T^2 & 6T & 2 & 0 & 0 \end{bmatrix}, \quad b = \begin{bmatrix} p_0 \\ p_f \\ v_0 \\ v_f \\ a_0 \\ a_f \end{bmatrix} \]

Any boundary condition set to None defaults to zero (rest-to-rest).

Parameters:

Name Type Description Default
start_position

Initial position vector (N,).

required
end_position

Final position vector (N,).

required
duration float

Total trajectory time in seconds.

required
start_vel

Initial velocity vector (N,). Defaults to zeros.

None
end_vel

Final velocity vector (N,). Defaults to zeros.

None
start_acc

Initial acceleration vector (N,). Defaults to zeros.

None
end_acc

Final acceleration vector (N,). Defaults to zeros.

None
Source code in src/shinro/trajectories/quintic_polynomial.py
def generate(
    self,
    start_position,
    end_position,
    duration: float,
    start_vel=None,
    end_vel=None,
    start_acc=None,
    end_acc=None,
):
    """Compute quintic polynomial coefficients by solving the 6x6 system.

    Solves :math:`M c = b` where:

    .. math::

        M = \\begin{bmatrix}
        0 & 0 & 0 & 0 & 0 & 1 \\\\
        T^5 & T^4 & T^3 & T^2 & T & 1 \\\\
        0 & 0 & 0 & 0 & 1 & 0 \\\\
        5T^4 & 4T^3 & 3T^2 & 2T & 1 & 0 \\\\
        0 & 0 & 0 & 2 & 0 & 0 \\\\
        20T^3 & 12T^2 & 6T & 2 & 0 & 0
        \\end{bmatrix}, \\quad
        b = \\begin{bmatrix} p_0 \\\\ p_f \\\\ v_0 \\\\ v_f \\\\ a_0 \\\\ a_f \\end{bmatrix}

    Any boundary condition set to None defaults to zero (rest-to-rest).

    Args:
        start_position: Initial position vector (N,).
        end_position: Final position vector (N,).
        duration: Total trajectory time in seconds.
        start_vel: Initial velocity vector (N,). Defaults to zeros.
        end_vel: Final velocity vector (N,). Defaults to zeros.
        start_acc: Initial acceleration vector (N,). Defaults to zeros.
        end_acc: Final acceleration vector (N,). Defaults to zeros.
    """
    start_vel = self.bk.zeros_like(start_position) if start_vel is None else start_vel
    start_acc = self.bk.zeros_like(start_position) if start_acc is None else start_acc
    end_vel = self.bk.zeros_like(end_position) if end_vel is None else end_vel
    end_acc = self.bk.zeros_like(end_position) if end_acc is None else end_acc

    self.T = duration
    T = duration
    M = self.bk.array([
        [0, 0, 0, 0, 0, 1],
        [T ** 5, T ** 4, T ** 3, T ** 2, T, 1],
        [0, 0, 0, 0, 1, 0],
        [5 * T ** 4, 4 * T ** 3, 3 * T ** 2, 2 * T, 1, 0],
        [0, 0, 0, 2, 0, 0],
        [20 * T ** 3, 12 * T ** 2, 6 * T, 2, 0, 0],
    ])

    b = [start_position, end_position, start_vel, end_vel, start_acc, end_acc]

    coeff_vectors = self.bk.solve(M, b)

    self.A = coeff_vectors[0]
    self.B = coeff_vectors[1]
    self.C = coeff_vectors[2]
    self.D = coeff_vectors[3]
    self.E = coeff_vectors[4]
    self.F = coeff_vectors[5]

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to [0, T]).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each of

shape matching the input dimensions (N,).

Source code in src/shinro/trajectories/quintic_polynomial.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to [0, T]).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each of
        shape matching the input dimensions (N,).
    """
    t = self.bk.clip(t, 0, self.T)
    pos = self.A * t ** 5 + self.B * t ** 4 + self.C * t ** 3 + self.D * t ** 2 + self.E * t + self.F
    vel = 5 * self.A * t ** 4 + 4 * self.B * t ** 3 + 3 * self.C * t ** 2 + 2 * self.D * t + self.E
    acc = 20 * self.A * t ** 3 + 12 * self.B * t ** 2 + 6 * self.C * t + 2 * self.D
    return pos, vel, acc

MinSnapPolynomial

Bases: TrajectoryGenerator

7th-order polynomial trajectory, minimum snap when rest-to-rest.

\[ p(t) = a_7 t^7 + a_6 t^6 + a_5 t^5 + a_4 t^4 + a_3 t^3 + a_2 t^2 + a_1 t + a_0 \]

Eight boundary conditions — position, velocity, acceleration, and jerk at both ends — fix the eight coefficients with one 8x8 bk.solve. With the free boundary derivatives zero this minimises \(\int (p''')^2\,\mathrm{d}t\) (snap is \(p''''\)), the 7th-order sibling of the minimum-jerk quintic:

\[ p(s) = p_0 + (p_f - p_0)\,(35 s^4 - 84 s^5 + 70 s^6 - 20 s^7), \qquad s = t / T \]

Jerk is only a boundary condition: position_at returns (position, velocity, acceleration) like every other generator (the sampler does not carry a jerk channel).

Parameters:

Name Type Description Default
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/min_snap.py
def __init__(self, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()

generate

generate(start_position, end_position, duration: float, start_vel=None, end_vel=None, start_acc=None, end_acc=None, start_jerk=None, end_jerk=None)

Compute the 7th-order coefficients by solving the 8x8 system.

Any boundary condition set to None defaults to zero (rest-to-rest).

Parameters:

Name Type Description Default
start_position

Initial position vector (N,).

required
end_position

Final position vector (N,).

required
duration float

Total trajectory time in seconds.

required
start_vel, end_vel, start_acc, end_acc, start_jerk, end_jerk

Boundary derivatives (N,) or None for zero.

required

Raises:

Type Description
ValueError

If duration is not positive.

Source code in src/shinro/trajectories/min_snap.py
def generate(
    self,
    start_position,
    end_position,
    duration: float,
    start_vel=None,
    end_vel=None,
    start_acc=None,
    end_acc=None,
    start_jerk=None,
    end_jerk=None,
):
    """Compute the 7th-order coefficients by solving the 8x8 system.

    Any boundary condition set to ``None`` defaults to zero (rest-to-rest).

    Args:
        start_position: Initial position vector (N,).
        end_position: Final position vector (N,).
        duration: Total trajectory time in seconds.
        start_vel, end_vel, start_acc, end_acc, start_jerk, end_jerk:
            Boundary derivatives (N,) or ``None`` for zero.

    Raises:
        ValueError: If ``duration`` is not positive.
    """
    if duration is None or duration <= 0:
        raise ValueError(f"MinSnapPolynomial: duration must be positive, got {duration}")
    bk = self.bk
    start_vel = bk.zeros_like(start_position) if start_vel is None else start_vel
    end_vel = bk.zeros_like(end_position) if end_vel is None else end_vel
    start_acc = bk.zeros_like(start_position) if start_acc is None else start_acc
    end_acc = bk.zeros_like(end_position) if end_acc is None else end_acc
    start_jerk = bk.zeros_like(start_position) if start_jerk is None else start_jerk
    end_jerk = bk.zeros_like(end_position) if end_jerk is None else end_jerk

    self.T = duration
    T = duration
    # Columns are [a_7, a_6, a_5, a_4, a_3, a_2, a_1, a_0]; rows are
    # p, p', p'', p''' evaluated at 0 and T.
    M = bk.array(
        [
            [0, 0, 0, 0, 0, 0, 0, 1],
            [T**7, T**6, T**5, T**4, T**3, T**2, T, 1],
            [0, 0, 0, 0, 0, 0, 1, 0],
            [7 * T**6, 6 * T**5, 5 * T**4, 4 * T**3, 3 * T**2, 2 * T, 1, 0],
            [0, 0, 0, 0, 0, 2, 0, 0],
            [42 * T**5, 30 * T**4, 20 * T**3, 12 * T**2, 6 * T, 2, 0, 0],
            [0, 0, 0, 0, 6, 0, 0, 0],
            [210 * T**4, 120 * T**3, 60 * T**2, 24 * T, 6, 0, 0, 0],
        ]
    )
    rhs = [start_position, end_position, start_vel, end_vel, start_acc, end_acc, start_jerk, end_jerk]
    coeffs = bk.solve(M, rhs)
    self.coeffs = [coeffs[i] for i in range(8)]  # a7, a6, ..., a0

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to \([0, T]\)).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each shape

(N,).

Source code in src/shinro/trajectories/min_snap.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to :math:`[0, T]`).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each shape
        ``(N,)``.
    """
    t = self.bk.clip(t, 0, self.T)
    c7, c6, c5, c4, c3, c2, c1, c0 = self.coeffs
    pos = c7 * t**7 + c6 * t**6 + c5 * t**5 + c4 * t**4 + c3 * t**3 + c2 * t**2 + c1 * t + c0
    vel = 7 * c7 * t**6 + 6 * c6 * t**5 + 5 * c5 * t**4 + 4 * c4 * t**3 + 3 * c3 * t**2 + 2 * c2 * t + c1
    acc = 42 * c7 * t**5 + 30 * c6 * t**4 + 20 * c5 * t**3 + 12 * c4 * t**2 + 6 * c3 * t + 2 * c2
    return pos, vel, acc

from_config classmethod

from_config(config, backend: ArrayBackend | None = None) -> Any

Create a waypoint schedule from a TOML config dict or MinSnapConfig.

Config fields

dt: Sampling period (s). start: Initial position (list of floats). end: Final position (list of floats). duration: Trajectory duration (s). start_vel, end_vel, start_acc, end_acc, start_jerk, end_jerk: Optional boundary derivatives (default: zeros). derivatives: Emit the velocity/acceleration dict instead of positions.

Parameters:

Name Type Description Default
config

TOML config dict or MinSnapConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description
Any

Array of shape (total_steps, N) with position waypoints, or — when

Any

derivatives is true — a dict of (total_steps, N) arrays

Any

under "position", "velocity", and "acceleration".

Source code in src/shinro/trajectories/min_snap.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None) -> Any:
    """Create a waypoint schedule from a TOML config dict or :class:`MinSnapConfig`.

    Config fields:
        dt: Sampling period (s).
        start: Initial position (list of floats).
        end: Final position (list of floats).
        duration: Trajectory duration (s).
        start_vel, end_vel, start_acc, end_acc, start_jerk, end_jerk:
            Optional boundary derivatives (default: zeros).
        derivatives: Emit the velocity/acceleration dict instead of positions.

    Args:
        config: TOML config dict or MinSnapConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        Array of shape (total_steps, N) with position waypoints, or — when
        ``derivatives`` is true — a dict of ``(total_steps, N)`` arrays
        under ``"position"``, ``"velocity"``, and ``"acceleration"``.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)

    def _vec(value):
        return None if value is None else bk.array(value)

    traj = cls(backend=bk)
    traj.generate(
        bk.array(cfg.start),
        bk.array(cfg.end),
        cfg.duration,
        _vec(cfg.start_vel),
        _vec(cfg.end_vel),
        _vec(cfg.start_acc),
        _vec(cfg.end_acc),
        _vec(cfg.start_jerk),
        _vec(cfg.end_jerk),
    )
    sampled = sample_schedule(traj, cfg.dt, order=2 if cfg.derivatives else 0)
    return sampled if cfg.derivatives else sampled["position"]

Lissajous

Bases: TrajectoryGenerator

Lissajous trajectory generator.

Drives every axis through a cosine at an odd-harmonic frequency, giving a smooth zero-velocity rest-to-rest motion that interpolates start and end while tracing a Lissajous figure around their midpoint:

\[ p(t) = p_0 + R\,\bigl(A_\text{local} \odot \cos(\omega t)\bigr), \qquad \omega_i = \frac{(2 k_i + 1)\pi}{T} \]

with center \(p_0 = (p_f + p_0^\text{start}) / 2\), world amplitude \(A = (\text{start} - \text{end}) / 2\), and local amplitude \(A_\text{local} = R^\top A\).

The per-axis frequencies act on the local frame, so R (a rotation matrix, world-from-local) orients the figure — mixing axes — while \(p(0) = p_0 + A = \text{start}\) and \(p(T) = p_0 - A = \text{end}\) still hold because \(R R^\top = I\). The odd harmonics make both boundary velocities zero:

\[ \dot{p}(t) &= -R\,\bigl(A_\text{local} \odot \omega \odot \sin(\omega t)\bigr), \\ \ddot{p}(t) &= -R\,\bigl(A_\text{local} \odot \omega^2 \odot \cos(\omega t)\bigr). \]

Supports arbitrary N-dimensional positions: k has one entry per axis and R is N x N (or omitted for the identity).

Parameters:

Name Type Description Default
k

Per-axis harmonic indices.

required
R

Optional N x N rotation matrix orienting the oscillation frame. Defaults to the identity.

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/lissajous.py
def __init__(self, k, R=None, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()
    self.k = k
    self.R = self.bk.array(R) if R is not None else None

generate

generate(start_position, end_position, duration: float)

Compute the center, amplitude, and per-axis frequencies.

Parameters:

Name Type Description Default
start_position

One extremum of the oscillation, \(p_0 + A\) (N,).

required
end_position

The opposite extremum, \(p_0 - A\) (N,).

required
duration float

Trajectory duration \(T\) (s).

required

Raises:

Type Description
ValueError

If k does not have one entry per axis, or R is not an N x N rotation matrix.

Source code in src/shinro/trajectories/lissajous.py
def generate(self, start_position, end_position, duration: float):
    """Compute the center, amplitude, and per-axis frequencies.

    Args:
        start_position: One extremum of the oscillation, :math:`p_0 + A`
            (N,).
        end_position: The opposite extremum, :math:`p_0 - A` (N,).
        duration: Trajectory duration :math:`T` (s).

    Raises:
        ValueError: If ``k`` does not have one entry per axis, or ``R`` is
            not an ``N x N`` rotation matrix.
    """
    self.p0 = (start_position + end_position) / 2.0
    self.A = (start_position - end_position) / 2.0
    self.T = duration

    n = len(self.p0)
    if len(self.k) != n:
        raise ValueError(
            f"Lissajous: k has {len(self.k)} entries but the positions have {n} axes"
        )
    R = self.bk.eye(n) if self.R is None else self.R
    if tuple(R.shape) != (n, n):
        raise ValueError(f"Lissajous: R must be ({n}, {n}), got {tuple(R.shape)}")
    if not self.bk.allclose(R.T @ R, self.bk.eye(n)):
        raise ValueError("Lissajous: R must be a rotation matrix (orthogonal)")
    self.R = R

    self.A_local = R.T @ self.A
    self.omegas = (2.0 * self.bk.array(self.k) + 1.0) * math.pi / self.T

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to \([0, T]\)).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each of shape

(N,).

Source code in src/shinro/trajectories/lissajous.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to :math:`[0, T]`).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each of shape
        ``(N,)``.
    """
    t = self.bk.clip(t, 0, self.T)
    wt = self.omegas * t
    cos_wt = self.bk.cos(wt)
    sin_wt = self.bk.sin(wt)
    pos = self.p0 + self.R @ (self.A_local * cos_wt)
    vel = -(self.R @ (self.A_local * self.omegas * sin_wt))
    acc = -(self.R @ (self.A_local * self.omegas**2 * cos_wt))
    return pos, vel, acc

from_config classmethod

from_config(config, backend: ArrayBackend | None = None) -> Any

Create a waypoint schedule from a TOML config dict or LissajousConfig.

Config fields

dt: Sampling period (s). start: One extremum of the oscillation (list of floats). end: The opposite extremum (list of floats). duration: Trajectory duration (s). k: Per-axis harmonic indices. R: Optional rotation matrix (nested list) orienting the figure. Defaults to the identity.

Parameters:

Name Type Description Default
config

TOML config dict or LissajousConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description
Any

Array of shape (total_steps, N) with position waypoints, or — when

Any

derivatives is true — a dict of (total_steps, N) arrays

Any

under "position", "velocity", and "acceleration".

Source code in src/shinro/trajectories/lissajous.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None) -> Any:
    """Create a waypoint schedule from a TOML config dict or :class:`LissajousConfig`.

    Config fields:
        dt: Sampling period (s).
        start: One extremum of the oscillation (list of floats).
        end: The opposite extremum (list of floats).
        duration: Trajectory duration (s).
        k: Per-axis harmonic indices.
        R: Optional rotation matrix (nested list) orienting the figure.
            Defaults to the identity.

    Args:
        config: TOML config dict or LissajousConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        Array of shape (total_steps, N) with position waypoints, or — when
        ``derivatives`` is true — a dict of ``(total_steps, N)`` arrays
        under ``"position"``, ``"velocity"``, and ``"acceleration"``.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    traj = cls(cfg.k, R=cfg.R, backend=bk)
    traj.generate(bk.array(cfg.start), bk.array(cfg.end), cfg.duration)
    sampled = sample_schedule(traj, cfg.dt, order=2 if cfg.derivatives else 0)
    return sampled if cfg.derivatives else sampled["position"]

CircularArc

Bases: TrajectoryGenerator

Circular arc (optionally helical) around a centre.

With \(R = |\text{start} - c|\), unit in-plane basis \(\hat{u} = (\text{start} - c)/R\) and \(\hat{v} = \hat{n} \times \hat{u}\) (unit normal \(\hat{n}\), \(\hat{u} \perp \hat{n}\)), angular rate \(\omega = \theta_\text{sweep}/T\), and \(\theta(t) = \omega t\):

\[ p(t) &= c + R\bigl(\hat{u}\cos\theta + \hat{v}\sin\theta\bigr) + \hat{n}\,\frac{\text{pitch}}{2\pi}\,\theta, \\ \dot{p}(t) &= R\,\omega\bigl(-\hat{u}\sin\theta + \hat{v}\cos\theta\bigr) + \hat{n}\,\frac{\omega\,\text{pitch}}{2\pi}, \\ \ddot{p}(t) &= -R\,\omega^2\bigl(\hat{u}\cos\theta + \hat{v}\sin\theta\bigr). \]

The speed is constant \(R\omega\) (plus the axial component) and the acceleration points at the centre — an exact circle, no solve.

\[ \text{start} = p(0), \qquad p(T) = \text{start} + \hat{n}\,\frac{\text{pitch}}{2\pi}\,\theta_\text{sweep} \]

Parameters:

Name Type Description Default
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/circular_arc.py
def __init__(self, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()

generate

generate(center, start, duration: float, sweep_angle: float, normal=None, pitch: float = 0.0)

Resolve the circle geometry (radius, plane, angular rate).

Parameters:

Name Type Description Default
center

Circle centre (3,).

required
start

A point on the circle (3,), perpendicular to normal about the centre.

required
duration float

Trajectory duration \(T\) (s).

required
sweep_angle float

Signed arc angle (radians).

required
normal

Arc-plane normal (3,). Defaults to [0, 0, 1].

None
pitch float

Axial advance along normal per full turn.

0.0

Raises:

Type Description
ValueError

On a non-positive duration, a non-3D centre/start/ normal, a zero-length normal, start == center, or a start - center that is not perpendicular to normal.

Source code in src/shinro/trajectories/circular_arc.py
def generate(
    self,
    center,
    start,
    duration: float,
    sweep_angle: float,
    normal=None,
    pitch: float = 0.0,
):
    """Resolve the circle geometry (radius, plane, angular rate).

    Args:
        center: Circle centre (3,).
        start: A point on the circle (3,), perpendicular to ``normal``
            about the centre.
        duration: Trajectory duration :math:`T` (s).
        sweep_angle: Signed arc angle (radians).
        normal: Arc-plane normal (3,). Defaults to ``[0, 0, 1]``.
        pitch: Axial advance along ``normal`` per full turn.

    Raises:
        ValueError: On a non-positive duration, a non-3D centre/start/
            normal, a zero-length normal, ``start == center``, or a
            ``start - center`` that is not perpendicular to ``normal``.
    """
    if duration is None or duration <= 0:
        raise ValueError(f"CircularArc: duration must be positive, got {duration}")
    bk = self.bk
    center = bk.array(center)
    start = bk.array(start)
    n = bk.array([0.0, 0.0, 1.0]) if normal is None else bk.array(normal)
    for label, vec in (("center", center), ("start", start), ("normal", n)):
        if tuple(vec.shape) != (3,):
            raise ValueError(f"CircularArc: {label} must be 3-D, got shape {tuple(vec.shape)}")

    n_norm = bk.norm(n)
    if bk.allclose(n_norm, bk.zeros_like(n_norm)):
        raise ValueError("CircularArc: normal must be non-zero")
    n_unit = n / n_norm

    r_vec = start - center
    radius = bk.norm(r_vec)
    if bk.allclose(radius, bk.zeros_like(radius)):
        raise ValueError("CircularArc: start must differ from center")
    # r_vec must lie in the arc plane: |r x n_hat| == |r|.
    if not bk.allclose(bk.norm(bk.cross(r_vec, n_unit)), radius):
        raise ValueError("CircularArc: start - center must be perpendicular to normal")

    self.center = center
    self.radius = radius  # R
    self.u = r_vec / radius  # unit, points at `start` (angle 0)
    self.v = bk.cross(n_unit, self.u)  # unit, leads u by 90 deg in-plane
    self.normal = n_unit
    self.omega = sweep_angle / duration
    self.pitch = pitch
    self.T = duration

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to \([0, T]\)).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each shape

(3,).

Source code in src/shinro/trajectories/circular_arc.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to :math:`[0, T]`).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each shape
        ``(3,)``.
    """
    t = self.bk.clip(t, 0, self.T)
    theta = self.omega * t
    c = self.bk.cos(theta)
    s = self.bk.sin(theta)
    planar = (self.u * c + self.v * s) * self.radius  # R (u cos + v sin)
    axial = self.normal * (self.pitch * theta / (2.0 * math.pi))
    pos = self.center + planar + axial
    vel = (-self.u * s + self.v * c) * (self.radius * self.omega) + self.normal * (
        self.pitch * self.omega / (2.0 * math.pi)
    )
    acc = -planar * (self.omega**2)
    return pos, vel, acc

from_config classmethod

from_config(config, backend: ArrayBackend | None = None) -> Any

Create a waypoint schedule from a TOML config dict or CircularArcConfig.

Config fields

dt: Sampling period (s). center: Circle centre (3,). start: A point on the circle (3,). duration: Trajectory duration (s). sweep_angle: Signed arc angle (radians). normal: Arc-plane normal (3,); defaults to [0, 0, 1]. pitch: Helix pitch (axial advance per full turn). derivatives: Emit the velocity/acceleration dict instead of positions.

Parameters:

Name Type Description Default
config

TOML config dict or CircularArcConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description
Any

Array of shape (total_steps, 3) with position waypoints, or — when

Any

derivatives is true — a dict of (total_steps, 3) arrays

Any

under "position", "velocity", and "acceleration".

Source code in src/shinro/trajectories/circular_arc.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None) -> Any:
    """Create a waypoint schedule from a TOML config dict or :class:`CircularArcConfig`.

    Config fields:
        dt: Sampling period (s).
        center: Circle centre (3,).
        start: A point on the circle (3,).
        duration: Trajectory duration (s).
        sweep_angle: Signed arc angle (radians).
        normal: Arc-plane normal (3,); defaults to ``[0, 0, 1]``.
        pitch: Helix pitch (axial advance per full turn).
        derivatives: Emit the velocity/acceleration dict instead of positions.

    Args:
        config: TOML config dict or CircularArcConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        Array of shape (total_steps, 3) with position waypoints, or — when
        ``derivatives`` is true — a dict of ``(total_steps, 3)`` arrays
        under ``"position"``, ``"velocity"``, and ``"acceleration"``.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    traj = cls(backend=bk)
    traj.generate(
        cfg.center,
        cfg.start,
        cfg.duration,
        cfg.sweep_angle,
        normal=cfg.normal,
        pitch=cfg.pitch,
    )
    sampled = sample_schedule(traj, cfg.dt, order=2 if cfg.derivatives else 0)
    return sampled if cfg.derivatives else sampled["position"]

BezierCurve

Bases: TrajectoryGenerator

Bézier trajectory over a list of control points.

With \(n =\) (number of control points) \(- 1\) and \(s = t / T\):

\[ B(t) = \sum_{i=0}^{n} \binom{n}{i}\, s^i\, (1 - s)^{n-i}\, P_i \]

The endpoints are interpolated (\(B(0) = P_0\), \(B(T) = P_n\)), and the velocity / acceleration are themselves Bézier curves over the scaled control-point differences:

\[ \dot{B}(t) &= \frac{n}{T} \sum_{i=0}^{n-1} \binom{n-1}{i}\, s^i\, (1 - s)^{n-1-i}\, (P_{i+1} - P_i), \\ \ddot{B}(t) &= \frac{n\,(n-1)}{T^2} \sum_{j=0}^{n-2} \binom{n-2}{j}\, s^j\, (1 - s)^{n-2-j}\, (P_{j+2} - 2 P_{j+1} + P_j). \]

The boundary velocities are \(\frac{n}{T}(P_1 - P_0)\) and \(\frac{n}{T}(P_n - P_{n-1})\) — not zero unless the end points are duplicated in the control list.

Parameters:

Name Type Description Default
control_points

Ordered control points, each shape (d,). The curve degree is implied by the list length (a 4-point list is a cubic).

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/bezier_curve.py
def __init__(self, control_points, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()
    self.P = control_points

generate

generate(start_position=None, end_position=None, duration: float | None = None)

Compute the Bernstein blocks for position, velocity, acceleration.

start_position / end_position are informational only — the curve's ends are the first and last control points, and supplying conflicting values is an error.

Parameters:

Name Type Description Default
start_position

Optional expected start; must equal P[0].

None
end_position

Optional expected end; must equal P[-1].

None
duration float | None

Trajectory duration \(T\) (s).

None

Raises:

Type Description
ValueError

If the control list has fewer than 2 points, ragged point dimensions, a non-positive duration, or declared start/end that disagree with the control list ends.

Source code in src/shinro/trajectories/bezier_curve.py
def generate(self, start_position=None, end_position=None, duration: float | None = None):
    """Compute the Bernstein blocks for position, velocity, acceleration.

    ``start_position`` / ``end_position`` are informational only — the
    curve's ends are the first and last control points, and supplying
    conflicting values is an error.

    Args:
        start_position: Optional expected start; must equal ``P[0]``.
        end_position: Optional expected end; must equal ``P[-1]``.
        duration: Trajectory duration :math:`T` (s).

    Raises:
        ValueError: If the control list has fewer than 2 points, ragged
            point dimensions, a non-positive duration, or declared
            start/end that disagree with the control list ends.
    """
    if duration is None or duration <= 0:
        raise ValueError(f"BezierCurve: duration must be positive, got {duration}")
    if len(self.P) < 2:
        raise ValueError("BezierCurve: needs at least 2 control points")
    dims = {len(p) for p in self.P}
    if len(dims) != 1:
        raise ValueError(
            f"BezierCurve: all control points must have the same dimension, got {sorted(dims)}"
        )

    P = self.bk.array(self.P)
    n = len(self.P) - 1
    self.T = duration

    if start_position is not None and not bool(
        self.bk.allclose(self.bk.array(start_position), P[0])
    ):
        raise ValueError(
            "BezierCurve: start_position disagrees with the first control point "
            "(a Bezier curve interpolates its control-list ends exactly)"
        )
    if end_position is not None and not bool(
        self.bk.allclose(self.bk.array(end_position), P[-1])
    ):
        raise ValueError(
            "BezierCurve: end_position disagrees with the last control point "
            "(a Bezier curve interpolates its control-list ends exactly)"
        )
    self.start_position = P[0]
    self.end_position = P[-1]

    self._points = P
    self._bc0 = self.bk.array([math.comb(n, i) for i in range(n + 1)])

    # Degree n-1 velocity curve on scaled first differences (P_{i+1} - P_i).
    m = len(self.P)
    d1 = self.bk.slice_(P, 1, m) - self.bk.slice_(P, 0, m - 1)
    self._vel_points = d1 * (n / self.T)
    self._bc1 = self.bk.array([math.comb(n - 1, i) for i in range(n)])

    # Degree n-2 acceleration curve on scaled second differences.
    self._has_acc = n >= 2
    if self._has_acc:
        d2 = self.bk.slice_(d1, 1, n) - self.bk.slice_(d1, 0, n - 1)
        self._acc_points = d2 * (n * (n - 1) / (self.T * self.T))
        self._bc2 = self.bk.array([math.comb(n - 2, j) for j in range(n - 1)])

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to \([0, T]\)).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each of shape

(d,). Acceleration is exact zeros for a degree-1 curve.

Source code in src/shinro/trajectories/bezier_curve.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to :math:`[0, T]`).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each of shape
        ``(d,)``. Acceleration is exact zeros for a degree-1 curve.
    """
    t = self.bk.clip(t, 0, self.T)
    s = t / self.T
    pos = self._bernstein(self._points, self._bc0, s)
    vel = self._bernstein(self._vel_points, self._bc1, s)
    acc = (
        self._bernstein(self._acc_points, self._bc2, s)
        if self._has_acc
        else self.bk.zeros_like(pos)
    )
    return pos, vel, acc

from_config classmethod

from_config(config, backend: ArrayBackend | None = None) -> Any

Create a waypoint schedule from a TOML config dict or BezierConfig.

Config fields

dt: Sampling period (s). duration: Trajectory duration (s). control_points: Ordered control points (list of float lists).

Parameters:

Name Type Description Default
config

TOML config dict or BezierConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description
Any

Array of shape (total_steps, d) with position waypoints, or — when

Any

derivatives is true — a dict of (total_steps, d) arrays

Any

under "position", "velocity", and "acceleration".

Source code in src/shinro/trajectories/bezier_curve.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None) -> Any:
    """Create a waypoint schedule from a TOML config dict or :class:`BezierConfig`.

    Config fields:
        dt: Sampling period (s).
        duration: Trajectory duration (s).
        control_points: Ordered control points (list of float lists).

    Args:
        config: TOML config dict or BezierConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        Array of shape (total_steps, d) with position waypoints, or — when
        ``derivatives`` is true — a dict of ``(total_steps, d)`` arrays
        under ``"position"``, ``"velocity"``, and ``"acceleration"``.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    traj = cls(cfg.control_points, backend=bk)
    traj.generate(duration=cfg.duration)
    sampled = sample_schedule(traj, cfg.dt, order=2 if cfg.derivatives else 0)
    return sampled if cfg.derivatives else sampled["position"]

BSpline

Bases: TrajectoryGenerator

B-spline trajectory over a knot vector and control polygon.

\[ C(t) = \sum_{i=0}^{n} N_{i,p}(t)\, P_i, \qquad n = m - p - 1 \]

with \(N_{i,p}\) the Cox–de Boor recursion over the knot vector \(\{u_i\}\) and \(m =\) len(knots) \(= n + p + 2\).

Derivatives reuse the same recursion by degree reduction: the \(k\)-th derivative curve is the B-spline of degree \(p - k\) over the differentiated control polygon

\[ Q_i = p\,\frac{P_{i+1} - P_i}{u_{i+p+1} - u_{i+1}} \]

with the first and last knot dropped (applied \(k\) times, _bspline_derv). generate precomputes the first and second derivative curves; position_at returns (pos, vel, acc) like shinro.trajectories.BezierCurve.

Parameters:

Name Type Description Default
degree_polynomial int

Polynomial degree \(p\) of the curve.

required
control_points Sequence[Sequence[float]]

Ordered control points, each shape (d,).

required
time_knot_vector Sequence[float]

Knot vector, exactly len(control_points) + degree_polynomial + 1 nondecreasing knots.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/b_spline.py
def __init__(
    self,
    degree_polynomial: int,
    control_points: Sequence[Sequence[float]],
    time_knot_vector: Sequence[float],
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.P = control_points
    self.knots = time_knot_vector
    self.p = degree_polynomial
    self.T: float | None = None

generate

generate(duration: float, start_position=None, end_position=None)

Validate the curve data and precompute the derivative curves.

start_position / end_position are informational only — the curve's ends are interpolated only by a clamped knot vector, so no agreement check is implied.

Parameters:

Name Type Description Default
duration float

Trajectory duration \(T\) (s).

required
start_position

Unused (see above).

None
end_position

Unused (see above).

None

Raises:

Type Description
ValueError

On a non-positive duration, fewer than 2 control points, ragged point dimensions, a negative degree, a knot count that disagrees with the control polygon, or a non-monotone knot vector.

Source code in src/shinro/trajectories/b_spline.py
def generate(self, duration: float, start_position=None, end_position=None):
    """Validate the curve data and precompute the derivative curves.

    ``start_position`` / ``end_position`` are informational only — the
    curve's ends are interpolated only by a *clamped* knot vector, so no
    agreement check is implied.

    Args:
        duration: Trajectory duration :math:`T` (s).
        start_position: Unused (see above).
        end_position: Unused (see above).

    Raises:
        ValueError: On a non-positive duration, fewer than 2 control
            points, ragged point dimensions, a negative degree, a knot
            count that disagrees with the control polygon, or a
            non-monotone knot vector.
    """
    if duration is None or duration <= 0:
        raise ValueError(f"BSpline: duration must be positive, got {duration}")
    if self.p < 0:
        raise ValueError(f"BSpline: degree must be non-negative, got {self.p}")
    if len(self.P) < 2:
        raise ValueError("BSpline: needs at least 2 control points")
    dims = {len(p) for p in self.P}
    if len(dims) != 1:
        raise ValueError(
            f"BSpline: all control points must have the same dimension, got {sorted(dims)}"
        )
    m = len(self.P)
    if m + self.p + 1 != len(self.knots):
        raise ValueError(
            f"BSpline: knot vector must hold len(control_points) + degree + 1 = "
            f"{m + self.p + 1} knots, got {len(self.knots)}"
        )
    if any(self.knots[i] > self.knots[i + 1] for i in range(len(self.knots) - 1)):
        raise ValueError("BSpline: knot vector must be nondecreasing")

    self.T = duration
    self._points = self.bk.array(self.P)
    self._knots = self.knots

    Q1, p1, k1 = self._bspline_derv(self.knots, 1, self.P, self.p, self.P)
    self._vel_curve = (self.bk.array(Q1), p1, self.bk.array(k1))
    Q2, p2, k2 = self._bspline_derv(self.knots, 2, self.P, self.p, self.P)
    self._acc_curve = (self.bk.array(Q2), p2, self.bk.array(k2))

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to \([0, T]\)).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each of shape

(d,). Velocity and acceleration are exact zeros for curves of

degree < 1 / < 2 respectively.

Source code in src/shinro/trajectories/b_spline.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to :math:`[0, T]`).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each of shape
        ``(d,)``. Velocity and acceleration are exact zeros for curves of
        degree < 1 / < 2 respectively.
    """
    pos = self._eval_curve(self._points, self.p, self._knots, t)
    vel_points, vel_p, vel_knots = self._vel_curve
    vel = self._eval_curve(vel_points, vel_p, vel_knots, t)
    acc_points, acc_p, acc_knots = self._acc_curve
    acc = self._eval_curve(acc_points, acc_p, acc_knots, t)
    return pos, vel, acc

from_config classmethod

from_config(config, backend: ArrayBackend | None = None) -> Any

Create a waypoint schedule from a TOML config dict or BSplineConfig.

Config fields

dt: Sampling period (s). duration: Trajectory duration (s). degree: Polynomial degree of the curve. control_points: Ordered control points (list of float lists). knots: Knot vector (list of floats).

Parameters:

Name Type Description Default
config

TOML config dict or BSplineConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description
Any

Array of shape (total_steps, d) with position waypoints, or — when

Any

derivatives is true — a dict of (total_steps, d) arrays

Any

under "position", "velocity", and "acceleration".

Source code in src/shinro/trajectories/b_spline.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None) -> Any:
    """Create a waypoint schedule from a TOML config dict or :class:`BSplineConfig`.

    Config fields:
        dt: Sampling period (s).
        duration: Trajectory duration (s).
        degree: Polynomial degree of the curve.
        control_points: Ordered control points (list of float lists).
        knots: Knot vector (list of floats).

    Args:
        config: TOML config dict or BSplineConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        Array of shape (total_steps, d) with position waypoints, or — when
        ``derivatives`` is true — a dict of ``(total_steps, d)`` arrays
        under ``"position"``, ``"velocity"``, and ``"acceleration"``.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    traj = cls(cfg.degree, cfg.control_points, cfg.knots, backend=bk)
    traj.generate(duration=cfg.duration)
    sampled = sample_schedule(traj, cfg.dt, order=2 if cfg.derivatives else 0)
    return sampled if cfg.derivatives else sampled["position"]

CatmullRom

Bases: TrajectoryGenerator

C1 Catmull-Rom spline through a start point and ordered waypoints.

Each hop is a cubic Hermite segment (reusing shinro.trajectories.CubicPolynomial) whose boundary velocities are the Catmull-Rom tangents

\[ \dot{P}_i = \frac{P_{i+1} - P_{i-1}}{d_{i-1} + d_i} \]

at interior waypoints, where \(d_i\) is the duration of hop \(i \to i+1\). Each tangent is the end velocity of one segment and the start velocity of the next, so the concatenated curve is C1 and passes through every waypoint without stopping — contrast cubic_segments, whose per-segment defaults bring the curve to rest at each waypoint.

Endpoint velocities default to zero (rest-to-rest start/stop); endpoint_tangent="one_sided" uses \((P_1 - P_0)/d_0\) and \((P_n - P_{n-1})/d_{n-1}\) — the classic one-sided Catmull-Rom ends.

Parameters:

Name Type Description Default
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/catmull_rom.py
def __init__(self, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()

generate

generate(waypoints, durations, endpoint_tangent: str = 'zero')

Build the C1 segments from waypoints and hop durations.

Parameters:

Name Type Description Default
waypoints

Sequence of n+1 positions (each shape (d,)), waypoints[0] the start.

required
durations

n hop durations (s); durations[i] is the time from waypoints[i] to waypoints[i+1].

required
endpoint_tangent str

"zero" or "one_sided".

'zero'

Raises:

Type Description
ValueError

On fewer than 2 waypoints, ragged dimensions, a duration count that disagrees with the waypoint count, a non-positive duration, or an unknown endpoint_tangent.

Source code in src/shinro/trajectories/catmull_rom.py
def generate(self, waypoints, durations, endpoint_tangent: str = "zero"):
    """Build the C1 segments from waypoints and hop durations.

    Args:
        waypoints: Sequence of n+1 positions (each shape ``(d,)``),
            ``waypoints[0]`` the start.
        durations: n hop durations (s); ``durations[i]`` is the time from
            ``waypoints[i]`` to ``waypoints[i+1]``.
        endpoint_tangent: ``"zero"`` or ``"one_sided"``.

    Raises:
        ValueError: On fewer than 2 waypoints, ragged dimensions, a
            duration count that disagrees with the waypoint count, a
            non-positive duration, or an unknown ``endpoint_tangent``.
    """
    if len(waypoints) < 2:
        raise ValueError("CatmullRom: needs at least 2 waypoints (start + 1)")
    dims = {len(p) for p in waypoints}
    if len(dims) != 1:
        raise ValueError(
            f"CatmullRom: all waypoints must have the same dimension, got {sorted(dims)}"
        )
    n = len(durations)
    if n != len(waypoints) - 1:
        raise ValueError(
            f"CatmullRom: expected {len(waypoints) - 1} hop durations for "
            f"{len(waypoints)} waypoints, got {n}"
        )
    if any(d <= 0 for d in durations):
        raise ValueError("CatmullRom: every hop duration must be positive")

    points = [self.bk.array(p) for p in waypoints]
    tangents = self._tangents(points, durations, endpoint_tangent)

    self._segments = []
    t0 = 0.0
    for i in range(n):
        seg = CubicPolynomial(backend=self.bk)
        seg.generate(points[i], points[i + 1], durations[i], tangents[i], tangents[i + 1])
        self._segments.append((t0, durations[i], seg))
        t0 += durations[i]
    self.T = t0

position_at

position_at(t: float)

Evaluate position, velocity, and acceleration at time t.

Parameters:

Name Type Description Default
t float

Time in seconds (clipped to \([0, T]\)).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays, each of shape

(d,).

Source code in src/shinro/trajectories/catmull_rom.py
def position_at(self, t: float):
    """Evaluate position, velocity, and acceleration at time t.

    Args:
        t: Time in seconds (clipped to :math:`[0, T]`).

    Returns:
        Tuple of (position, velocity, acceleration) arrays, each of shape
        ``(d,)``.
    """
    t = self.bk.clip(t, 0, self.T)
    for start, duration, seg in self._segments:
        if t <= start + duration:
            return seg.position_at(t - start)
    start, _, seg = self._segments[-1]
    return seg.position_at(t - start)

from_config classmethod

from_config(config, backend: ArrayBackend | None = None) -> Any

Create a waypoint schedule from a TOML config dict or CatmullRomConfig.

Config fields

dt: Sampling period (s). start: Position of waypoint 0. waypoints: Ordered [[waypoints]] entries, each with a duration (s from the previous waypoint) and a position. endpoint_tangent: "zero" (default) or "one_sided".

Parameters:

Name Type Description Default
config

TOML config dict or CatmullRomConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description
Any

Array of shape (total_steps, d) with position waypoints, or — when

Any

derivatives is true — a dict of (total_steps, d) arrays

Any

under "position", "velocity", and "acceleration".

Source code in src/shinro/trajectories/catmull_rom.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None) -> Any:
    """Create a waypoint schedule from a TOML config dict or :class:`CatmullRomConfig`.

    Config fields:
        dt: Sampling period (s).
        start: Position of waypoint 0.
        waypoints: Ordered ``[[waypoints]]`` entries, each with a
            ``duration`` (s from the previous waypoint) and a ``position``.
        endpoint_tangent: ``"zero"`` (default) or ``"one_sided"``.

    Args:
        config: TOML config dict or CatmullRomConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        Array of shape (total_steps, d) with position waypoints, or — when
        ``derivatives`` is true — a dict of ``(total_steps, d)`` arrays
        under ``"position"``, ``"velocity"``, and ``"acceleration"``.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    wps = strict_from_list(CatmullRomWaypointConfig, cfg.waypoints, "catmull_rom.waypoint")
    waypoints = [cfg.start] + [wp.position for wp in wps]
    durations = [wp.duration for wp in wps]
    traj = cls(backend=bk)
    traj.generate(waypoints, durations, endpoint_tangent=cfg.endpoint_tangent)
    sampled = sample_schedule(traj, cfg.dt, order=2 if cfg.derivatives else 0)
    return sampled if cfg.derivatives else sampled["position"]

Akima

Bases: _WaypointSpline

Akima spline — overshoot-resistant C1 waypoint interpolation.

The tangent at interior waypoint \(i\) is Akima's local four-slope estimate over the neighbouring interval slopes \(s_{i-2..i+1}\):

\[ \dot{P}_i = \frac{|s_{i+1} - s_i|\, s_{i-1} + |s_{i-1} - s_{i-2}|\, s_i}{|s_{i+1} - s_i| + |s_{i-1} - s_{i-2}|} \]

(the average of \(s_{i-1}\) and \(s_i\) where the denominator vanishes, applied elementwise). End tangents use linearly extrapolated end slopes, so the curve is defined by a local stencil and — unlike shinro.trajectories.CatmullRom — is designed not to overshoot steep changes. Each hop is a cubic Hermite segment (shinro.trajectories.CubicPolynomial), giving C1 continuity.

Config is start + [[waypoints]]{duration, position}, like catmull_rom.

Parameters:

Name Type Description Default
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/waypoint_splines.py
def __init__(self, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()

CubicSpline

Bases: _WaypointSpline

Natural cubic spline — C2 waypoint interpolation.

The interior second derivatives \(M_i = S''(P_i)\) solve the tridiagonal system

\[ h_{i-1} M_{i-1} + 2(h_{i-1} + h_i) M_i + h_i M_{i+1} = 6\left(\frac{P_{i+1} - P_i}{h_i} - \frac{P_i - P_{i-1}}{h_{i-1}}\right) \]

with the natural end conditions \(M_0 = M_n = 0\) (zero boundary acceleration). The waypoint velocities are then

\[ \dot{P}_i = \frac{P_{i+1} - P_i}{h_i} - \frac{h_i (2 M_i + M_{i+1})}{6} \]

and each hop is a cubic Hermite segment (shinro.trajectories.CubicPolynomial), which reproduces the spline segment exactly — so the concatenated curve is C2. Unlike Akima it is global (one bk.solve) and can overshoot steep changes.

Config is start + [[waypoints]]{duration, position}, like catmull_rom.

Parameters:

Name Type Description Default
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/trajectories/waypoint_splines.py
def __init__(self, backend: ArrayBackend | None = None):
    self.bk = backend or NumpyBackend()

sample_schedule

Sample one generator over [0, duration) into stacked (steps, N) arrays.

Parameters:

Name Type Description Default
traj

A generator with position_at(t) -> (pos, vel, acc) and a backend (traj.bk).

required
dt float

Sampling period (s).

required
duration float | None

Horizon (s). Defaults to the generator's own total duration.

None
order int

0 = position, 1 = +velocity, 2 = +acceleration.

2

Returns:

Type Description
dict[str, Any]

Dict mapping "position" (always), "velocity" (order >= 1),

dict[str, Any]

and "acceleration" (order >= 2) to (steps, N) arrays.

Source code in src/shinro/trajectories/sampling.py
def sample_schedule(traj, dt: float, duration: float | None = None, order: int = 2) -> dict[str, Any]:
    """Sample one generator over ``[0, duration)`` into stacked ``(steps, N)`` arrays.

    Args:
        traj: A generator with ``position_at(t) -> (pos, vel, acc)`` and a
            backend (``traj.bk``).
        dt: Sampling period (s).
        duration: Horizon (s). Defaults to the generator's own total duration.
        order: 0 = position, 1 = +velocity, 2 = +acceleration.

    Returns:
        Dict mapping ``"position"`` (always), ``"velocity"`` (``order >= 1``),
        and ``"acceleration"`` (``order >= 2``) to ``(steps, N)`` arrays.
    """
    bk = traj.bk
    total = total_duration(traj) if duration is None else duration
    n_steps = round(total / dt)
    keys = _keys(order)
    samples: dict[str, list] = {key: [] for key in keys}
    for step in range(n_steps):
        pos, vel, acc = traj.position_at(step * dt)
        values = {"position": pos, "velocity": vel, "acceleration": acc}
        for key in keys:
            samples[key].append(values[key])
    return _stack(bk, samples)

sample_segments

Concatenate per-segment samples into stacked (steps, N) arrays.

Parameters:

Name Type Description Default
segments

Iterable of (generator, duration) pairs, in time order. Each generator is sampled at t = k * dt for k < round(duration / dt) — the joint is emitted once, as the next segment's t = 0 (the existing segment-schedule convention).

required
dt float

Sampling period (s).

required
order int

0 = position, 1 = +velocity, 2 = +acceleration.

2

Returns:

Type Description
dict[str, Any]

Dict as in sample_schedule.

Raises:

Type Description
ValueError

If segments is empty.

Source code in src/shinro/trajectories/sampling.py
def sample_segments(segments, dt: float, order: int = 2) -> dict[str, Any]:
    """Concatenate per-segment samples into stacked ``(steps, N)`` arrays.

    Args:
        segments: Iterable of ``(generator, duration)`` pairs, in time order.
            Each generator is sampled at ``t = k * dt`` for
            ``k < round(duration / dt)`` — the joint is emitted once, as the
            next segment's ``t = 0`` (the existing segment-schedule convention).
        dt: Sampling period (s).
        order: 0 = position, 1 = +velocity, 2 = +acceleration.

    Returns:
        Dict as in :func:`sample_schedule`.

    Raises:
        ValueError: If ``segments`` is empty.
    """
    segments = list(segments)
    if not segments:
        raise ValueError("sample_segments: no segments")
    bk = segments[0][0].bk
    keys = _keys(order)
    samples: dict[str, list] = {key: [] for key in keys}
    for traj, duration in segments:
        n_steps = round(duration / dt)
        for step in range(n_steps):
            pos, vel, acc = traj.position_at(step * dt)
            values = {"position": pos, "velocity": vel, "acceleration": acc}
            for key in keys:
                samples[key].append(values[key])
    return _stack(bk, samples)

limit_trajectory

Return a TimeScaled view of traj that respects the limits.

Parameters:

Name Type Description Default
traj

A generator with position_at(t) -> (pos, vel, acc).

required
max_velocity float | None

Velocity limit (per component). None to ignore.

None
max_acceleration float | None

Acceleration limit (per component). None to ignore.

None
samples int

Number of intervals used to locate the peaks.

1000

Returns:

Type Description
TimeScaled

A TimeScaled wrapper (drop-in for sample_schedule).

Raises:

Type Description
ValueError

If both limits are None.

Source code in src/shinro/trajectories/time_scaling.py
def limit_trajectory(
    traj,
    max_velocity: float | None = None,
    max_acceleration: float | None = None,
    samples: int = 1000,
) -> TimeScaled:
    """Return a :class:`TimeScaled` view of ``traj`` that respects the limits.

    Args:
        traj: A generator with ``position_at(t) -> (pos, vel, acc)``.
        max_velocity: Velocity limit (per component). ``None`` to ignore.
        max_acceleration: Acceleration limit (per component). ``None`` to ignore.
        samples: Number of intervals used to locate the peaks.

    Returns:
        A :class:`TimeScaled` wrapper (drop-in for ``sample_schedule``).

    Raises:
        ValueError: If both limits are ``None``.
    """
    return TimeScaled(traj, time_scale_factor(traj, max_velocity, max_acceleration, samples))

time_scale_factor

Smallest \(k \ge 1\) so traversing trajk times slower respects the limits.

Samples traj.position_at at samples + 1 points and takes the peak component speed / acceleration. Time-scaling by \(k\) divides velocity by \(k\) and acceleration by \(k^2\), so

\[ k = \max\!\left(1,\; \frac{\max_i |\dot{p}_i|}{v_\text{max}},\; \sqrt{\frac{\max_i |\ddot{p}_i|}{a_\text{max}}}\right). \]

Parameters:

Name Type Description Default
traj

A generator with position_at(t) -> (pos, vel, acc).

required
max_velocity float | None

Velocity limit (per component). None to ignore.

None
max_acceleration float | None

Acceleration limit (per component). None to ignore.

None
samples int

Number of intervals used to locate the peaks.

1000

Returns:

Type Description
float

The time-scale factor \(k \ge 1\).

Raises:

Type Description
ValueError

If both limits are None.

Source code in src/shinro/trajectories/time_scaling.py
def time_scale_factor(
    traj,
    max_velocity: float | None = None,
    max_acceleration: float | None = None,
    samples: int = 1000,
) -> float:
    r"""Smallest :math:`k \ge 1` so traversing ``traj`` ``k`` times slower respects the limits.

    Samples ``traj.position_at`` at ``samples + 1`` points and takes the peak
    component speed / acceleration. Time-scaling by :math:`k` divides velocity
    by :math:`k` and acceleration by :math:`k^2`, so

    .. math::

        k = \max\!\left(1,\; \frac{\max_i |\dot{p}_i|}{v_\text{max}},\;
            \sqrt{\frac{\max_i |\ddot{p}_i|}{a_\text{max}}}\right).

    Args:
        traj: A generator with ``position_at(t) -> (pos, vel, acc)``.
        max_velocity: Velocity limit (per component). ``None`` to ignore.
        max_acceleration: Acceleration limit (per component). ``None`` to ignore.
        samples: Number of intervals used to locate the peaks.

    Returns:
        The time-scale factor :math:`k \ge 1`.

    Raises:
        ValueError: If both limits are ``None``.
    """
    if max_velocity is None and max_acceleration is None:
        raise ValueError("time_scale_factor: at least one of max_velocity / max_acceleration is required")
    bk = traj.bk
    total = total_duration(traj)
    v_need = 0.0
    a_need = 0.0
    for i in range(samples + 1):
        _, vel, acc = traj.position_at(total * i / samples)
        if max_velocity is not None:
            v_need = max(v_need, _peak(bk, vel) / max_velocity)
        if max_acceleration is not None:
            a_need = max(a_need, _peak(bk, acc) / max_acceleration)
    return max(1.0, v_need, math.sqrt(a_need))

TimeScaled

Wrap a generator so it is traversed k times slower.

position_at(t) returns inner.position_at(t / k) with the derivatives rescaled (velocity ÷ k, acceleration ÷ k²). The path is unchanged; only the timing is stretched, so the horizon grows to k * inner_T.

Source code in src/shinro/trajectories/time_scaling.py
def __init__(self, inner, k: float):
    self.inner = inner
    self.k = k
    self.bk = inner.bk
    self.T = total_duration(inner) * k

position_at

position_at(t: float)

Evaluate the time-scaled trajectory at time t.

Parameters:

Name Type Description Default
t float

Time in seconds on the scaled horizon \([0, k T]\).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays.

Source code in src/shinro/trajectories/time_scaling.py
def position_at(self, t: float):
    """Evaluate the time-scaled trajectory at time ``t``.

    Args:
        t: Time in seconds on the *scaled* horizon :math:`[0, k T]`.

    Returns:
        Tuple of (position, velocity, acceleration) arrays.
    """
    pos, vel, acc = self.inner.position_at(t / self.k)
    return pos, vel / self.k, acc / (self.k * self.k)

s_curve_limit

Return an SCurveScaled view of traj that respects the limits.

Parameters:

Name Type Description Default
traj

A generator with position_at(t) -> (pos, vel, acc).

required
max_velocity float | None

Velocity limit (per component). None to ignore.

None
max_acceleration float | None

Acceleration limit (per component). None to ignore.

None
max_jerk float | None

Jerk limit (per component). None to ignore.

None
samples int

Number of intervals used to locate the peaks.

1000

Returns:

Type Description
SCurveScaled

An SCurveScaled wrapper (drop-in for sample_schedule).

Raises:

Type Description
ValueError

If all three limits are None.

Source code in src/shinro/trajectories/time_scaling.py
def s_curve_limit(
    traj,
    max_velocity: float | None = None,
    max_acceleration: float | None = None,
    max_jerk: float | None = None,
    samples: int = 1000,
) -> SCurveScaled:
    """Return an :class:`SCurveScaled` view of ``traj`` that respects the limits.

    Args:
        traj: A generator with ``position_at(t) -> (pos, vel, acc)``.
        max_velocity: Velocity limit (per component). ``None`` to ignore.
        max_acceleration: Acceleration limit (per component). ``None`` to ignore.
        max_jerk: Jerk limit (per component). ``None`` to ignore.
        samples: Number of intervals used to locate the peaks.

    Returns:
        An :class:`SCurveScaled` wrapper (drop-in for ``sample_schedule``).

    Raises:
        ValueError: If all three limits are ``None``.
    """
    return SCurveScaled(
        traj, s_curve_horizon(traj, max_velocity, max_acceleration, max_jerk, samples)
    )

s_curve_horizon

Horizon \(T'\) for the min-jerk S-curve time law that respects the limits.

The output is p(T σ(t / T')) with \(\sigma\) the min-jerk profile, so by the chain rule each output derivative's peak is a fixed function of \(T'\):

\[ \max|\dot{p}_\text{out}| = \frac{T\,V}{T'}, \quad \max|\ddot{p}_\text{out}| = \frac{A}{T'^2}, \quad \max|p'''_\text{out}| = \frac{J}{T'^3} \]

where \(V, A, J\) are sampled from the path. Solving each for \(T'\) and taking the largest (and at least the original horizon \(T\), so the S-curve never speeds the path up) gives the returned horizon.

Parameters:

Name Type Description Default
traj

A generator with position_at(t) -> (pos, vel, acc).

required
max_velocity float | None

Velocity limit (per component). None to ignore.

None
max_acceleration float | None

Acceleration limit (per component). None to ignore.

None
max_jerk float | None

Jerk limit (per component). None to ignore. Jerk is estimated by finite differences (no generator exposes it).

None
samples int

Number of intervals used to locate the peaks.

1000

Returns:

Type Description
float

The S-curve horizon \(T' \ge T\) (s).

Raises:

Type Description
ValueError

If all three limits are None.

Source code in src/shinro/trajectories/time_scaling.py
def s_curve_horizon(
    traj,
    max_velocity: float | None = None,
    max_acceleration: float | None = None,
    max_jerk: float | None = None,
    samples: int = 1000,
) -> float:
    r"""Horizon :math:`T'` for the min-jerk S-curve time law that respects the limits.

    The output is ``p(T σ(t / T'))`` with :math:`\sigma` the min-jerk profile, so
    by the chain rule each output derivative's peak is a fixed function of
    :math:`T'`:

    .. math::

        \max|\dot{p}_\text{out}| = \frac{T\,V}{T'}, \quad
        \max|\ddot{p}_\text{out}| = \frac{A}{T'^2}, \quad
        \max|p'''_\text{out}| = \frac{J}{T'^3}

    where :math:`V, A, J` are sampled from the path. Solving each for :math:`T'`
    and taking the largest (and at least the original horizon :math:`T`, so the
    S-curve never speeds the path up) gives the returned horizon.

    Args:
        traj: A generator with ``position_at(t) -> (pos, vel, acc)``.
        max_velocity: Velocity limit (per component). ``None`` to ignore.
        max_acceleration: Acceleration limit (per component). ``None`` to ignore.
        max_jerk: Jerk limit (per component). ``None`` to ignore. Jerk is
            estimated by finite differences (no generator exposes it).
        samples: Number of intervals used to locate the peaks.

    Returns:
        The S-curve horizon :math:`T' \ge T` (s).

    Raises:
        ValueError: If all three limits are ``None``.
    """
    if max_velocity is None and max_acceleration is None and max_jerk is None:
        raise ValueError(
            "s_curve_horizon: at least one of max_velocity / max_acceleration / max_jerk is required"
        )
    bk = traj.bk
    T = total_duration(traj)
    V = A = J = 0.0
    h = T / (samples * 100) if T > 0 else 1e-9
    for i in range(samples + 1):
        s = i / samples
        sig, sig1, sig2, sig3 = _min_jerk_terms(s)
        tau = T * sig
        _, vel, acc = traj.position_at(tau)
        if max_velocity is not None:
            V = max(V, _peak(bk, vel * sig1))
        if max_acceleration is not None:
            A = max(A, _peak(bk, acc * (T**2 * sig1**2) + vel * (T * sig2)))
        if max_jerk is not None:
            jerk = _finite_difference_jerk(traj, tau, h)
            J = max(
                J,
                _peak(bk, jerk * (T**3 * sig1**3) + acc * (3 * T**2 * sig1 * sig2) + vel * (T * sig3)),
            )

    horizon = T
    if max_velocity is not None:
        horizon = max(horizon, T * V / max_velocity)
    if max_acceleration is not None:
        horizon = max(horizon, math.sqrt(A / max_acceleration))
    if max_jerk is not None:
        horizon = max(horizon, (J / max_jerk) ** (1.0 / 3.0))
    return horizon

SCurveScaled

Wrap a generator so its path parameter follows a rest-to-rest min-jerk S-curve.

position_at(t) evaluates the inner generator at the path time φ(t) = T·σ(t / T') (\(\sigma\) = min-jerk) and applies the chain rule. The path is the same curve traversed with an S-curve timing; the horizon is T' and the ends come to rest (σ'(0) = σ'(1) = 0).

Source code in src/shinro/trajectories/time_scaling.py
def __init__(self, inner, horizon: float):
    self.inner = inner
    self.bk = inner.bk
    self.T = horizon
    self._span = total_duration(inner)

position_at

position_at(t: float)

Evaluate the S-curve time-scaled trajectory at time t.

Parameters:

Name Type Description Default
t float

Time in seconds on the scaled horizon \([0, T']\).

required

Returns:

Type Description

Tuple of (position, velocity, acceleration) arrays.

Source code in src/shinro/trajectories/time_scaling.py
def position_at(self, t: float):
    """Evaluate the S-curve time-scaled trajectory at time ``t``.

    Args:
        t: Time in seconds on the *scaled* horizon :math:`[0, T']`.

    Returns:
        Tuple of (position, velocity, acceleration) arrays.
    """
    span = self._span
    horizon = self.T
    s = t / horizon
    sig, sig1, sig2, _ = _min_jerk_terms(s)
    phi = span * sig
    dphi = (span / horizon) * sig1
    ddphi = (span / horizon**2) * sig2
    pos, vel, acc = self.inner.position_at(phi)
    return pos, vel * dphi, acc * dphi**2 + vel * ddphi