Tutorial: Real-Time Control
Prerequisites
- Quick Start completed
- Basic familiarity with the
Nodetrait and Scheduler
What You'll Build
A robot controller with three nodes running at different rates and execution classes:
- Motor Controller at 1 kHz — real-time, safety-critical
- LiDAR Driver at 100 Hz — real-time sensor processing
- Path Planner at 10 Hz — compute-heavy, runs on a thread pool
Time estimate: ~20 minutes
Step 1: Define Three Nodes
Start with three basic nodes. All run as BestEffort on the main thread — no real-time yet.
// simplified
use horus::prelude::*;
struct MotorController {
cmd_sub: Topic<CmdVel>,
}
impl MotorController {
fn new() -> Result<Self> {
Ok(Self { cmd_sub: Topic::new("cmd_vel")? })
}
}
impl Node for MotorController {
fn name(&self) -> &str { "motor_ctrl" }
fn tick(&mut self) {
if let Some(cmd) = self.cmd_sub.recv() {
// Apply velocity command to motors
}
}
fn enter_safe_state(&mut self) {
// SAFETY: zero velocity and engage brakes
}
}
struct LidarDriver {
scan_pub: Topic<LaserScan>,
}
impl LidarDriver {
fn new() -> Result<Self> {
Ok(Self { scan_pub: Topic::new("lidar.scan")? })
}
}
impl Node for LidarDriver {
fn name(&self) -> &str { "lidar" }
fn tick(&mut self) {
let scan = LaserScan::new();
self.scan_pub.send(scan);
}
}
struct PathPlanner {
scan_sub: Topic<LaserScan>,
cmd_pub: Topic<CmdVel>,
}
impl PathPlanner {
fn new() -> Result<Self> {
Ok(Self {
scan_sub: Topic::new("lidar.scan")?,
cmd_pub: Topic::new("cmd_vel")?,
})
}
}
impl Node for PathPlanner {
fn name(&self) -> &str { "planner" }
fn tick(&mut self) {
if let Some(scan) = self.scan_sub.recv() {
let cmd = CmdVel::new(0.3, 0.0);
self.cmd_pub.send(cmd);
}
}
}
You should be able to compile this with horus run — all three nodes tick on the main thread with no timing guarantees.
Step 2: Add Rates
Adding .rate() to a BestEffort node does three things automatically:
- Sets the tick frequency
- Derives a default budget (80% of the period)
- Derives a default deadline (95% of the period)
- Promotes the node to the RT execution class
// simplified
use horus::prelude::*;
let mut scheduler = Scheduler::new();
scheduler.add(MotorController::new()?)
.order(0)
.rate(1000_u64.hz()) // 1 kHz → budget=800us, deadline=950us, auto-RT
.build()?;
scheduler.add(LidarDriver::new()?)
.order(10)
.rate(100_u64.hz()) // 100 Hz → budget=8ms, deadline=9.5ms, auto-RT
.build()?;
scheduler.add(PathPlanner::new()?)
.order(50)
.rate(10_u64.hz()) // 10 Hz → budget=80ms, deadline=95ms, auto-RT
.build()?;
Notice all three nodes are now RT. That is correct for the motor and LiDAR, but the path planner does not need hard real-time — we fix that in Step 4.
Step 3: Add Safety Policies
If the motor controller misses a deadline, the arm could overshoot and collide. The Miss enum controls what happens:
| Variant | Behavior | Use case |
|---|---|---|
Miss::Warn | Log a warning, continue | Soft real-time — logging, UI |
Miss::Skip | Drop the late tick, run next on schedule | Firm real-time — sensors |
Miss::SafeMode | Call enter_safe_state() on the node | Motor controllers — must zero output |
Miss::Stop | Shut down the entire scheduler | Safety-critical — unacceptable to continue |
Apply safety to the motor controller:
// simplified
use horus::prelude::*;
scheduler.add(MotorController::new()?)
.order(0)
.rate(1000_u64.hz())
.budget(800_u64.us()) // override auto-derived budget
.deadline(950_u64.us()) // override auto-derived deadline
.on_miss(Miss::SafeMode) // zero velocity on deadline miss
.build()?;
This means: if the motor controller exceeds its 950 us deadline, the scheduler calls enter_safe_state() (which zeros velocity and engages brakes).
The LiDAR is less critical — a missed scan is suboptimal but not dangerous:
// simplified
use horus::prelude::*;
scheduler.add(LidarDriver::new()?)
.order(10)
.rate(100_u64.hz())
.on_miss(Miss::Skip) // drop late ticks, planner uses previous scan
.build()?;
Step 4: Move the Planner to Compute
The path planner runs complex algorithms that benefit from parallel execution. Use .compute() to move it off the RT thread:
// simplified
use horus::prelude::*;
scheduler.add(PathPlanner::new()?)
.order(50)
.compute() // runs on worker thread pool, not RT thread
.rate(10_u64.hz()) // rate-limited but no RT enforcement
.build()?;
With .compute(), the planner runs on a worker thread. This prevents a slow planning cycle from blocking the 1 kHz motor loop. Note that .rate() still limits frequency but does NOT enforce RT budget/deadline on Compute nodes.
Step 5: Enable RT on the Scheduler
For production deployments, enable real-time OS scheduling on the scheduler itself:
// simplified
use horus::prelude::*;
let mut scheduler = Scheduler::new()
.prefer_rt() // request SCHED_FIFO if available
.watchdog(500_u64.ms()) // detect frozen nodes
.max_deadline_misses(5) // isolate after 5 consecutive misses
.tick_rate(1000_u64.hz()); // global tick rate
.prefer_rt()requests real-time OS scheduling (SCHED_FIFO + mlockall on Linux). Falls back gracefully if permissions are unavailable..watchdog()enables frozen node detection with graduated degradation..max_deadline_misses()sets the threshold before a node is isolated.
For per-node CPU pinning, use .core() on the node builder:
// simplified
use horus::prelude::*;
scheduler.add(MotorController::new()?)
.order(0)
.rate(1000_u64.hz())
.budget(800_u64.us())
.deadline(950_u64.us())
.on_miss(Miss::SafeMode)
.core(0) // pin to CPU core 0
.build()?;
scheduler.add(LidarDriver::new()?)
.order(10)
.rate(100_u64.hz())
.on_miss(Miss::Skip)
.core(1) // separate core from motor controller
.build()?;
Step 6: Complete System
Here is the full program with all nodes configured:
// simplified
use horus::prelude::*;
fn main() -> Result<()> {
let mut scheduler = Scheduler::new()
.prefer_rt()
.watchdog(500_u64.ms())
.max_deadline_misses(5)
.tick_rate(1000_u64.hz());
// 1 kHz motor controller — safety-critical, RT, pinned to core 0
scheduler.add(MotorController::new()?)
.order(0)
.rate(1000_u64.hz())
.budget(800_u64.us())
.deadline(950_u64.us())
.on_miss(Miss::SafeMode)
.core(0)
.build()?;
// 100 Hz LiDAR — RT, pinned to core 1
scheduler.add(LidarDriver::new()?)
.order(10)
.rate(100_u64.hz())
.on_miss(Miss::Skip)
.core(1)
.build()?;
// 10 Hz path planner — compute thread pool
scheduler.add(PathPlanner::new()?)
.order(50)
.compute()
.rate(10_u64.hz())
.build()?;
scheduler.run()
}
You should see the system run. Press Ctrl+C to stop — the timing report shows budget/deadline statistics for each node.
Key Takeaways
.rate()implies RT — auto-derives budget (80%), deadline (95%), and promotes to RT execution class. Override with.budget()/.deadline()as needed.- Safety is explicit — always set
.on_miss()for safety-critical nodes.Miss::SafeModecallsenter_safe_state().Miss::Skipdrops late ticks. - Separate by execution class — keep fast control loops on RT threads, move heavy computation to
.compute() .prefer_rt()over.require_rt()— degrades gracefully during development, use.require_rt()only in production where running without RT is unacceptable- CPU pinning —
.core(n)on the node builder prevents OS thread migration and cache thrashing
Next Steps
- Deterministic Mode — reproducible simulations with fixed timesteps
- Safety Monitor — graduated watchdog and health states
- RT Setup — Linux RT kernel configuration
See Also
- Scheduler API — Full reference for all builder methods
- Execution Classes — How RT auto-detection works
- Real-Time Concepts — Why real-time matters for robotics
- Miss Enum — All deadline miss policies