@@ -321,9 +321,7 @@ matches the training configuration.
321321
322322---
323323
324- ## Advanced Topics
325-
326- ### Custom matchers
324+ ## Advanced: Custom Matchers
327325
328326The built-in matchers cover the standard IsaacLab tensor naming conventions.
329327If your model uses a different naming scheme you can register additional matchers
@@ -364,7 +362,7 @@ tensors that the defaults don't cover.
364362
365363---
366364
367- ### Custom logging backend
365+ ## Advanced: Custom Logging Backend
368366
369367By default the controller logs to stdout. To redirect log messages to your own
370368logging system, implement ` LoggingInterface ` and call ` setLogger() ` :
@@ -409,3 +407,171 @@ exploy::control::setLogger(&adapter);
409407
410408`setLogger()` stores a raw pointer, so the adapter must outlive the controller.
411409Pass `nullptr` to revert to the built-in stdout logger.
410+
411+ ---
412+
413+ ## Advanced: Custom Commands
414+
415+ The built-in `CommandInterface` covers standard command types (SE2 velocity,
416+ SE3 pose, boolean selectors, float values, joint positions). If your policy
417+ consumes a tensor that doesn't match any of those — for example an arbitrary
418+ vector produced by a higher-level planner — you can feed it in by registering
419+ a custom `Matcher` and `Input` pair.
420+
421+ ### How it works
422+
423+ When `controller.create()` loads the ONNX model it iterates over every input
424+ and output tensor and calls `matches()` on each registered matcher. If your
425+ matcher claims a tensor, `createInputs()` is later called to instantiate the
426+ component that will populate it every cycle.
427+
428+ The pattern therefore involves three pieces:
429+
430+ | Piece | Responsibility |
431+ |-------|---------------|
432+ | Your data-source class | Owns the data and exposes `init` / `read` methods; independent of `CommandInterface` |
433+ | `CustomInput : public Input` | Bridges your data source to an ONNX input buffer |
434+ | `CustomMatcher : public Matcher` | Recognises the tensor by name, stores the match, and constructs `CustomInput` instances |
435+
436+ ### Example — feeding an arbitrary vector command
437+
438+ Suppose your model has an input tensor named `custom.planner.output` of shape
439+ `[1, N]` that should be filled from a motion planner. The naming convention
440+ `custom.<type>.<name>` groups tensors by type and identifies each by name.
441+ The built-in matchers ignore tensors with the `custom.*` prefix, so you need a
442+ custom matcher.
443+
444+ **Step 1 — define an interface for your data source**
445+
446+ ```cpp
447+ // Your own header — no dependency on exploy.
448+ class PlannerInterface {
449+ public:
450+ // Called once during init; return false to abort controller init.
451+ virtual bool initPlannerOutput(const std::string& output_name) = 0;
452+
453+ // Called every cycle; return std::nullopt when data is not yet available.
454+ virtual std::optional<std::vector<double>>
455+ plannerOutput(const std::string& output_name) const = 0;
456+ };
457+ ```
458+
459+ ** Step 2 — implement ` Input ` **
460+
461+ ``` cpp
462+ #include " exploy/components.hpp" // Input, OnnxRuntime
463+ #include " exploy/interfaces.hpp"
464+
465+ class PlannerInput : public exploy ::control::Input {
466+ public:
467+ PlannerInput(const std::string& tensor_key,
468+ const std::string& output_name,
469+ PlannerInterface& planner)
470+ : tensor_key_ (tensor_key),
471+ output_name_ (output_name),
472+ planner_ (planner) {}
473+
474+ // Called once by controller.init() — non-real-time.
475+ bool init(exploy::control::RobotStateInterface& /* state* /,
476+ exploy::control::CommandInterface& /* command* /) override {
477+ return planner_ .initPlannerOutput(output_name_ );
478+ }
479+
480+ // Called every cycle by controller.update() — real-time.
481+ bool read(exploy::control::OnnxRuntime& runtime,
482+ exploy::control::RobotStateInterface& /* state* /,
483+ exploy::control::CommandInterface& /* command* /) override {
484+ auto maybe_data = planner_ .plannerOutput(output_name_ );
485+ if (!maybe_data) return false;
486+
487+ auto maybe_buffer = runtime.inputBuffer<float>(tensor_key_);
488+ if (!maybe_buffer) return false;
489+
490+ if (maybe_buffer->size() != maybe_data->size()) return false;
491+ std::copy(maybe_data->begin(), maybe_data->end(), maybe_buffer->begin());
492+ return true;
493+ }
494+
495+ private:
496+ std::string tensor_key_ ;
497+ std::string output_name_ ;
498+ PlannerInterface& planner_ ;
499+ };
500+ ```
501+
502+ **Step 3 — implement `Matcher`**
503+
504+ The matcher uses the `custom.<type>.<name>` pattern. The regex captures the
505+ type segment (group 1) and the name segment (group 2). Here the matcher
506+ only handles the `planner` type; extend `matches()` to cover additional types
507+ as needed.
508+
509+ ```cpp
510+ #include "exploy/matcher.hpp"
511+ #include <regex>
512+
513+ class PlannerMatcher : public exploy::control::Matcher {
514+ public:
515+ explicit PlannerMatcher(PlannerInterface& planner) : planner_(planner) {}
516+
517+ // Called by controller.create() for every tensor in the ONNX model.
518+ bool matches(const exploy::control::Match& maybe_match) override {
519+ // Matches tensors of the form custom.<type>.<name>.
520+ static const std::regex kPattern(R"(custom\.([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+))");
521+ std::smatch m;
522+ if (std::regex_match(maybe_match.name, m, kPattern) && m.size() > 2) {
523+ const std::string& type = m[1].str(); // e.g. "planner"
524+ const std::string& name = m[2].str(); // e.g. "output"
525+ if (type == "planner") {
526+ found_matches_[name] = maybe_match;
527+ return true;
528+ }
529+ }
530+ return false;
531+ }
532+
533+ // Called after create() to instantiate inputs for every claimed tensor.
534+ std::vector<std::unique_ptr<exploy::control::Input>> createInputs() const override {
535+ std::vector<std::unique_ptr<exploy::control::Input>> inputs;
536+ for (const auto& [name, match] : found_matches_) {
537+ inputs.push_back(
538+ std::make_unique<PlannerInput>(match.name, name, planner_));
539+ }
540+ return inputs;
541+ }
542+
543+ private:
544+ PlannerInterface& planner_;
545+ // found_matches_ is inherited from exploy::control::Matcher
546+ };
547+ ```
548+
549+ ** Step 4 — register the matcher before ` controller.create() ` **
550+
551+ ``` cpp
552+ MyMotionPlanner planner; // implements PlannerInterface
553+
554+ exploy::control::OnnxRLController controller (state, command, data_collection);
555+
556+ // Must be registered before create() — matchers are evaluated inside create().
557+ controller.context().registerMatcher(std::make_unique<PlannerMatcher >(planner));
558+
559+ controller.create("/path/to/policy.onnx");
560+ controller.init(/* enable_data_collection=* /false);
561+ ```
562+
563+ After this, every tensor matching `custom.planner.<name>` is claimed by your
564+ matcher. For each such tensor `read()` is called once per `controller.update()`
565+ cycle, immediately before ONNX inference.
566+
567+ ### Key constraints
568+
569+ - **Register before `create()`** — matchers are only consulted during model
570+ loading. Registering after `create()` has no effect.
571+ - **Buffer element type** — use `runtime.inputBuffer<float>(key)` for `float`
572+ tensors. The ONNX runtime buffer type must match the tensor's element type in
573+ the model.
574+ - **Return value** — returning `false` from `read()` causes `controller.update()`
575+ to return `false` for that cycle, signalling a failed update to the caller.
576+ - **Custom matchers are evaluated after built-ins** — if a tensor name matches a
577+ built-in matcher it is claimed first and your matcher will not see it.
0 commit comments