wbc_kit spike — Whole-Body Control frameworks via cppyy¶
Date: 2026-07-12 · Env: pixi wbc (standalone, conda-forge only),
pinocchio 4.0.0, crocoddyl 3.2.1, tsid 1.x, casadi 3.7.2,
example-robot-data 5.0.0, libboost 1.90, cppyy 3.5.0, Python 3.12.13, linux-64.
Question: tsid / crocoddyl / pinocchio are all on conda-forge with Python bindings (verified). So the cppyy win — if there is one — must be sharper than "bindings exist". Where, for whole-body control, does cppyy do something the bindings genuinely cannot? "Bindings are fine, no kit needed" is an acceptable honest answer.
Verdict: GO, narrowly and specifically — Crocoddyl. The sharp, measurable,
demo-worthy win is authoring a custom Crocoddyl action model in C++ inline and
JIT-compiling it at runtime with no build system, so the DDP solver calls its
calc/calcDiff natively. On the canonical unicycle problem this runs at the exact
speed of Crocoddyl's compiled built-in model and ~21x faster than the
Python-derived model that Crocoddyl's bindings support — converging to a
bit-identical cost. This is the ompl_kit "lower the hot path to C++" story applied
to optimal control, and it is a genuinely new capability: the bindings let you
prototype a custom model in Python (slow) or ship one in C++ (needs a CMake build);
cppyy fills the missing "fast and no build system, in the same script" cell. A
thin wbc_kit (bringup + the crash-safe custom-model compile + the one mandatory
Crocoddyl-3.2 boilerplate) is justified. pinocchio's templated-scalar angle and
tsid's custom-task angle were evaluated and are weaker (see the table).
1. Decision table — where does cppyy actually win for WBC?¶
| Framework | Binding today | Failure-mode the binding leaves | cppyy angle | Effort | Demo value | Evidence |
|---|---|---|---|---|---|---|
| Crocoddyl 3.2 | boost::python; can subclass ActionModelAbstract in Python |
Python-authored models are slow in the DDP hot loop; the fast path (C++ model) needs a CMake project + rebuild | Author the custom C++ action model inline (cppdef), JIT'd at runtime — native speed, no build system. Prototype in Python, lower to inline C++ in the same script |
Low–Med | HIGH | S2/S3: WORKS. 21.7x vs Python model, == built-in speed, cost bit-identical; bringup clean (~0.4 s JIT, no boost/ORC wall) |
| pinocchio 4.0 | double + casadi scalar (pinocchio.casadi) shipped; cppad not built |
Non-casadi scalar surfaces (ModelTpl<Scalar> for a scalar the binding never built) |
Instantiate ModelTpl<Scalar> on demand (the pcl PointCloud<T> pattern) |
Med | Low–Med | S4: BLOCKED in this env — pinocchio's 25-type JointModel boost::variant exceeds boost 1.90's template-arity when re-instantiated for a new scalar (ModelTpl<float> and .cast<float>() both fail to compile). casadi (the main autodiff scalar) already shipped |
| tsid 1.x | boost::python; bindings expose only concrete task types (no subclassable TaskBase/TaskMotion trampoline) |
Authoring a new task type from Python isn't exposed | Cross-inheritance: derive TaskMotion and override compute (plain virtuals, not final) |
Med | Med | S5: expected WORKS (same pattern as ompl/control), not probed — it is the same cross-inheritance capability those kits already prove, so lower novelty. Bringup shares Crocoddyl's clean pinocchio+boost stack |
| OCS2 (C++-only) | none | — | Would be a pure cppyy target | — | — | S6: not on conda-forge / robostack; source build is large (multi-package MPC framework) -> out of scope, documented |
| mc_rtc (C++-only) | own Python bindings, own build | — | — | — | — | S6: not on conda-forge / robostack -> out of scope |
| proxsuite / QP | conda-forge package with bindings | — | none | — | — | S6: bindings are complete; no cppyy win. Honest "no kit needed" |
Chosen probe target: Crocoddyl — the only candidate whose cppyy win is simultaneously (a) sharper than "bindings exist", (b) measurable end-to-end, and (c) demo-worthy in a new domain (optimal control) rather than a re-run of the cross-inheritance pattern ompl_kit/control_kit already own.
2. Probe — Crocoddyl capability matrix¶
Each probed in a fresh subprocess (S20: risky includes/cppdef out-of-process first)
from the wbc env against the installed Crocoddyl 3.2.1.
| # | Capability | Result | Evidence |
|---|---|---|---|
| 1 | Bringup + JIT: pinocchio/fwd.hpp + Crocoddyl core/action/state/solver headers, load libpinocchio_default.so + libcrocoddyl.so |
WORKS | Header JIT ~420 ms (pinocchio/fwd 224 + crocoddyl core 155 + solvers 44); lib load ~5 ms. No boost/ORC wall despite the eigen+boost+pinocchio stack (S20's warning did not bite here). Comparable to ompl (~538 ms) |
| 2 | Built-in model via cppyy: construct crocoddyl::ActionModelUnicycle, run calc |
WORKS | xnext=[1.05, 0, 0.01], cost=50.13 for x=[1,0,0], u=[0.5,0.1] — exact |
| 3 | Custom C++ action model (cross-inheritance in C++): cppdef a subclass of crocoddyl::ActionModelAbstract, drive a real FDDP solve |
WORKS | 100-node unicycle, FDDP -> cost=250.039320, 8 iters, converged. Solve driven entirely in C++ (Pattern 6 containers). See S3 |
| 4 | Numeric verification vs the binding's built-in model | WORKS | The inline-C++ model's converged cost is bit-identical to Crocoddyl's compiled ActionModelUnicycle and to a Python-derived model (all three: 250.039320, 8 iters) |
| 5 | Benchmark (the honest number) | WORKS | S3 — 21.7x vs Python model; == built-in C++ speed |
One sharp edge (fixed in the kit): authoring a custom model is a
failed-cppdef-crash minefield (S20 Pattern 9). Two forced dead-ends before it
compiled: (a) calc/calcDiff signatures must match the base's
const Eigen::Ref<const VectorXs>& exactly; (b) Crocoddyl 3.2's CROCODDYL_BASE_CAST
macro adds two pure-virtual clone methods (cloneAsDouble/cloneAsFloat) that a
subclass must implement or it stays abstract. Each mistake crashed Cling during
transaction revert (no Python traceback). wbc_kit.safe_cppdef probes the model
out-of-process first and raises a clean CppyyKitError; wbc_kit.ACTION_MODEL_CLONES
is the mandatory boilerplate.
3. Bench — custom action model, three ways (the honest number)¶
Same unicycle optimal-control problem (T=100 nodes, FDDP, x0=[-1,-1,1]), authored and solved three ways. Shared machine during measurement — provisional, directional not exact (best of 7 after warm-up).
| model authoring path | cost | iters | solve | vs Python-model |
|---|---|---|---|---|
(A) Python-derived (subclass ActionModelAbstract in Python — the binding's prototype path) |
250.039320 | 8 | 6.84 ms | 1.0x |
(ref) built-in C++ (crocoddyl::ActionModelUnicycle, compiled in the binding) |
250.039320 | 8 | 0.34 ms | 20.2x |
(B) cppyy inline C++ (custom model cppdef'd at runtime, no build system) |
250.039320 | 8 | 0.32 ms | 21.7x |
Reading these honestly:
- All three converge to a bit-identical cost — the inline-C++ model is a faithful
lowering of the Python prototype, not an approximation. This is the tests-as-
contract discipline: the numeric match is the regression gate (test_wbc_kit.py).
- The inline-C++ model runs at the compiled built-in's speed (0.32 vs 0.34 ms) —
cppyy's JIT'd C++ is native C++; there is no Python in the DDP hot loop.
- ~21x over the Python-derived model. The DDP solver calls calc/calcDiff per
node per iteration (plus line-search rollouts) — thousands of crossings; the Python
model pays the boundary + NumPy-allocation cost on every one. This is the crocoddyl
analogue of ompl_kit's "Python validity checker in the hot loop" figure, in a domain
(trajectory optimization) where the callback truly dominates.
- The win is "fast and no build system, same script." Crocoddyl's own workflow
is "prototype in Python, rewrite the hot model in C++"; the rewrite normally means a
CMake project linking libcrocoddyl. cppyy makes the lowered C++ model a cppdef
string in the same file — the exact ROSCon storyline (prototype -> lower -> benchmark,
code stays ~the same).
Run it: pixi run -e wbc demo-wbc-lower (and pixi run -e wbc test-wbc).
4. pinocchio templated-scalar — BLOCKED in this env (the plan's named gap)¶
The plan flagged pinocchio::ModelTpl<Scalar> for a non-double Scalar (autodiff)
as the pinocchio angle. Findings:
- casadi is already shipped. pinocchio.casadi (cpin) imports; the conda-forge
feedstock builds WITH_CASADI. So the main autodiff scalar is a binding feature,
not a cppyy gap.
- Non-casadi scalars are env-blocked. ModelTpl<float> and the
build-as-double-then-.cast<float>() path both fail to compile — not a Cling
quirk (g++ reproduces it): pinocchio's JointModelVariant is a 25-type
boost::variant, and re-instantiating it for a new scalar hits boost 1.90's
make_variant_list template-arity limit (wrong number of template arguments (25,
should be at least 0)). The shipped double/casadi libraries sidestep this by being
precompiled; JIT-instantiating a fresh scalar from headers does not.
- Additionally, pinocchio's buildModels:: sample builders hardcode double inertias,
so the ergonomic "any scalar on demand" (the pcl PointCloud<T> pattern) does not
transfer cleanly even setting boost aside.
So the plan's named gap is real in principle but env-blocked here. It might be pried open with boost-preprocessor arity defines (a S20 "peel one layer" exercise), but that is a dependency-config fight, not a clean cppyy win — and casadi already covers the motivating use case. Honest verdict: not the probe target.
5. tsid custom tasks — genuine but not novel¶
tsid's boost::python bindings expose only concrete task classes
(TaskSE3Equality, TaskComEquality, TaskJointPosture, ...) — there is no exposed
subclassable TaskBase/TaskMotion with a virtual trampoline, so you cannot
author a new task type from Python through the binding. TaskMotion's virtuals
(compute, getConstraint, ...) are plain (not final), so a Python or C++ subclass
via cppyy cross-inheritance would work — this is a real capability the binding lacks.
But it is the same cross-inheritance pattern ompl_kit and control_kit already
prove (Python derives a C++ virtual base; S16). Lower novelty, medium demo value,
and it shares Crocoddyl's clean pinocchio+boost bringup. Documented as a viable
follow-on, not the headline.
6. C++-only candidates & environment findings¶
- OCS2 — not on conda-forge or robostack; a large multi-package MPC framework (source build with catkin/ROS deps). A pure cppyy target in principle, but the source-build size puts it out of scope for this spike. Documented.
- mc_rtc — not on conda-forge/robostack; ships its own Python bindings + build. Out of scope.
- proxsuite and the QP-solver ecosystem — on conda-forge with complete bindings. No cppyy win; honest "no kit needed."
Environment / lock changes (flag for the lead)¶
- Added
[feature.wbc]+wbcenv to pixi.toml — STANDALONE (no-default-feature), NOTsolve-group="default". The plan suggested solve-group default; it is infeasible and I switched to standalone on evidence: - conda-forge pinocchio/crocoddyl/tsid can only co-resolve with the robostack-jazzy
ROS stack if they share a boost, but the shared solve-group is over-constrained by
the ROS stack —
pixi install -e wbcunder solve-group default fails (libboost 1.86 ... conflicts with the versions reported above; some pinocchio builds also demanded python 3.9). pinocchio/crocoddyl/tsid carry no ROS dependency, so a standalone conda-forge env is the correct home; it resolves cleanly (boost 1.90, py3.12) and leaves the shared ROS lock untouched.
Dated correction (2026-07-12): the pinocchio↔ROS half of this clash dissolved — conda-forge rebuilt pinocchio 4.x against libboost 1.90, so
pinocchio+example-robot-datanow co-solve with the robostack ROS stack undersolve-group="default"(verified; the retarget lane's[feature.retarget-ros]env does exactly this and runs the CLIK retarget alongside rclcpp_kit in one process). Thewbcenv stays standalone only for crocoddyl/tsid, which still pull the older boost line here. Unchanged: the Cling header-parse wall onpinocchio::Model(the 25-typeboost::variant, §4 above) — it trips on boost 1.90 too, so driving pinocchio's rigid-body core from cppyy remains blocked regardless of the solve; use the bindings. - The standalonewbcfeature re-declares its ownpython/cppyy/compilers/numpy/pytest, since it does not inherit the default. -pixi.lockre-locked to add thewbcenv's solve (a new standalone env; the default and existing kit envs are unchanged — the standalone choice was made specifically to avoid perturbing the shared lock). Re-lock flagged as requested. - Registeredwbc_kitin thelintandtestpixi tasks and on the defaultPYTHONPATH; the new tests auto-skip outside thewbcenv (verified:6 skippedin the default env), so the default suite is untouched.
7. Generic-lesson candidates for COMMON_PATTERNS¶
Noted for the lead (COMMON_PATTERNS.md is the lead's to edit):
-
Cross-binding-runtime co-existence (NEW nuance for S16/S20). A library can ship boost::python bindings and be driven by cppyy in the same process — both load the same
libcrocoddyl.so, the C++ objects are separate. The clean division of labour: prototype with the library's own binding, lower the hot path with cppyy, in one script. But a cppyy-created C++ object cannot be handed to a boost::python API (two proxy runtimes) — so the cppyy path must build its own containers/solve in C++ (Pattern 6), not feed the binding's objects. Worth a sentence: "mixing a library's own binding with cppyy is fine and often ideal; just don't pass objects between the two runtimes." -
Versioned pure-virtual creep across the inheritance boundary (sharpens S16). A library minor version can add pure virtuals to a base you subclass and silently make your override abstract — Crocoddyl 3.2's
CROCODDYL_BASE_CASTaddedcloneAsDouble/cloneAsFloattoActionModelAbstract. In C++ it is a compile error; in cppyy it is a failed-cppdefcrash (S9/S20 Pattern 9). Rule: when cross-inheriting a C++ base,nm/grep the base for every pure virtual (including macro-injected ones) before authoring; probe the subclasscppdefout-of-process. A kit should ship the mandatory boilerplate as a constant (wbc_kit.ACTION_MODEL_CLONES). -
boost::varianttemplate-arity is an env-version JIT wall (new S20 sub-case). A bigboost::variant(pinocchio's 25-typeJointModel) that a precompiled.socarries fine can be un-JIT-able from headers under a newer boost whose preprocessed arity limit it exceeds — parse fails, not execution (distinct from the ORC static-init wall). Suspect it when a template class re-instantiation for a new parameter fails but the shipped specialization works. Reproduce withg++to distinguish from a Cling quirk. -
"Slow Python subclass, fast C++ subclass, no build system" is a recurring shape. Third instance of the lowering pattern after ompl_kit (validity checker) and control_kit (controller): a framework whose hot loop calls a user-authored virtual (OMPL validity, ros2_control update, Crocoddyl
calc/calcDiff) is the ideal cppyy target — cppyy uniquely offers the fast authoring path (inline C++) without the framework's usual build step. Candidate for a named pattern: "lower the hot virtual."