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 monitor or CLI
let max_speed = self.params.get_or("max_speed", 1.5);
let pid_kp = self.params.get_or("pid_kp", 1.0);

Benefits:

  • No recompilation - Change values without rebuilding
  • Live tuning - Adjust while robot is running
  • Persistence - Save/load from YAML files
  • Sharing - Export/import parameter sets
  • Safety - Fallback to defaults if missing
  • Validation - Framework-internal only; clamp your own values in code (see Validate Parameter Values)
  • Versioning - Optimistic locking for concurrent edits

Core Concepts

Parameter Storage

Parameters are stored in a thread-safe map:

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

Location: .horus/config/params.yaml (relative to your project directory)

Format:

# Flat key-value pairs (keys are plain strings)
tick_rate: 30
max_memory_mb: 512
max_speed: 1.0
max_angular_speed: 1.0
acceleration_limit: 0.5
lidar_rate: 10
camera_fps: 30
sensor_timeout_ms: 1000
emergency_stop_distance: 0.3
collision_threshold: 0.5
pid_kp: 1.0
pid_ki: 0.1
pid_kd: 0.05

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)

Key Organization

Keys are stored as flat strings in a BTreeMap (sorted alphabetically). Use descriptive names with underscores:

// Descriptive flat keys
self.params.get_or("max_speed", 1.5);
self.params.get_or("pid_kp", 1.0);
self.params.get_or("lidar_rate", 10);

You can use dot notation as a naming convention for grouping, but note that dots are treated as literal characters — there is no automatic hierarchy:

// Dot notation is a naming convention, not a hierarchy
self.params.get_or("motion.max_speed", 1.5);
self.params.get_or("control.pid.kp", 1.0);

Using Parameters in Nodes

Accessing Parameters

Store a RuntimeParams instance as a field on your node struct:

use horus::prelude::*;

pub struct VelocityController {
    params: RuntimeParams,
    max_speed: f64,
    acceleration: f64,
}

impl VelocityController {
    fn new() -> Result<Self> {
        let params = RuntimeParams::new()?;
        let max_speed = params.get_or("max_speed", 1.5);
        let acceleration = params.get_or("acceleration_limit", 0.5);
        Ok(Self { params, max_speed, acceleration })
    }
}

impl Node for VelocityController {
    fn name(&self) -> &'static str { "velocity_controller" }

    fn init(&mut self) -> Result<()> {
        self.max_speed = self.params.get_or("max_speed", 1.5);
        self.acceleration = self.params.get_or("acceleration_limit", 0.5);
        hlog!(info, "Max speed: {} m/s", self.max_speed);
        hlog!(info, "Acceleration: {} m/s²", self.acceleration);
        Ok(())
    }

    fn tick(&mut self) {
        let target_velocity = self.max_speed;
        // Use parameters...
    }
}

Parameter Methods

Generic get with default (get_or):

let speed = self.params.get_or("max_speed", 1.5);            // f64
let enabled = self.params.get_or("auto_mode", false);         // bool
let rate = self.params.get_or("update_rate_hz", 60);          // i32
let name: String = self.params.get_or("node_name", "default".to_string());  // String

Generic get (returns Option<T>):

// Returns None if parameter doesn't exist or type doesn't match
if let Some(speed) = self.params.get::<f64>("max_speed") {
    self.max_speed = speed;
}

Generic get with explicit errors (get_typed, returns HorusResult<T>):

// Distinguishes "missing key" from "wrong type" instead of failing silently
let rate: i32 = self.params.get_typed("update_rate_hz")?;

Set parameter:

// Update parameter value (validates against metadata if set)
self.params.set("max_speed", 2.0)?;

// Set complex types
self.params.set("camera_resolution", vec![1920, 1080])?;

Query methods:

self.params.has("max_speed");           // bool — check if key exists
self.params.list_keys();                // Vec<String> — all parameter keys
self.params.get_all();                  // BTreeMap<String, Value> — all params

Mutating methods:

self.params.remove("old_key");          // Option<Value> — remove and return
self.params.reset()?;                   // Drop every key, then restore the
                                        // built-in defaults — in-memory values
                                        // from params.yaml and HORUS_PARAM_*
                                        // are discarded too

Persistence:

// Save current params to .horus/config/params.yaml
self.params.save_to_disk()?;

// Load params from a specific YAML file
self.params.load_from_disk(Path::new("my_params.yaml"))?;

Live Reloading

Check for updates every tick:

pub struct AdaptiveController {
    params: RuntimeParams,
    max_speed: f64,
    tick_count: u64,
}

impl Node for AdaptiveController {
    fn name(&self) -> &'static str { "adaptive_controller" }

    fn tick(&mut self) {
        // Check every 60 ticks (~1 second at 60 Hz)
        if self.tick_count % 60 == 0 {
            let new_speed = self.params.get_or("max_speed", 1.5);

            if new_speed != self.max_speed {
                hlog!(info, "Speed updated: {} → {}", self.max_speed, new_speed);
                self.max_speed = new_speed;
            }
        }

        self.tick_count += 1;
    }
}

Performance note: A parameter read is a read-lock plus a BTreeMap lookup and a serde_json deserialize — cheap, but not free. Avoid reading hundreds of parameters every tick. Cache values and reload periodically.

Complex Parameter Types

Arrays:

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

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

Objects:

use horus::serde_json::json;   // re-exported — not a direct dependency

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

// Get the object back
if let Some(config) = self.params.get::<serde_json::Map<String, serde_json::Value>>("network_config") {
    let ip = config.get("ip").and_then(|v| v.as_str()).unwrap_or("localhost");
    let port = config.get("port").and_then(|v| v.as_i64()).unwrap_or(8080);
}

Default Parameters

RuntimeParams::new() always starts from these built-in defaults, then layers .horus/config/params.yaml and HORUS_PARAM_* environment variables over them key by key:

# Built-in defaults, as they would look written out to
# .horus/config/params.yaml. Nothing writes this file for you —
# it appears once `horus param set/save` or save_to_disk() runs.

# System
tick_rate: 30
max_memory_mb: 512

# Motion
max_speed: 1.0
max_angular_speed: 1.0
acceleration_limit: 0.5

# Sensors
lidar_rate: 10
camera_fps: 30
sensor_timeout_ms: 1000

# Safety
emergency_stop_distance: 0.3
collision_threshold: 0.5

# PID
pid_kp: 1.0
pid_ki: 0.1
pid_kd: 0.05

Customization:

  1. Built-in defaults are always the base layer; .horus/config/params.yaml overrides individual keys on top of them, and HORUS_PARAM_* environment variables (lower-cased, e.g. HORUS_PARAM_CAMERA_FPS=60camera_fps) override both. A partial params.yaml does not suppress the other defaults.
  2. Edit the YAML file directly, use the monitor, or use horus param set
  3. Call params.reset() to restore defaults
  4. Call params.save_to_disk() to persist changes

Managing Parameters

Via Monitor

Web interface (easiest method):

# Start monitor
horus monitor

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

Features:

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

See Monitor Guide for the /api/params REST endpoints.

Via Code

Save to disk:

// Parameters are NOT auto-saved on set() — you must save explicitly
self.params.save_to_disk()?;

Load from disk:

use std::path::Path;

// Load from a specific file
self.params.load_from_disk(Path::new(".horus/config/params.yaml"))?;

Via CLI

Use horus param to manage parameters from the command line:

# List all parameters
horus param list
horus param list --verbose    # Include metadata
horus param list --json       # JSON output

# Get/set values
horus param get max_speed
horus param set max_speed 2.0
horus param set enabled true

# Delete a parameter
horus param delete old_key

# Reset all parameters to defaults
# (--force is required; without it the command prints a notice and does nothing)
horus param reset --force

# Save/load from files
horus param save my_preset.yaml
horus param load my_preset.yaml

# Dump all parameters as YAML to stdout
horus param dump

Via File Edit

Direct YAML editing:

# Edit parameters file (project-relative)
vim .horus/config/params.yaml

# Changes take effect on next RuntimeParams::new() or load_from_disk()

Format:

# Use spaces (2 or 4), not tabs
max_speed: 2.0            # number
mode: "auto"              # string (quotes optional for simple strings)
enabled: true             # boolean
rates: [10, 30, 100]      # array

# Comments survive only until something writes the file — `horus param
# set/delete/reset/load` and `params.save_to_disk()` re-serialize the whole
# map and drop all comments (keys are also re-sorted alphabetically).
pid_kp: 1.0    # Proportional gain
pid_ki: 0.1    # Integral gain
pid_kd: 0.05   # Derivative gain

Common Patterns

PID Controller Tuning

use horus::prelude::*;

pub struct PIDController {
    params: RuntimeParams,
    kp: f64,
    ki: f64,
    kd: f64,
    integral: f64,
    last_error: f64,
}

impl Node for PIDController {
    fn name(&self) -> &'static str { "pid_controller" }

    fn init(&mut self) -> Result<()> {
        self.kp = self.params.get_or("pid_kp", 1.0);
        self.ki = self.params.get_or("pid_ki", 0.1);
        self.kd = self.params.get_or("pid_kd", 0.05);
        hlog!(info, "PID: Kp={}, Ki={}, Kd={}", self.kp, self.ki, self.kd);
        Ok(())
    }

    fn tick(&mut self) {
        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 PIDController {
    fn compute_error(&self) -> f64 {
        // Your error calculation
        0.0
    }
}

Tuning workflow:

  1. Start robot with default gains
  2. Open monitor → Parameters
  3. Adjust pid_kp/pid_ki/pid_kd while robot runs
  4. Observe behavior in monitor metrics
  5. Repeat until satisfactory
  6. Save with horus param save or params.save_to_disk()

Feature Flags

pub struct AdvancedController {
    params: RuntimeParams,
    enable_obstacle_avoidance: bool,
    enable_path_planning: bool,
    enable_localization: bool,
}

impl Node for AdvancedController {
    fn name(&self) -> &'static str { "advanced_controller" }

    fn init(&mut self) -> Result<()> {
        self.enable_obstacle_avoidance = self.params.get_or("obstacle_avoidance", false);
        self.enable_path_planning = self.params.get_or("path_planning", false);
        self.enable_localization = self.params.get_or("localization", true);
        Ok(())
    }

    fn tick(&mut self) {
        if self.enable_localization {
            self.update_localization();
        }
        if self.enable_obstacle_avoidance {
            self.avoid_obstacles();
        }
        if self.enable_path_planning {
            self.plan_path();
        }
    }
}

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

Environment-Specific Config

pub struct NetworkNode {
    params: RuntimeParams,
    server_url: String,
    timeout_ms: u64,
}

impl Node for NetworkNode {
    fn name(&self) -> &'static str { "network_node" }

    fn init(&mut self) -> Result<()> {
        let env: String = self.params.get_or("environment", "development".to_string());

        match env.as_str() {
            "production" => {
                self.server_url = self.params.get_or("prod_url", "prod.example.com:8080".to_string());
                self.timeout_ms = self.params.get_or("prod_timeout_ms", 3000_i64) as u64;
            },
            "staging" => {
                self.server_url = self.params.get_or("staging_url", "staging.example.com:8080".to_string());
                self.timeout_ms = self.params.get_or("staging_timeout_ms", 5000_i64) as u64;
            },
            _ => {
                self.server_url = self.params.get_or("dev_url", "localhost:8080".to_string());
                self.timeout_ms = self.params.get_or("dev_timeout_ms", 10000_i64) as u64;
            }
        }

        hlog!(info, "Connecting to {} (timeout: {}ms)", self.server_url, self.timeout_ms);
        Ok(())
    }

    fn tick(&mut self) {
        // Network logic...
    }
}

Rate Limiting

pub struct SensorPublisher {
    params: RuntimeParams,
    publish_rate_hz: u64,
    last_publish: std::time::Instant,
}

impl Node for SensorPublisher {
    fn name(&self) -> &'static str { "sensor_publisher" }

    fn init(&mut self) -> Result<()> {
        self.publish_rate_hz = self.params.get_or("publish_rate_hz", 10_i64) as u64;
        hlog!(info, "Publishing at {} Hz", self.publish_rate_hz);
        Ok(())
    }

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

        if self.last_publish.elapsed() >= interval {
            self.publish_data();
            self.last_publish = std::time::Instant::now();
        }
    }
}

impl SensorPublisher {
    fn publish_data(&mut self) {
        // Publishing logic
    }
}

Best Practices

Naming Conventions

Use descriptive names:

# Good
lidar_scan_rate_hz: 10
camera_resolution_width: 1920

# Bad
rate: 10
w: 1920

Use consistent snake_case:

# Good (snake_case)
max_speed: 1.5
acceleration_limit: 0.5

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

Always Provide Defaults

Never crash on missing parameters:

// Good - provides fallback
let speed = self.params.get_or("max_speed", 1.5);

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

Use sensible defaults:

// Good - safe defaults
let emergency_stop = self.params.get_or("emergency_stop", true);     // Default to safe state
let max_speed = self.params.get_or("max_speed", 1.0);               // Default to slow

// Bad - unsafe defaults
let emergency_stop = self.params.get_or("emergency_stop", false);    // Unsafe!
let max_speed = self.params.get_or("max_speed", 100.0);             // Too fast!

Document Parameters

Add comments in YAML:

# Maximum linear velocity in m/s (default: 1.0)
max_speed: 1.0

# Maximum angular velocity in rad/s (default: 1.0)
max_angular_speed: 1.0

# Proportional gain - affects responsiveness (range: 0.1-10.0)
pid_kp: 1.0

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

# Derivative gain - affects damping (range: 0.001-0.1)
pid_kd: 0.05

These comments are for humans reading the file, and they do not survive a write. horus param set/delete/reset/load and params.save_to_disk() re-serialize the whole parameter map and overwrite the file, dropping every comment. Keep the authoritative documentation in code (below) or in version control, not only in params.yaml.

Add documentation in code:

fn init(&mut self) -> Result<()> {
    // Load PID gains (tuning range: Kp=0.1-10, Ki=0.01-1, Kd=0.001-0.1)
    self.kp = self.params.get_or("pid_kp", 1.0);
    self.ki = self.params.get_or("pid_ki", 0.1);
    self.kd = self.params.get_or("pid_kd", 0.05);
    Ok(())
}

Validate Parameter Values

Validation rules are internal — clamp in your own code.

set() does check a parameter's metadata before writing: if metadata exists and marks the key read-only it returns Parameter '<key>' is read-only, and otherwise it runs the key's validation rules. But there is no public API to attach that metadata. RuntimeParams::new() starts with an empty metadata map, the metadata field is private with no public writer, and ValidationRule is pub(crate) — so it cannot even be named from outside horus_core. In practice, parameters you create carry no metadata and no rule ever fires.

The metadata API available to your code is read-only:

use horus_core::params::ParamMetadata;

// get_metadata returns Option<ParamMetadata>; it is None for any
// parameter you created yourself
let meta: Option<ParamMetadata> = params.get_metadata("max_speed");

if let Some(meta) = meta {
    println!("{:?} ({:?})", meta.description, meta.unit); // both Option<String>
    println!("read-only: {}", meta.read_only());          // bool, via getter
}

description and unit are the only public fields; validation and read_only are not, so ParamMetadata cannot be built with a struct literal outside horus_core. Note also that ParamMetadata is not in horus::prelude — only RuntimeParams is. Import it as horus_core::params::ParamMetadata.

Internal validation rules (for reference only): MinValue(f64), MaxValue(f64), Range(f64, f64), RegexPattern(String), Enum(Vec<String>), MinLength(usize), MaxLength(usize), RequiredKeys(Vec<String>). These are framework-internal today — treat the manual clamping pattern below as the supported way to bound a parameter.

Manual bounds checking in code (recommended):

fn init(&mut self) -> Result<()> {
    let speed = self.params.get_or("max_speed", 1.5);

    // Clamp to safe range
    self.max_speed = speed.max(0.0).min(5.0);

    if speed != self.max_speed {
        hlog!(warn, "max_speed {} out of range, clamped to {}", speed, self.max_speed);
    }
    Ok(())
}

Export Parameter Sets

Create presets for different scenarios:

# Save current parameters to a preset file
horus param save aggressive_tuning.yaml

# Backup current params and switch to a different preset
cp .horus/config/params.yaml .horus/config/params_backup.yaml
horus param load aggressive_tuning.yaml

# Dump current params to stdout for inspection
horus param dump

Troubleshooting

Parameters Not Loading

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

Cause: wrong file location (it must be .horus/config/params.yaml relative to the process's current working directory), an empty file (it parses as null and is treated as "no overrides"), or a HORUS_PARAM_* environment variable overriding the key.

A malformed or unreadable params.yaml is not a silent fallback. RuntimeParams::new() refuses to start and returns parameter file .horus/config/params.yaml is malformed: <err>. Refusing to start on built-in defaults — fix the file or remove it. Only RuntimeParams::default() falls back, and it prints [PARAMS] Failed to initialize RuntimeParams: ... to stderr first. So if you see defaults with no diagnostic, the file isn't being found — not mis-parsed.

Solution:

# Check file exists in the right place (project-relative)
ls -la .horus/config/params.yaml

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

# Check permissions
chmod 644 .horus/config/params.yaml

# Verify with CLI
horus param list

Parameters Not Saving

Problem: Changes via set() don't persist after restart.

Cause: set() only updates in-memory storage. You must call save_to_disk() explicitly.

Solution:

# Create config directory if needed
mkdir -p .horus/config

# Save from CLI
horus param save

# Or save from code
self.params.save_to_disk()?;

Type Mismatch

Problem: Parameter exists but wrong type.

Error:

Parameter 'max_speed' type mismatch: invalid type: string "1.5", expected f64

This error is only produced by get_typed(). get() and get_or() fail silentlyget() returns None and get_or() returns your default whenever the stored value doesn't deserialize into the requested type, so a type mismatch there looks like a missing parameter rather than an error.

Solution:

Check YAML format:

# Wrong - string
max_speed: "1.5"

# Correct - number
max_speed: 1.5

Or tolerate both shapes in code — get_or alone will not do this, it just hands back the default when the stored value is a string:

// Try as number first, then parse a string value as a fallback
let speed = self.params
    .get::<f64>("max_speed")
    .or_else(|| {
        self.params
            .get::<String>("max_speed")
            .and_then(|s| s.parse::<f64>().ok())
    })
    .unwrap_or(1.5);

Lost Parameters After Update

Problem: Parameters reset to defaults after code update.

Cause: If .horus/config/params.yaml is deleted or empty, RuntimeParams::new() loads defaults.

Solution:

Backup parameters before updating:

# Backup
horus param save params_backup.yaml

# After update, restore if needed
horus param load params_backup.yaml

Performance Considerations

Access Speed

Parameters use Arc<RwLock<BTreeMap>>:

  • Read: read lock + BTreeMap lookup + a serde_json deserialize of the stored value — cheap, but not free (a String read allocates)
  • Write: write lock + BTreeMap insert (plus validation and a version bump) — cheap, but not free
  • Thread-safe: Multiple nodes can read simultaneously

The only published measurement is a coarse CI bound: 1000 sets and 1000 gets each complete in under 500 ms.

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 buffered Topic instead)

Caching Strategy

Good: Cache and reload periodically

fn tick(&mut self) {
    // Reload every 60 ticks (~1 second)
    if self.reload_counter % 60 == 0 {
        self.max_speed = self.params.get_or("max_speed", 1.5);
    }
    self.reload_counter += 1;

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

Bad: Read every tick unnecessarily

fn tick(&mut self) {
    // Wasteful - reads same value 60 times per second
    let max_speed = self.params.get_or("max_speed", 1.5);
}

Version Tracking

RuntimeParams includes an optimistic locking system for concurrent edit protection:

// Get current version of a parameter
let version = self.params.get_version("max_speed");

// Set with version check — fails if another writer changed it
self.params.set_with_version("max_speed", 2.0, version)?;

This prevents lost updates when multiple processes or threads modify the same parameter simultaneously.

Audit Logging

Parameter changes are automatically logged to .horus/logs/param_changes.log:

[2025-01-15 14:30:00] max_speed: 1.0 -> 2.0
[2025-01-15 14:31:15] pid_kp: 1.0 -> 1.5

This provides a history of all runtime parameter modifications for debugging and tuning review.

Next Steps