Skip to content

Add an exponential-approach (proportional pursuit) reach model alongside min_jerk #2

Description

@cboulay

Summary

min_jerk.py covers one family of reach: a trajectory with a deadline, which
eases in from rest and eases out to rest over a duration T. That is the right
model when the generator is what moves the effector, or when you want a reach
that visibly commits to arriving at a particular time.

It is the wrong model for a whole other family: a servo. Proposing
exponential.py (name open) for the case where the command is simply
"head at the target, at a speed proportional to how far away it is":

v(t) = k · (target − position(t))

No deadline, no duration, no plan. Distance then decays as
d(t) = d₀·exp(−k·t), and the effector is always pointed exactly at the target.

Why it is worth having alongside min-jerk

The two make genuinely different claims and produce visibly different
trajectories, so which one you assume matters:

min-jerk exponential approach
speed profile symmetric bell, peaks at τ = 0.5 front-loaded, peaks early
peak / mean speed 1.875 ~1.5 with a modest onset ramp
arrival eases to rest at the target never formally arrives; asymptotic
deadline T, and the plan is built around it none
disturbance response re-plan; how hard it corrects depends on time remaining re-points immediately; correction depends only on distance
parameters peak_speed, ballistic_duration, min_horizon k, plus an onset time constant

The disturbance row is the interesting one. Min-jerk's re-planning derives its
correction from the time remaining, which is a strong and specific assumption —
push the effector late in a reach and it corrects hard because the deadline is
near. An exponential approach has no deadline to be near, so its correction
depends only on where the effector is. Neither is universally right; having both
lets a caller choose, and lets an analysis compare them.

It is also the natural model for anything servo-like: a cursor assist, a
proportional controller, a smoothing filter pulling toward a setpoint. Those are
common enough that reaching for min-jerk and then explaining why the trajectory
does not ease out is a recurring papercut.

The one wrinkle: pure proportional control starts with a step

v = k·d at t=0 commands full speed immediately, which is not what a real
effector does and produces an infinite-acceleration onset. Two ways out, both
one parameter:

  1. First-order lag on the commandv_cmd low-passed with time constant
    tau_onset. Cheap, O(1) per sample, matches the EWMA primitive already used
    elsewhere in the ezmsg ecosystem.
  2. Critically-damped second order toward the target, which gives the same
    shape from a different derivation and has no separate smoothing stage.

Either yields the characteristic profile: eases in from rest, peaks at roughly a
quarter of the way through the approach, then decays exponentially — still
moving when it crosses a tolerance radius around the target.

API sketch

Mirroring min_jerk.py's shape so the two are drop-in alternatives — stdlib
only, scalar in / scalar out, dataclass-backed state:

@dataclass
class ExponentialReach:
    target_x: float = 0.0
    target_y: float = 0.0
    k: float = 1.0              # 1/s; time constant of the approach is 1/k
    tau_onset: float = 0.0      # 0 disables the onset lag (pure proportional)
    max_speed: float = 0.0      # 0 disables the clamp
    cmd_vx: float = 0.0
    cmd_vy: float = 0.0
    t_prev: float = 0.0
    active: bool = False

def begin_reach(state, t0, target_x, target_y, k, *, tau_onset=0.0, max_speed=0.0) -> None: ...
def step(state, t, pos_x, pos_y, movement_allowed: bool) -> tuple[float, float]: ...

step keeping the same signature as min-jerk's is the point — a caller should be
able to swap the model without touching its loop.

Notes

  • Unlike min-jerk there is no ReplanSeed question. There is no plan to
    continue from, so the command depends only on the current position; the
    COMMANDED / MEASURED distinction has nothing to attach to.
  • A tolerance radius is worth documenting rather than implementing: since the
    approach is asymptotic, "arrived" is a caller-side decision and baking a
    threshold in would make the model less reusable.
  • Happy to send a PR if the shape looks right — checking on the name and on
    whether the onset should be option 1 or option 2 before writing it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions