Parameters Guide

Runtime parameters in HORUS provide dynamic configuration without recompiling code. Adjust speeds, gains, thresholds, and behaviors on-the-fly for rapid prototyping and tuning.

Why Parameters?

Without parameters:

// Hardcoded - requires recompile to change
let max_speed = 1.5;
let pid_kp = 1.0;

With parameters:

// Dynamic - change at runtime via dashboard or CLI
let max_speed = ctx.params.get_f64("motion.max_speed", 1.5);
let pid_kp = ctx.params.get_f64("control.pid.kp", 1.0);

Benefits:

  • No recompilation - Change values without rebuilding
  • Live tuning - Adjust while robot is running
  • Persistence - Saved to disk automatically
  • Sharing - Export/import parameter sets
  • Safety - Fallback to defaults if missing

Core Concepts

Parameter Storage

Parameters are stored in a thread-safe map:

Arc<RwLock<BTreeMap<String, Value>>>

Location: ~/.horus/config/params.yaml

Format:

motion:
  max_speed: 1.5
  acceleration: 0.5
  deceleration: 0.8
control:
  pid:
    kp: 1.0
    ki: 0.1
    kd: 0.01
safety:
  emergency_stop: false
  collision_threshold: 0.3
sensors:
  lidar_rate_hz: 10
  camera_resolution: [1920, 1080]

Parameter Types

HORUS supports all JSON-compatible types:

  • Numbers - f64, i64, u64 (stored as Value::Number)
  • Strings - String (stored as Value::String)
  • Booleans - bool (stored as Value::Bool)
  • Arrays - Vec<T> (stored as Value::Array)
  • Objects - HashMap<String, T> (stored as Value::Object)

Hierarchical Keys

Use dot notation for organization:

// Good: Hierarchical structure
ctx.params.get_f64("motion.max_speed", 1.5);
ctx.params.get_f64("control.pid.kp", 1.0);
ctx.params.get_i32("sensors.lidar_rate_hz", 10);

// Avoid: Flat structure
ctx.params.get_f64("max_speed", 1.5);
ctx.params.get_f64("kp", 1.0);

Benefits:

  • Clear organization
  • Avoid name collisions
  • Easy to export/import subsets
  • Natural grouping in dashboard

Using Parameters in Nodes

Accessing Parameters

Parameters are available via the NodeInfo context:

node! {
    VelocityController {
        data {
            max_speed: f64 = 1.5,
            acceleration: f64 = 0.5
        }

        init(ctx) {
            // Load parameters on initialization
            self.max_speed = ctx.params.get_f64("motion.max_speed", 1.5);
            self.acceleration = ctx.params.get_f64("motion.acceleration", 0.5);

            ctx.log_info(&format!("Max speed: {} m/s", self.max_speed));
            ctx.log_info(&format!("Acceleration: {} m/s²", self.acceleration));
        }

        tick(ctx) {
            // Use parameters
            let target_velocity = self.max_speed;
            // ...
        }
    }
}

Parameter Methods

Get with default:

// If parameter doesn't exist, returns default value
let speed = ctx.params.get_f64("motion.max_speed", 1.5);
let enabled = ctx.params.get_bool("features.auto_mode", false);
let rate = ctx.params.get_i32("sensors.update_rate_hz", 60);

Get optional:

// Returns Option<T> - None if parameter doesn't exist
if let Some(speed) = ctx.params.get::<f64>("motion.max_speed") {
    self.max_speed = speed;
}

Set parameter:

// Update parameter value
ctx.params.set("motion.max_speed", 2.0).ok();

// Set complex types
ctx.params.set("sensors.camera_resolution", vec![1920, 1080]).ok();

Type-specific getters:

// Convenience methods with type inference
let max_speed: f64 = ctx.params.get_f64("motion.max_speed", 1.5);
let node_name: String = ctx.params.get_string("node.name", "default");
let enabled: bool = ctx.params.get_bool("features.enabled", true);
let count: i32 = ctx.params.get_i32("samples.count", 100);
let id: u64 = ctx.params.get_u64("node.id", 0);

Live Reloading

Check for updates every tick:

node! {
    AdaptiveController {
        data {
            max_speed: f64 = 1.5,
            last_speed_check: u64 = 0
        }

        tick(ctx) {
            // Check every 60 ticks (~1 second at 60 Hz)
            if self.last_speed_check % 60 == 0 {
                // Reload parameter
                let new_speed = ctx.params.get_f64("motion.max_speed", 1.5);

                if new_speed != self.max_speed {
                    ctx.log_info(&format!("Speed updated: {}  {}", self.max_speed, new_speed));
                    self.max_speed = new_speed;
                }
            }

            self.last_speed_check += 1;
        }
    }
}

Performance note: Parameter access is fast (~80-350ns via Arc<RwLock>), but avoid reading hundreds of parameters every tick. Cache values and reload periodically.

Complex Parameter Types

Arrays:

// Set array
ctx.params.set("waypoints", vec![1.0, 2.5, 3.0, 4.5]).ok();

// Get array
let waypoints: Vec<f64> = ctx.params
    .get::<Vec<serde_json::Value>>("waypoints")
    .map(|v| v.iter().filter_map(|x| x.as_f64()).collect())
    .unwrap_or_default();

Objects:

use serde_json::json;

// Set nested object
let config = json!({
    "ip": "192.168.1.100",
    "port": 8080,
    "timeout_ms": 5000
});
ctx.params.set("network.config", config).ok();

// Get nested values
let ip = ctx.params.get_string("network.config.ip", "localhost");
let port = ctx.params.get_i32("network.config.port", 8080);

Default Parameters

HORUS provides sensible defaults on first run:

# ~/.horus/config/params.yaml (auto-generated)
motion:
  max_linear_velocity: 1.0
  max_angular_velocity: 1.0
  acceleration_limit: 0.5

sensors:
  lidar_rate_hz: 10
  camera_rate_hz: 30
  imu_rate_hz: 100

safety:
  emergency_stop_enabled: true
  collision_threshold: 0.5
  max_tilt_angle_deg: 30.0

control:
  pid_linear:
    kp: 1.0
    ki: 0.1
    kd: 0.01
  pid_angular:
    kp: 1.0
    ki: 0.1
    kd: 0.01

Customization:

  1. File is created on first launch
  2. Edit directly or use dashboard
  3. Changes persist across restarts
  4. Delete file to reset to defaults

Managing Parameters

Via Dashboard

Web interface (easiest method):

# Start dashboard
horus dashboard

# Navigate to Parameters tab
#  View all parameters
#  Edit values inline
#  Changes auto-save to disk

Features:

  • Live editing with validation
  • Type indicators (number/string/boolean)
  • Export entire parameter set
  • Import from YAML/JSON
  • Delete individual parameters

See Dashboard Guide for API details.

Via Code

Manual save:

// RuntimeParams saves automatically on set()
// But you can force a save
ctx.params.save_to_disk().ok();

Manual load:

// Reload from disk (useful after external edit)
ctx.params.load_from_disk().ok();

Via File Edit

Direct YAML editing:

# Edit parameters file
vim ~/.horus/config/params.yaml

# Changes take effect:
# - Immediately if node reloads
# - On next restart if not

Format:

# Use spaces (2 or 4), not tabs
motion:
  max_speed: 2.0        # number
  mode: "auto"          # string (quotes optional for simple strings)
  enabled: true         # boolean

sensors:
  rates: [10, 30, 100]  # array

# Comments are preserved
control:
  pid:
    kp: 1.0   # Proportional gain
    ki: 0.1   # Integral gain
    kd: 0.01  # Derivative gain

Common Patterns

PID Controller Tuning

node! {
    PIDController {
        data {
            kp: f64 = 1.0,
            ki: f64 = 0.1,
            kd: f64 = 0.01,
            integral: f64 = 0.0,
            last_error: f64 = 0.0
        }

        init(ctx) {
            // Load PID gains
            self.kp = ctx.params.get_f64("control.pid.kp", 1.0);
            self.ki = ctx.params.get_f64("control.pid.ki", 0.1);
            self.kd = ctx.params.get_f64("control.pid.kd", 0.01);

            ctx.log_info(&format!("PID: Kp={}, Ki={}, Kd={}", self.kp, self.ki, self.kd));
        }

        tick(ctx) {
            // Compute PID output
            let error = self.compute_error();
            self.integral += error;
            let derivative = error - self.last_error;

            let output = self.kp * error + self.ki * self.integral + self.kd * derivative;

            self.last_error = error;

            // Use output...
        }

        impl {
            fn compute_error(&self) -> f64 {
                // Your error calculation
                0.0
            }
        }
    }
}

Tuning workflow:

  1. Start robot with default gains
  2. Open dashboard Parameters
  3. Adjust control.pid.kp/ki/kd while robot runs
  4. Observe behavior in dashboard metrics
  5. Repeat until satisfactory
  6. Parameters auto-save - done!

Feature Flags

node! {
    AdvancedController {
        data {
            enable_obstacle_avoidance: bool = false,
            enable_path_planning: bool = false,
            enable_localization: bool = true
        }

        init(ctx) {
            self.enable_obstacle_avoidance = ctx.params.get_bool("features.obstacle_avoidance", false);
            self.enable_path_planning = ctx.params.get_bool("features.path_planning", false);
            self.enable_localization = ctx.params.get_bool("features.localization", true);
        }

        tick(ctx) {
            if self.enable_localization {
                self.update_localization();
            }

            if self.enable_obstacle_avoidance {
                self.avoid_obstacles();
            }

            if self.enable_path_planning {
                self.plan_path();
            }
        }

        impl {
            fn update_localization(&mut self) { /* ... */ }
            fn avoid_obstacles(&mut self) { /* ... */ }
            fn plan_path(&mut self) { /* ... */ }
        }
    }
}

Environment-Specific Config

node! {
    NetworkNode {
        data {
            server_url: String = String::from("localhost:8080"),
            timeout_ms: u64 = 5000,
            retry_count: i32 = 3
        }

        init(ctx) {
            // Load environment-specific parameters
            let env = ctx.params.get_string("environment", "development");

            match env.as_str() {
                "production" => {
                    self.server_url = ctx.params.get_string("network.prod_url", "prod.example.com:8080");
                    self.timeout_ms = ctx.params.get_u64("network.prod_timeout_ms", 3000);
                },
                "staging" => {
                    self.server_url = ctx.params.get_string("network.staging_url", "staging.example.com:8080");
                    self.timeout_ms = ctx.params.get_u64("network.staging_timeout_ms", 5000);
                },
                _ => {
                    self.server_url = ctx.params.get_string("network.dev_url", "localhost:8080");
                    self.timeout_ms = ctx.params.get_u64("network.dev_timeout_ms", 10000);
                }
            }

            ctx.log_info(&format!("Connecting to {} (timeout: {}ms)", self.server_url, self.timeout_ms));
        }
    }
}

Rate Limiting

node! {
    SensorPublisher {
        data {
            publish_rate_hz: u64 = 10,
            last_publish: std::time::Instant = std::time::Instant::now()
        }

        init(ctx) {
            self.publish_rate_hz = ctx.params.get_u64("sensors.publish_rate_hz", 10);
            ctx.log_info(&format!("Publishing at {} Hz", self.publish_rate_hz));
        }

        tick(ctx) {
            let interval = std::time::Duration::from_millis(1000 / self.publish_rate_hz);

            if self.last_publish.elapsed() >= interval {
                // Publish sensor data
                self.publish_data(ctx);
                self.last_publish = std::time::Instant::now();
            }
        }

        impl {
            fn publish_data(&mut self, ctx: Option<&mut NodeInfo>) {
                // Publishing logic
            }
        }
    }
}

Best Practices

Naming Conventions

Use hierarchical structure:

# Good
motion:
  max_linear_velocity: 1.5
  max_angular_velocity: 2.0
control:
  pid:
    kp: 1.0
    ki: 0.1

# Bad
max_linear_velocity: 1.5
max_angular_velocity: 2.0
pid_kp: 1.0
pid_ki: 0.1

Use descriptive names:

# Good
sensors:
  lidar_scan_rate_hz: 10
  camera_resolution_width: 1920

# Bad
sensors:
  rate: 10
  w: 1920

Use consistent casing:

# Good (snake_case)
motion:
  max_speed: 1.5
  acceleration_limit: 0.5

# Bad (mixed casing)
motion:
  maxSpeed: 1.5
  acceleration_limit: 0.5

Always Provide Defaults

Never crash on missing parameters:

// Good - provides fallback
let speed = ctx.params.get_f64("motion.max_speed", 1.5);

// Bad - panics if missing
let speed = ctx.params.get::<f64>("motion.max_speed").unwrap();

Use sensible defaults:

// Good - safe defaults
let emergency_stop = ctx.params.get_bool("safety.emergency_stop", true);  // Default to safe state
let max_speed = ctx.params.get_f64("motion.max_speed", 1.0);  // Default to slow

// Bad - unsafe defaults
let emergency_stop = ctx.params.get_bool("safety.emergency_stop", false);  // Unsafe!
let max_speed = ctx.params.get_f64("motion.max_speed", 100.0);  // Too fast!

Document Parameters

Add comments in YAML:

motion:
  # Maximum linear velocity in m/s (default: 1.5)
  max_linear_velocity: 1.5

  # Maximum angular velocity in rad/s (default: 2.0)
  max_angular_velocity: 2.0

control:
  pid:
    # Proportional gain - affects responsiveness (range: 0.1-10.0)
    kp: 1.0

    # Integral gain - affects steady-state error (range: 0.01-1.0)
    ki: 0.1

    # Derivative gain - affects damping (range: 0.001-0.1)
    kd: 0.01

Add documentation in code:

init(ctx) {
    // Load PID gains (tuning range: Kp=0.1-10, Ki=0.01-1, Kd=0.001-0.1)
    self.kp = ctx.params.get_f64("control.pid.kp", 1.0);
    self.ki = ctx.params.get_f64("control.pid.ki", 0.1);
    self.kd = ctx.params.get_f64("control.pid.kd", 0.01);
}

Validate Parameter Values

Add bounds checking:

init(ctx) {
    // Load with validation
    let speed = ctx.params.get_f64("motion.max_speed", 1.5);

    // Clamp to safe range
    self.max_speed = speed.max(0.0).min(5.0);  // 0-5 m/s

    if speed != self.max_speed {
        ctx.log_warning(&format!(
            "max_speed {} out of range, clamped to {}",
            speed, self.max_speed
        ));
    }
}

Export Parameter Sets

Create presets for different scenarios:

# Save current parameters to preset
horus dashboard
#  Parameters tab  Export  Save as "aggressive_tuning.yaml"

# Switch to different preset
cp ~/.horus/config/params.yaml ~/.horus/config/params_backup.yaml
cp aggressive_tuning.yaml ~/.horus/config/params.yaml

# Restart application to load new parameters

Troubleshooting

Parameters Not Loading

Problem: Parameters show default values even though YAML file exists.

Cause: YAML syntax error or file permissions.

Solution:

# Check YAML syntax
cat ~/.horus/config/params.yaml

# Validate YAML
yamllint ~/.horus/config/params.yaml

# Check permissions
ls -la ~/.horus/config/params.yaml

# Should be readable
chmod 644 ~/.horus/config/params.yaml

Parameters Not Saving

Problem: Changes in dashboard don't persist.

Cause: Config directory doesn't exist or lacks write permissions.

Solution:

# Create config directory
mkdir -p ~/.horus/config

# Set permissions
chmod 755 ~/.horus/config

# Manually save from code
ctx.params.save_to_disk().ok();

Type Mismatch

Problem: Parameter exists but wrong type.

Error:

Parameter 'motion.max_speed' expected f64, got String

Solution:

Check YAML format:

# Wrong - string
max_speed: "1.5"

# Correct - number
max_speed: 1.5

Or use type conversion in code:

// Try as number first, then parse string as fallback
let speed = ctx.params.get_f64("motion.max_speed", 1.5);

Lost Parameters After Update

Problem: Parameters reset to defaults after code update.

Cause: Defaults overwrite file on initialization.

Solution:

Backup parameters before updating:

# Backup
cp ~/.horus/config/params.yaml ~/.horus/config/params_backup.yaml

# After update, restore if needed
cp ~/.horus/config/params_backup.yaml ~/.horus/config/params.yaml

Performance Considerations

Access Speed

Parameters use Arc<RwLock<BTreeMap>>:

  • Read: ~80-350ns (read lock + BTreeMap lookup)
  • Write: ~100-500ns (write lock + BTreeMap insert + potential save)
  • Thread-safe: Multiple nodes can read simultaneously

Fast enough for:

  • Loading parameters in init() (one-time)
  • Checking parameters every tick (60 Hz)
  • Checking parameters every 100 ticks (~1 second)

Too slow for:

  • Reading hundreds of parameters every tick
  • Using as real-time message passing (use Hub instead)

Caching Strategy

Good: Cache and reload periodically

data {
    max_speed: f64 = 1.5,
    reload_counter: u64 = 0
}

tick(ctx) {
    // Reload every 60 ticks (~1 second)
    if self.reload_counter % 60 == 0 {
        self.max_speed = ctx.params.get_f64("motion.max_speed", 1.5);
    }
    self.reload_counter += 1;

    // Use cached value
    let velocity = calculate_velocity(self.max_speed);
}

Bad: Read every tick unnecessarily

tick(ctx) {
    // Wasteful - reads same value 60 times per second
    let max_speed = ctx.params.get_f64("motion.max_speed", 1.5);
}

Next Steps