Skip to content

Commit 559484e

Browse files
awoll-bdaiexploy-bot
authored andcommitted
Add documentation for custom commands
### What change is being made Add documentation on how to support a custom command in the controller. ### Why this change is being made Custom commands are a common use case. ### Tested n/a GitOrigin-RevId: 3ce5427e1d4819062b10616171ce21d2c5063f7f
1 parent 97bf516 commit 559484e

1 file changed

Lines changed: 170 additions & 4 deletions

File tree

docs/tutorial/controller/controller_tutorial.md

Lines changed: 170 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,7 @@ matches the training configuration.
321321
322322
---
323323
324-
## Advanced Topics
325-
326-
### Custom matchers
324+
## Advanced: Custom Matchers
327325
328326
The built-in matchers cover the standard IsaacLab tensor naming conventions.
329327
If 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

369367
By default the controller logs to stdout. To redirect log messages to your own
370368
logging 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.
411409
Pass `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

Comments
 (0)