Real-time integration guide
Recommended topology
Production systems use the ADR-0007 two-domain model:
- The planning thread is the sole writer of
AxisGroupandAxisModel. It drains commands, runs function blocks andcycle(), and fills the committed-trajectory ring. - The RT thread pops exactly one frame per cycle, calls the narrow drive interface, and publishes feedback.
- The domains share only four SPSC queues: commands, committed trajectory, feedback, and state snapshots.
The RT thread does not call submit_*, look-ahead, kinematics solvers, or
planning functions that may grow storage. If planning slows down, look-ahead
depth falls; the RT cycle never waits for the planning thread. See
core/demo/rt_executor_demo.cpp for the reference implementation.
Cycle skeleton
// Planning domain owns these objects for their entire lifetime.
axis::AxisModel axis;
adapters::ServoSim servo;
// Planning domain: consume commands, call FBs, run cycle(), publish a frame.
axis.cycle();
// RT domain: consume exactly one committed frame and bridge feedback.
// Do not call planning APIs from this thread.
Synchronized joint command frames
stream::JointStreamGroup provides the fixed-capacity H1 command primitive
for up to 48 joints. The group is planning-domain-owned and single-threaded:
push_frame(), cycle(), and read_setpoint_frame() must all run on the
owning planning thread, and a producer on any other thread must hand frames
over through its own SPSC queue first. Configure and reset every member before
the session, then submit complete frames through push_frame():
stream::JointStreamGroupConfig config{};
config.joint_count = 2;
config.mode = stream::JointFrameMode::direct;
// Fill every config.joints[i] filter, torque, gain, and safe-gain limit.
stream::JointStreamGroup joint_stream;
joint_stream.configure_frame(config);
joint_stream.reset(0, {});
joint_stream.reset(1, {});
stream::JointCommandFrame frame{};
frame.joint_count = 2;
frame.timestamp_cycles = 1; // ordering/echo only
frame.joints[0] = {0.2, 0.01, 0.0, 8.0, 2.0};
frame.joints[1] = {-0.2, -0.01, 0.0, 8.0, 2.0};
joint_stream.push_frame(frame);
joint_stream.cycle();
const auto &command = joint_stream.read_setpoint_frame();
The producer timestamp never schedules a future activation: the owning local
cycle controls activation and watchdog age. direct exposes accepted q/dq
fields together in the next cycle and does not claim jerk-limited smoothing.
upsample reuses the 1D OTG filter; fast-path joints respond together, while
slow replans are limited to 10 joints per cycle and may take up to five cycles
across a full 48-joint frame. Invalid member data rejects the whole frame.
The returned frame is a command snapshot, not actual feedback. H1 carries
tau_ff but does not authorize a drive adapter or executor to apply it; that
requires the separate T18 torque-limit, velocity-supervision, and position-
fence contract.
Fixed-base dynamics feedforward
dyn::FixedBaseChain computes pure feedforward torque for one caller-owned
1–8 joint revolute chain. Build and validate the model during initialization,
then call the bounded O(n) RNEA with SI quantities:
dyn::FixedBaseChain chain(spec);
if (!chain.valid()) {
// Reject the configuration before starting the cyclic task.
}
double tau_ff[8]{};
const double gravity[3]{0.0, 0.0, -9.80665};
const auto error =
chain.inverse_dynamics(q_rad, dq_rad_s, ddq_rad_s2, gravity, tau_ff);
An error leaves tau_ff unchanged. Separate arms, legs, and torso chains use
separate instances. H3 only produces numbers; a planning-domain producer may
copy them into an H1 command frame after unit and ownership checks, but the
drive/executor must continue ignoring tau_ff until T18 defines and verifies
torque limiting, velocity supervision, and position fencing.
For planning-domain Python experiments, the current source provides a narrow q/dq-only facade over the same primitive:
import pyplcopen
stream = pyplcopen.JointStreamSim(
7, "upsample",
velocity_limit=0.8,
acceleration_limit=0.08,
jerk_limit=0.02,
)
stream.reset([0.0] * 7)
stream.push_frame([0.1] * 7, timestamp_cycles=1, velocities=[0.0] * 7)
stream.cycle(10)
command = stream.setpoint_frame()
JointStreamSim validates every Python vector before touching the group and
rejects an invalid frame atomically. It is not a cross-thread RT API and does
not expose tau_ff, kp, or kd; use the C++ primitive for integration and
keep torque application disabled until the separate T18 contract closes.
Real EtherCAT access, thread scheduling, clock synchronization, and bus I/O
live outside this repository. At the cycle boundary, the host executor calls
adapters::Servo and returns feedback through hooks such as
set_actual_feedback() and set_digital_input(). The current Feetech STS
adapter is a software protocol layer only: it has no serial I/O and does not
replace an industrial real-time bus.
RT checklist
- No heap allocation, blocking lock, exception, or system call on the cyclic path.
- Use an integer cycle counter for time; never accumulate floating-point time.
- Prepare fixed-capacity containers during initialization or in the planning domain. RT reads only committed data.
- Drive adapters implement the narrow interface; do not move bus objects or thread ownership into the core.
- Feedback, commands, and snapshots use single-writer queues. Never share a
mutable
AxisModelbetween threads.
Run before submitting:
cmake --build build-sync --config Debug
ctest --test-dir build-sync -C Debug --output-on-failure
cmake -P cmake/rt_safety_scan.cmake
If a planning-domain submission fails, retain the current committed
trajectory and record its rt::ErrorCode; do not retry the same submission
from the RT thread. If the committed ring runs dry, the host monitors the ring
level and enters its own safety policy instead of blocking the drive thread
while it waits for planning.