Source code for udaan.flatness.jet

"""Jet — a time-varying signal packaged with its time derivatives.

A *k-th order jet* at time t₀ of a (possibly vector-valued) signal y(t) is the
tuple

    Jₖ(y, t₀) = (y(t₀), ẏ(t₀), ÿ(t₀), …, y⁽ᵏ⁾(t₀))

In differential-flatness theory (Fliess, Lévine, et al.) the state and input
of a flat system are algebraic functions of such a jet of the flat output.
This module provides a lightweight container for these tuples, used
throughout ``udaan.flatness`` as the atomic data type for flat-output
derivative bundles.

Convention
----------
The underlying storage is a 2-D numpy array of shape ``(order+1, dim)``.
Row ``k`` is the k-th time derivative of the signal::

    data[0]  →  y
    data[1]  →  ẏ
    data[2]  →  ÿ
    ...
    data[order]  →  y⁽ᵒʳᵈᵉʳ⁾

Scalar signals (``dim == 1``) may be constructed from a 1-D array of length
``order+1``; it's promoted to 2-D internally. Indexing a scalar jet returns
a Python ``float``; indexing a vector jet returns a 1-D ``np.ndarray`` of
shape ``(dim,)``.

Strictly, the k-jet in the differential-geometry literature is the truncated
Taylor polynomial ``Σ yᵏ(t₀)/k! · zᵏ``; ``Jet`` stores the raw derivative
tuple (no factorial scaling).

See also
--------
https://en.wikipedia.org/wiki/Jet_(mathematics)
"""

from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np


[docs] @dataclass class Jet: """Time-derivative tuple ``(y, ẏ, ÿ, …)`` of a signal at a single time. Parameters ---------- data: Either a 1-D array of shape ``(order+1,)`` — which promotes to a scalar jet with ``dim == 1`` — or a 2-D array of shape ``(order+1, dim)``. Dtype is coerced to float64. Attributes ---------- data: 2-D numpy array of shape ``(order+1, dim)``. Examples -------- Scalar signal (e.g. yaw angle with three derivative levels):: >>> psi = Jet(np.array([0.0, 0.1, 0.2])) >>> psi.order 2 >>> psi.dim 1 >>> psi[1] 0.1 Vector signal (e.g. position with four derivative levels):: >>> x = Jet(np.stack([[0, 0, 1], [1, 0, 0], [0, 0, 0], [0, 0, 0]], axis=0)) >>> x.order 3 >>> x.dim 3 >>> x[1] array([1., 0., 0.]) """ data: np.ndarray = field(default_factory=lambda: np.zeros((1, 1))) def __post_init__(self) -> None: arr = np.asarray(self.data, dtype=float) if arr.ndim == 1: arr = arr.reshape(-1, 1) elif arr.ndim != 2: raise ValueError( f"Jet.data must be 1-D (scalar signal) or 2-D " f"(vector signal); got shape {arr.shape}" ) if arr.shape[0] < 1: raise ValueError("Jet requires at least one row (the 0-th derivative).") self.data = arr # ─── Shape / type introspection ─────────────────────────────────── @property def order(self) -> int: """Highest derivative index stored; ``data.shape[0] - 1``.""" return self.data.shape[0] - 1 @property def dim(self) -> int: """Signal dimension; ``data.shape[1]`` (``1`` for scalar signals).""" return self.data.shape[1] @property def is_scalar(self) -> bool: """``True`` if the signal is scalar (``dim == 1``).""" return self.dim == 1 # ─── Indexed access ─────────────────────────────────────────────── def __getitem__(self, k: int) -> float | np.ndarray: """The k-th derivative. Returns ``float`` for scalar signals (``dim == 1``), otherwise a 1-D ``np.ndarray`` of shape ``(dim,)``. """ if not 0 <= k <= self.order: raise IndexError(f"Jet derivative index {k} out of range [0, {self.order}]") row = self.data[k] return float(row[0]) if self.is_scalar else row def __len__(self) -> int: """Number of derivative levels (``order + 1``).""" return self.data.shape[0] def __iter__(self): """Iterate over derivative levels in order (y, ẏ, ÿ, …).""" for k in range(self.order + 1): yield self[k] # ─── Named accessors for common low orders ──────────────────────── # # These are pure conveniences. ``jet.acceleration`` is exactly the # same as ``jet[2]`` — use whichever reads best in a given line. @property def value(self) -> float | np.ndarray: return self[0] @property def velocity(self) -> float | np.ndarray: if self.order < 1: raise AttributeError("Jet has order 0; no velocity stored") return self[1] @property def acceleration(self) -> float | np.ndarray: if self.order < 2: raise AttributeError(f"Jet has order {self.order}; no acceleration stored") return self[2] @property def jerk(self) -> float | np.ndarray: if self.order < 3: raise AttributeError(f"Jet has order {self.order}; no jerk stored") return self[3] @property def snap(self) -> float | np.ndarray: if self.order < 4: raise AttributeError(f"Jet has order {self.order}; no snap stored") return self[4] @property def crackle(self) -> float | np.ndarray: if self.order < 5: raise AttributeError(f"Jet has order {self.order}; no crackle stored") return self[5] @property def pop(self) -> float | np.ndarray: if self.order < 6: raise AttributeError(f"Jet has order {self.order}; no pop stored") return self[6] # ─── numpy interop ──────────────────────────────────────────────── def __array__(self, dtype=None) -> np.ndarray: """Return the underlying 2-D derivative array.""" return self.data if dtype is None else self.data.astype(dtype) # ─── Constructors / transformations ───────────────────────────────
[docs] @classmethod def from_list(cls, derivatives: list) -> Jet: """Construct from an explicit list of derivative arrays/scalars. Examples -------- >>> Jet.from_list([pos, vel, acc, jerk, snap]) """ stacked = np.stack([np.atleast_1d(d) for d in derivatives]) return cls(stacked)
[docs] @classmethod def zeros(cls, order: int, dim: int = 1) -> Jet: """A jet of all zeros with the given ``order`` and ``dim``.""" return cls(np.zeros((order + 1, dim)))
[docs] def truncate(self, new_order: int) -> Jet: """Drop derivatives above ``new_order`` and return a new jet.""" if new_order < 0 or new_order > self.order: raise ValueError(f"truncate(new_order={new_order}) out of range [0, {self.order}]") return Jet(self.data[: new_order + 1])
[docs] def differentiate(self) -> Jet: """Shift derivatives down by one level and return a new jet. Given ``J = (y, ẏ, ÿ, …, y⁽ᵒʳᵈᵉʳ⁾)`` returns ``J' = (ẏ, ÿ, …, y⁽ᵒʳᵈᵉʳ⁾)`` — i.e. the jet of the time derivative of the original signal, with ``order`` reduced by one. """ if self.order < 1: raise ValueError("Cannot differentiate an order-0 jet; the derivative is not stored.") return Jet(self.data[1:])
# ─── Debug / display ────────────────────────────────────────────── def __repr__(self) -> str: if self.is_scalar: vals = ", ".join(f"{float(row[0]):.4g}" for row in self.data) else: vals = ", ".join(np.array2string(row, precision=4, separator=", ") for row in self.data) return f"Jet(order={self.order}, dim={self.dim}; [{vals}])"