Skip to content

Commit d3f4bc8

Browse files
awoll-bdaiexploy-bot
authored andcommitted
Add example controller implementation (#70)
# Pull Request ### What change is being made Add an example controller implementation. Run the example with the exported policy from the isaaclab example in CI. ### Why this change is being made Better documentation and end to end testing. ### Tested Added to CI. GitOrigin-RevId: b3766c6dc1742a8eede769232330c7433ebe1a01
1 parent abeea95 commit d3f4bc8

10 files changed

Lines changed: 945 additions & 349 deletions

File tree

.github/workflows/test.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ jobs:
7575

7676

7777
test-isaaclab-export:
78-
name: Test Export IsaacLab
78+
name: Test Export IsaacLab and Controller Example
7979
runs-on: github-gpu-runner
8080
timeout-minutes: 60
8181
steps:
@@ -88,13 +88,18 @@ jobs:
8888
pixi-version: latest
8989
cache: true
9090
cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
91-
environments: isaaclab
91+
environments: >-
92+
isaaclab
93+
controller
9294
9395
- name: Run Isaac Lab Export Test
9496
env:
9597
OMNI_KIT_ACCEPT_EULA: yes
9698
run: pixi run -e isaaclab export-isaaclab-ci
9799

100+
- name: Run Controller Loopback Example
101+
run: pixi run -e controller run-example
102+
98103

99104
test-frameworks-isaaclab:
100105
name: Test Frameworks - IsaacLab

control/context.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ bool OnnxContext::createContext(OnnxRuntime& onnx_model, bool strict) {
6565
return false;
6666
}
6767

68-
if (!metadata::checkExployVersion(onnx_model.getCustomMetadata("exploy_version"))) return false;
68+
// TODO: enable strict mode to ensure version compatibility.
69+
if (!metadata::checkExployVersion(onnx_model.getCustomMetadata("exploy_version"),
70+
/*strict*/ false))
71+
return false;
6972

7073
std::optional<int> maybe_update_rate = parseUpdateRate(onnx_model);
7174
if (!maybe_update_rate.has_value()) return false;

control/metadata.hpp

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -289,37 +289,51 @@ inline std::optional<Version> parseVersion(const std::string& s) {
289289
*
290290
* @param maybe_version_str The value of the "exploy_version" metadata key, or std::nullopt if
291291
* absent.
292+
* @param strict If true, treat mismatch as a failure. If false, only log a warning and return true.
292293
* @return true if the version is present and within the supported range, false otherwise.
293294
*/
294-
inline bool checkExployVersion(const std::optional<std::string>& maybe_version_str) {
295+
inline bool checkExployVersion(const std::optional<std::string>& maybe_version_str,
296+
bool strict = true) {
297+
const auto log = [&](const std::string& msg) {
298+
if (strict) {
299+
LOG_STREAM(ERROR, msg);
300+
} else {
301+
LOG_STREAM(WARN, msg);
302+
}
303+
};
304+
295305
if (!maybe_version_str.has_value()) {
296-
LOG(ERROR,
297-
"ONNX model does not contain 'exploy_version' metadata. The ONNX file is not "
298-
"compatible with this controller.");
299-
return false;
306+
log("ONNX model does not contain 'exploy_version' metadata. "
307+
"The ONNX file might not be compatible with this controller.");
308+
return !strict;
300309
}
301310

302311
std::string version_str;
303312
try {
304313
version_str = json::parse(maybe_version_str.value()).get<std::string>();
305314
} catch (const json::exception&) {
306-
LOG_STREAM(ERROR, "Failed to parse exploy_version: '" << maybe_version_str.value() << "'.");
307-
return false;
315+
log(
316+
fmt::format("Failed to JSON parse exploy_version: '{}'. "
317+
"The ONNX file might not be compatible with this controller.",
318+
maybe_version_str.value()));
319+
return !strict;
308320
}
309321

310322
auto maybe_version = parseVersion(version_str);
311323
if (!maybe_version.has_value()) {
312-
LOG_STREAM(ERROR, "Failed to parse exploy_version: '" << version_str << "'.");
313-
return false;
324+
log(
325+
fmt::format("Failed to parse exploy_version: '{}'. "
326+
"The ONNX file might not be compatible with this controller.",
327+
version_str));
328+
return !strict;
314329
}
315330

316331
const auto& v = maybe_version.value();
317332
const bool in_range = kMinSupportedExployVersion <= v && v <= kMaxSupportedExployVersion;
318333
if (!in_range) {
319-
LOG_STREAM(ERROR, "exploy_version '" << version_str << "' is outside the supported range ["
320-
<< kMinSupportedExployVersion.toString() << ", "
321-
<< kMaxSupportedExployVersion.toString() << "].");
322-
return false;
334+
log(fmt::format("exploy_version '{}' is outside the supported range [{}, {}].", version_str,
335+
kMinSupportedExployVersion.toString(), kMaxSupportedExployVersion.toString()));
336+
return !strict;
323337
}
324338
return true;
325339
}

examples/controller/CMakeLists.txt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright (c) 2026 Robotics and AI Institute LLC dba RAI Institute. All rights reserved.
2+
3+
cmake_minimum_required(VERSION 3.20)
4+
project(loopback_controller_example LANGUAGES CXX)
5+
6+
set(CMAKE_CXX_STANDARD 20)
7+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
8+
9+
set(EXPLOY_ROOT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.."
10+
CACHE PATH "Path to the exploy project root")
11+
12+
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
13+
add_subdirectory(
14+
"${EXPLOY_ROOT_DIR}/control"
15+
"${CMAKE_CURRENT_BINARY_DIR}/exploy_control"
16+
EXCLUDE_FROM_ALL
17+
)
18+
19+
add_executable(loopback_controller_example
20+
main.cpp
21+
)
22+
23+
target_include_directories(loopback_controller_example PRIVATE
24+
# The example's own headers (loopback_state_interface.hpp etc.)
25+
"${CMAKE_CURRENT_SOURCE_DIR}"
26+
)
27+
28+
target_link_libraries(loopback_controller_example PRIVATE
29+
exploy
30+
)
31+
32+
target_compile_options(loopback_controller_example PRIVATE
33+
-Wall -Wextra -Wunused-variable -Wunused-parameter
34+
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 Robotics and AI Institute LLC dba RAI Institute. All rights reserved.
2+
#pragma once
3+
4+
#include <optional>
5+
#include <string>
6+
7+
#include "command_interface.hpp"
8+
#include "interfaces.hpp"
9+
10+
namespace exploy::control::examples {
11+
12+
/**
13+
* @brief Configuration for the fixed command interface.
14+
*/
15+
struct FixedCommandConfig {
16+
// SE(2) velocity command [vx (m/s), vy (m/s), omega_z (rad/s)].
17+
SE2Velocity se2_velocity{0.5, 0.0, 0.0};
18+
};
19+
20+
/**
21+
* @class FixedCommandInterface
22+
*
23+
* @brief A CommandInterface that returns a static SE(2) velocity command.
24+
*
25+
* Only intended for the example where no external operator input is
26+
* available. Returns a configurable SE(2) velocity command (linear and angular).
27+
*/
28+
class FixedCommandInterface : public CommandInterface {
29+
public:
30+
explicit FixedCommandInterface(FixedCommandConfig config = {}) : config_(std::move(config)) {}
31+
32+
33+
bool initSe2Velocity(const std::string& /*command_name*/,
34+
const SE2VelocityConfig& /*config*/) override {
35+
return true;
36+
}
37+
38+
std::optional<SE2Velocity> se2Velocity(const std::string& /*command_name*/) override {
39+
return config_.se2_velocity;
40+
}
41+
42+
void setSe2Velocity(const SE2Velocity& velocity) { config_.se2_velocity = velocity; }
43+
44+
const SE2Velocity& se2Velocity() const { return config_.se2_velocity; }
45+
46+
private:
47+
FixedCommandConfig config_{};
48+
};
49+
50+
} // namespace exploy::control::examples
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 Robotics and AI Institute LLC dba RAI Institute. All rights reserved.
2+
#pragma once
3+
4+
/**
5+
* @file loopback_state_interface.hpp
6+
* @brief RobotStateInterface that feeds commanded values back as measured state.
7+
*
8+
* This is intentionally minimal: every init*() succeeds, every getter returns a
9+
* sensible default (zeros / identity), and the set*() methods store the commanded
10+
* value so that it is returned the following cycle — modelling a perfect,
11+
* zero-delay actuator.
12+
*/
13+
14+
#include <span>
15+
#include <string>
16+
#include <unordered_map>
17+
#include <vector>
18+
#include <cmath>
19+
#include <utility>
20+
21+
#include "state_interface.hpp"
22+
23+
namespace exploy::control::examples {
24+
25+
/**
26+
* @brief Per-joint loopback state (position, velocity, effort).
27+
*/
28+
struct JointState {
29+
double position{0.0};
30+
double velocity{0.0};
31+
double effort{0.0};
32+
};
33+
34+
/**
35+
* @brief Concrete RobotStateInterface with perfect loopback semantics.
36+
*
37+
* Commanded joint targets written via setJoint*() are returned by
38+
* jointPosition() / jointVelocity() / jointEffort() on the next query,
39+
* modelling a zero-latency, perfectly tracking actuator.
40+
*
41+
* All body/IMU/base state values are fixed at identity / zero so that an
42+
* ONNX policy can run without a physics simulation.
43+
*/
44+
class LoopbackRobotStateInterface : public RobotStateInterface {
45+
public:
46+
bool initBasePosW() override { return true; }
47+
bool initBaseQuatW() override { return true; }
48+
49+
std::optional<Position> basePosW() const override {
50+
return Position{0.0, 0.0, 0.8};
51+
}
52+
std::optional<Quaternion> baseQuatW() const override {
53+
return Quaternion::Identity();
54+
}
55+
56+
bool initBaseLinVelB() override { return true; }
57+
bool initBaseAngVelB() override { return true; }
58+
59+
std::optional<LinearVelocity> baseLinVelB() const override {
60+
return LinearVelocity::Zero();
61+
}
62+
std::optional<AngularVelocity> baseAngVelB() const override {
63+
return AngularVelocity::Zero();
64+
}
65+
66+
bool initJointPosition(const std::string& joint_name) override {
67+
joint_states_.emplace(joint_name, JointState{});
68+
return true;
69+
}
70+
bool initJointVelocity(const std::string& joint_name) override {
71+
joint_states_.emplace(joint_name, JointState{});
72+
return true;
73+
}
74+
bool initJointEffort(const std::string& joint_name) override {
75+
joint_states_.emplace(joint_name, JointState{});
76+
return true;
77+
}
78+
bool initJointOutput(const std::string& /*joint_name*/) override {
79+
return true;
80+
}
81+
82+
std::optional<double> jointPosition(const std::string& joint_name) const override {
83+
if (joint_states_.contains(joint_name)) {
84+
return joint_states_.at(joint_name).position;
85+
}
86+
return std::nullopt;
87+
}
88+
std::optional<double> jointVelocity(const std::string& joint_name) const override {
89+
if (joint_states_.contains(joint_name)) {
90+
return joint_states_.at(joint_name).velocity;
91+
}
92+
return std::nullopt;
93+
}
94+
std::optional<double> jointEffort(const std::string& joint_name) const override {
95+
if (joint_states_.contains(joint_name)) {
96+
return joint_states_.at(joint_name).effort;
97+
}
98+
return std::nullopt;
99+
}
100+
101+
/// Loopback: store the commanded position so it is returned next cycle.
102+
bool setJointPosition(const std::string& joint_name, double position) override {
103+
joint_states_[joint_name].position = position;
104+
return true;
105+
}
106+
bool setJointVelocity(const std::string& joint_name, double velocity) override {
107+
joint_states_[joint_name].velocity = velocity;
108+
return true;
109+
}
110+
bool setJointEffort(const std::string& joint_name, double effort) override {
111+
joint_states_[joint_name].effort = effort;
112+
return true;
113+
}
114+
bool setJointPGain(const std::string& /*joint_name*/, double /*p_gain*/) override {
115+
return true;
116+
}
117+
bool setJointDGain(const std::string& /*joint_name*/, double /*d_gain*/) override {
118+
return true;
119+
}
120+
121+
// Read-only access to the full joint state map (used by main.cpp for logging).
122+
const std::unordered_map<std::string, JointState>& jointStates() const {
123+
return joint_states_;
124+
}
125+
126+
bool initSe2Velocity(const std::string& frame_name) override {
127+
se2_velocities_.emplace(frame_name, SE2Velocity::Zero());
128+
return true;
129+
}
130+
bool setSe2Velocity(const std::string& frame_name, const SE2Velocity& velocity) override {
131+
se2_velocities_[frame_name] = velocity;
132+
return true;
133+
}
134+
135+
// Read-only access to all SE(2) velocity outputs (used by main.cpp for logging).
136+
const std::unordered_map<std::string, SE2Velocity>& se2VelocityOutputs() const {
137+
return se2_velocities_;
138+
}
139+
140+
bool initImuOrientationW(const std::string& /*imu_name*/) override { return true; }
141+
142+
std::optional<Quaternion> imuOrientationW(const std::string& /*imu_name*/) const override {
143+
return Quaternion::Identity();
144+
}
145+
146+
bool initBodyPositionW(const std::string& /*body_name*/) override { return true; }
147+
bool initBodyOrientationW(const std::string& /*body_name*/) override { return true; }
148+
149+
std::optional<Position> bodyPositionW(const std::string& /*body_name*/) const override {
150+
return Position{0.0, 0.0, 0.8};
151+
}
152+
std::optional<Quaternion> bodyOrientationW(const std::string& /*body_name*/) const override {
153+
return Quaternion::Identity();
154+
}
155+
156+
bool initHeightScan(const std::string& sensor_name,
157+
const HeightScanConfig& config) override {
158+
// Pre-allocate zero-filled layers sized by the grid formula.
159+
const auto& p = config.pattern;
160+
const std::size_t nx = static_cast<std::size_t>(std::round(p.size.x() / p.resolution)) + 1;
161+
const std::size_t ny = static_cast<std::size_t>(std::round(p.size.y() / p.resolution)) + 1;
162+
const std::size_t n = nx * ny;
163+
164+
HeightScan scan;
165+
for (const auto& layer : config.layer_names) {
166+
scan.layers[layer] = std::vector<double>(n, 0.0);
167+
}
168+
height_scans_[sensor_name] = std::move(scan);
169+
return true;
170+
}
171+
172+
std::optional<HeightScan*> heightScan(
173+
const std::string& sensor_name,
174+
const std::unordered_set<std::string>& /*layer_names*/,
175+
const Position& /*base_pos_w*/,
176+
const Quaternion& /*base_quat_w*/) override {
177+
if (!height_scans_.contains(sensor_name)) {
178+
return std::nullopt;
179+
}
180+
return &height_scans_.at(sensor_name);
181+
}
182+
183+
private:
184+
std::unordered_map<std::string, JointState> joint_states_;
185+
std::unordered_map<std::string, SE2Velocity> se2_velocities_;
186+
std::unordered_map<std::string, HeightScan> height_scans_;
187+
};
188+
189+
} // namespace exploy::control::examples

0 commit comments

Comments
 (0)