Tutorial: Real-Time Control

Looking for the Python version? See Tutorial: Real-Time Control (Python).

Prerequisites

  • Quick Start completed
  • Basic familiarity with the Node trait and Scheduler

What You'll Build

A robot controller with three nodes running at different rates and execution classes:

  1. Motor Controller at 1 kHz — real-time, safety-critical
  2. LiDAR Driver at 100 Hz — real-time sensor processing
  3. 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:

  1. Sets the tick frequency
  2. Derives a default budget (80% of the period)
  3. Derives a default deadline (95% of the period)
  4. 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:

VariantBehaviorUse case
Miss::WarnLog a warning, continueSoft real-time — logging, UI
Miss::SkipDrop the late tick, run next on scheduleFirm real-time — sensors
Miss::SafeModeCall enter_safe_state() on the nodeMotor controllers — must zero output
Miss::StopShut down the entire schedulerSafety-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::SafeMode calls enter_safe_state(). Miss::Skip drops 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

See Also