Kinodynamic Planning (FLASK)¶
MotionPlanner.plan_kinodynamic plans a time-parameterised,
dynamically feasible trajectory directly — no separate time
parameterization step. It implements FLASK (Duong et al., "Ultrafast
Sampling-based Kinodynamic Planning via Differential Flatness", T-RO
2026): RRT-Connect in the differentially flat output space, where every
tree edge is a closed-form cubic that is collision-checked with the same
SIMD VAMP kernels as the geometric planner. The implementation is ported
from Fetch-Planning.
from wheelchair_planning.planning import create_planner
from wheelchair_planning.types import KinodynamicConfig
planner = create_planner("wheelchair_whole_body", pointcloud=cloud)
planner.set_joint_limits({"Joint_Virtual_X": (-2.0, 3.0), "Joint_Virtual_Y": (-1.5, 1.5)})
result = planner.plan_kinodynamic(start, goal, config=KinodynamicConfig(time_limit=1.0))
traj = result.trajectory # KinodynamicTrajectory
times, q, qd, qdd = traj.sample_uniform(dt=0.01)
twist = traj.sample_base_twist(times) # (T, 2): v, omega for the base
It works for every subgroup (wheelchair_arm, wheelchair_base,
wheelchair_whole_body). See examples/planning/kinodynamic.py.
The wheelchair base¶
The wheelchair is a differential drive: the rear hub-motor wheels drive
and the front omni wheels roll freely. base_link's origin — the planned
(x, y) — is the midpoint of the rear drive axle, so the base moves
along its heading or rotates in place, never sideways. The planner's
output satisfies this exactly (zero lateral velocity).
The seated user faces +x, the omni-wheel end. By default the chair drives
forward only and turns in place where needed, so it always moves the way
its user faces (BASE_REVERSE_ENABLE = False; pass
KinodynamicConfig(allow_reverse=True) to permit reversing).
Base positions are sampled uniformly inside the planner's joint limits,
which default to the URDF's ±10 m virtual-joint limits. Narrow
Joint_Virtual_X / Joint_Virtual_Y around the task's workspace with
set_joint_limits (or PlannerConfig(joint_limits=...)); the limits
persist across set_subgroup and also apply to the geometric planners.
How it works¶
-
Flat outputs
Arm joints are their own flat outputs (fully actuated). The diff-drive base contributes its planar position
(x, y); the heading follows the direction of travel,theta = atan2(y_dot, x_dot) + gear * pi. -
Closed-form edges
Nodes are
(position, velocity)states. An edge is the cubic Hermite between its endpoints — the minimum-effort (LQMT) motion — with the durationT*that minimisessum_i int y_i''^2 / a_max_i^2 dt + rho T(a quartic root). -
Limits
Joint position / velocity / acceleration bounds are checked exactly on each cubic;
T*is stretched until they and the base speed, acceleration and yaw rate hold. Base speed, tangential acceleration and yaw rate are then checked on a 50 ms grid. -
SIMD collision checks
Each edge is sampled at VAMP's resolution along its arc length and checked in interleaved
ConfigurationBlockbatches (FLASK Alg. 3), then the solution is simplified the way VAMP simplifies geometric paths: greedy shortcuts (Alg. 4) plus subdivide-and-cut-corners smoothing, keeping a change only if it lowers the cost.
The base at rest¶
The unicycle model has no heading at zero velocity — exactly where a mobile manipulator starts, stops and grasps. The planner makes parked states first-class:
- A parked node keeps its heading. Edges leave and enter it along that heading (forward or reverse); when a tree grows out of a parked node, the sampled target's lateral velocity is projected so the cubic departs aligned.
- Gears only change at parked nodes, so every drive segment has a well-defined heading and no hidden cusp.
- Rotate in place: a connection into or out of a parked node whose heading does not match gets a spin segment (arm holding still). Between two parked nodes that is rotate–translate–rotate, which is why open-floor legs solve in about a millisecond.
Tracking the trajectory¶
The trajectory is C¹ in every flat output: positions and velocities are continuous; accelerations — and the base yaw rate, which depends on them — jump at segment knots. A feed-forward + feedback controller is enough:
- Joints:
u = qd_ref + Kp (q_ref - q) + Ki ∫(q_ref - q)as a velocity command (what a joint trajectory controller does). - Base: feed-forward
(v, omega)fromtraj.base_twist(t)plus pose feedback in the body frame (e.g. a Kanayama law).
Pass the current state to replan on the fly — the new trajectory starts with the given velocity:
q_now, qd_now = traj.position(t), traj.velocity(t)
result = planner.plan_kinodynamic(q_now, new_goal, start_velocity=qd_now)
Clearance
Trajectories are collision-free at zero clearance, up to the sampling
resolution like every VAMP edge. Tracking error is not accounted for:
inflate obstacles with PlannerConfig.point_radius for a margin, and
replan from the measured state.
Configuration¶
KinodynamicConfig holds the planner parameters. The limits come from
wheelchair_planning/wheelchair.py (JOINT_VELOCITY_LIMITS,
JOINT_ACCELERATION_LIMITS, BASE_MAX_*, BASE_REVERSE_ENABLE); they
are placeholders (base 0.6 m/s, 1.0 rad/s, forward only) — tune them to
the robot's controllers. velocity_scale / acceleration_scale scale
all of them at once.
| Field | Default | Meaning |
|---|---|---|
time_limit |
1.0 |
Planning budget (s) |
rho |
1.0 |
Time weight; rest-to-rest edges peak at sqrt(rho) × the acceleration limit |
max_extension_time |
3.0 |
Tree step: longer cubics are truncated (node stays on the cubic) |
rest_sample_probability |
0.3 |
Share of zero-velocity samples (straight-line edges, gear changes) |
spin_probability |
0.3 |
Share of rotate-in-place extensions from parked nodes |
allow_reverse |
BASE_REVERSE_ENABLE |
Permit the reverse gear |
simplify_iterations / simplify_time_limit |
5 / 0.01 |
Rounds and time cap (s) for the VAMP-style simplification; raise the cap for shorter trajectories |
seed |
0 |
RNG seed (0 = nondeterministic) |
Results¶
Planning time per call (search + simplification) with the default limits (base 0.6 m/s, 1.0 rad/s, forward only), 3 s budget, 20 seeds per leg, measured on a shared desktop CPU under load, so absolute times are pessimistic. Every trajectory was re-checked independently — dense collision resampling every 2 ms, joint and base limits, C¹ continuity, zero lateral velocity:
| Leg | Solved | Median | p90 |
|---|---|---|---|
| arm only: around a shelf | 20/20 | 1.8 ms | 2.5 ms |
| whole body: rotate in place + move the arm | 20/20 | 0.2 ms | 0.2 ms |
| whole body: sideways and turn around | 20/20 | 0.4 ms | 0.4 ms |
| whole body: behind, turn and drive | 20/20 | 0.5 ms | 0.6 ms |
| whole body: 2.5 m ahead, pillar in the way | 20/20 | 24 ms | 103 ms |
| base only: drive to the table around a pillar | 20/20 | 25 ms | 32 ms |
| whole body: drive to the table and reach | 20/20 | 98 ms | 158 ms |
One of the 140 trajectories grazed an obstacle between the planner's own
collision samples: the tucked gripper touched a pillar point by 0.5 mm
over ~1 cm of travel. Edges are checked at VAMP's resolution (~1.4 cm
here), like the geometric planner's, so keep PlannerConfig.point_radius
as the clearance margin.
The base limits dominate planning time: with 0.3 m/s and 0.5 rad/s most curved edges are infeasible, and (measured with reverse allowed) the drive to the table took about twice as long to plan and the whole-body legs about three times as long.
Two things keep the collision checks cheap. The chassis spheres are spread
over twelve fixed child links (scripts/split_collision_link.py), so the
checker's per-link bounding spheres skip the parts of the chassis the arm
is nowhere near; one check costs ~1 µs per configuration in a SIMD batch.
And durations are stretched for the base yaw-rate limit, not only for
speed and acceleration, so an edge that turns too fast is slowed down
instead of rejected.
Driving with the arm tucked and then moving the arm is the robust pattern; planning base and arm together is an 18-dimensional flat-state problem and needs a longer budget in cluttered scenes.