HORUS Python Bindings

Production-Ready Python API for the HORUS robotics framework - combines simplicity with advanced features for professional robotics applications.

Why HORUS Python?

  • Zero Boilerplate: Working node in 10 lines
  • Flexible API: Functional style or class inheritance - your choice
  • Production Performance: ~500ns latency (same shared memory as Rust)
  • Per-Node Rate Control: Different nodes at different frequencies (100Hz sensor, 10Hz logger)
  • Automatic Timestamps: Built-in message timing and staleness detection
  • Typed Messages: Optional type-safe messages from Rust
  • Multiprocess Support: Process isolation and multi-language nodes
  • Pythonic: Feels like native Python, not wrapped C++
  • Rich Ecosystem: Use NumPy, OpenCV, scikit-learn, etc.

Quick Start

Installation

Automatic (Recommended)

Python bindings are automatically installed when you run the HORUS installer:

# From HORUS root directory
./install.sh

The installer will detect Python 3.9+ and automatically build and install the bindings.

Manual Installation

If you prefer to install manually or need to rebuild:

# Install maturin (Python/Rust build tool)
# Option A: Via Cargo (recommended for Ubuntu 24.04+)
cargo install maturin

# Option B: Via pip (if not blocked by PEP 668)
# pip install maturin

# Build and install from source
cd horus_py
maturin develop --release

Requirements:

  • Python 3.9+
  • Rust 1.70+
  • Linux (for shared memory support)

Minimal Example

import horus

def process(node):
    node.send("output", "Hello HORUS!")

node = horus.Node(pubs="output", tick=process, rate=1)
horus.run(node, duration=3)

This minimal example demonstrates functional-style node creation without class boilerplate.


Core API

Creating a Node

node = horus.Node(
    name="my_node",            # Optional: auto-generated if not provided
    pubs=["topic1", "topic2"], # Topics to publish to
    subs=["input1", "input2"], # Topics to subscribe to
    tick=my_function,          # Function called repeatedly
    rate=30,                   # Hz (default: 30)
    init=setup_fn,             # Optional: called once at start
    shutdown=cleanup_fn,       # Optional: called once at end
    on_error=error_fn,         # Optional: called on tick errors
    default_capacity=1024      # Optional: topic buffer capacity
)

Parameters:

  • name (str, optional): Node name (auto-generated if omitted)
  • pubs (str | list[str], optional): Topics to publish to
  • subs (str | list[str], optional): Topics to subscribe from
  • tick (callable): Function called each cycle, receives (node) as argument
  • rate (int, optional): Execution rate in Hz (default: 30)
  • init (callable, optional): Setup function, called once at start
  • shutdown (callable, optional): Cleanup function, called once at end
  • on_error (callable, optional): Error handler, called if tick raises an exception
  • default_capacity (int, optional): Buffer capacity for auto-created topics (default: 1024)

Alternative: Class-Based Inheritance

For those who prefer OOP, you can inherit from horus.Node:

import horus

class SensorNode(horus.Node):
    def __init__(self):
        super().__init__(
            name="sensor",
            pubs=["temperature"],
            rate=10
        )

    def tick(self, info=None):
        # Override tick method
        self.send("temperature", 25.0)

    def init(self, info=None):
        # Optional: override init
        print("Sensor initialized!")

    def shutdown(self, info=None):
        # Optional: override shutdown
        print("Sensor shutting down!")

# Use it
sensor = SensorNode()
horus.run(sensor)

Both patterns work! Use functional style for simplicity or class inheritance for complex nodes with state.

Node Functions

Your tick function receives the node as a parameter:

def my_tick(node):
    # Check for messages
    if node.has_msg("input"):
        data = node.get("input")  # Get one message

    # Get all messages
    all_msgs = node.get_all("input")

    # Send messages
    node.send("output", {"value": 42})

Node Methods:

MethodDescription
node.send(topic, data)Publish a message to a topic
node.get(topic)Get one message (returns None if empty)
node.get_all(topic)Get all available messages as a list
node.has_msg(topic)Check if messages are available
node.get_timestamp(topic)Peek at the timestamp of the next message without consuming it
node.get_message_age(topic)Get the age of the next message in seconds
node.is_stale(topic, max_age)Check if the next message is older than max_age seconds
node.get_with_timestamp(topic)Get message and its timestamp as a (msg, timestamp) tuple
node.log_info(msg)Log an informational message
node.log_warning(msg)Log a warning message
node.log_error(msg)Log an error message
node.log_debug(msg)Log a debug message
node.request_stop()Request the scheduler to stop execution
node.publishersProperty: list of publisher topic names
node.subscribersProperty: list of subscriber topic names

Running Nodes

# Single node
horus.run(node)

# Multiple nodes
horus.run(node1, node2, node3, duration=10)

Examples

1. Simple Publisher

import horus

def publish_temperature(node):
    node.send("temperature", 25.5)

sensor = horus.Node(
    name="temp_sensor",
    pubs="temperature",
    tick=publish_temperature,
    rate=1  # 1 Hz
)

horus.run(sensor, duration=10)

2. Subscriber

import horus

def display_temperature(node):
    if node.has_msg("temperature"):
        temp = node.get("temperature")
        print(f"Temperature: {temp}°C")

display = horus.Node(
    name="display",
    subs="temperature",
    tick=display_temperature
)

horus.run(display)

3. Pub/Sub Pipeline

import horus

def publish(node):
    node.send("raw", 42.0)

def process(node):
    if node.has_msg("raw"):
        data = node.get("raw")
        result = data * 2.0
        node.send("processed", result)

def display(node):
    if node.has_msg("processed"):
        value = node.get("processed")
        print(f"Result: {value}")

# Create pipeline
publisher = horus.Node("publisher", pubs="raw", tick=publish, rate=1)
processor = horus.Node("processor", subs="raw", pubs="processed", tick=process)
displayer = horus.Node("display", subs="processed", tick=display)

# Run all together
horus.run(publisher, processor, displayer, duration=5)

4. Using Lambda Functions

import horus

# Producer (inline)
producer = horus.Node(
    pubs="numbers",
    tick=lambda n: n.send("numbers", 42),
    rate=1
)

# Transformer (inline)
doubler = horus.Node(
    subs="numbers",
    pubs="doubled",
    tick=lambda n: n.send("doubled", n.get("numbers") * 2) if n.has_msg("numbers") else None
)

horus.run(producer, doubler, duration=5)

5. Multi-Topic Robot Controller

import horus

def robot_controller(node):
    # Read from multiple sensors
    lidar_data = None
    camera_data = None

    if node.has_msg("lidar"):
        lidar_data = node.get("lidar")

    if node.has_msg("camera"):
        camera_data = node.get("camera")

    # Compute commands
    if lidar_data and camera_data:
        cmd = compute_navigation(lidar_data, camera_data)
        node.send("motors", cmd)
        node.send("status", "navigating")

robot = horus.Node(
    name="robot_controller",
    subs=["lidar", "camera"],
    pubs=["motors", "status"],
    tick=robot_controller,
    rate=50  # 50Hz control loop
)

6. Lifecycle Management

import horus

class Context:
    def __init__(self):
        self.count = 0
        self.file = None

ctx = Context()

def init_handler(node):
    print("Starting up!")
    ctx.file = open("data.txt", "w")

def tick_handler(node):
    ctx.count += 1
    data = f"Tick {ctx.count}"
    node.send("data", data)
    ctx.file.write(data + "\n")

def shutdown_handler(node):
    print(f"Processed {ctx.count} messages")
    ctx.file.close()

node = horus.Node(
    pubs="data",
    init=init_handler,
    tick=tick_handler,
    shutdown=shutdown_handler,
    rate=10
)

horus.run(node, duration=5)

Advanced Features (Production-Ready)

HORUS Python includes advanced features that match or exceed ROS2 capabilities while maintaining simplicity.

Scheduler

The Scheduler orchestrates node execution with priority ordering, per-node rate control, and real-time features.

Creating a Scheduler:

# Fluent API — configure nodes inline with chained calls
scheduler = horus.Scheduler()
scheduler.node(motor_ctrl).order(0).budget(200).rate(1000.0).build()
scheduler.node(planner).order(5).compute().build()
scheduler.node(telemetry).order(10).async_io().rate(1.0).build()

NodeBuilder Methods (returned by scheduler.node()):

MethodDescription
.order(n)Execution priority (lower = runs first)
.rate(hz)Node tick rate in Hz — auto-derives budget/deadline, marks as RT
.budget(us)Tick budget in microseconds
.on_miss(policy)"warn", "skip", "safe_mode", or "stop"
.on(topic)Event-driven — wakes only when topic has new data
.compute()Offload to worker thread pool (planning, ML)
.async_io()Run on async executor (network, disk)
.failure_policy(name, ...)"fatal", "restart", "skip", or "ignore" — optional kwargs: max_retries, backoff_ms, max_failures, cooldown_ms
.build()Finalize and register — returns Scheduler

Adding Nodes:

scheduler.add(sensor, order=0, rate=100.0)
scheduler.add(controller, order=1, budget=200, on_miss="safe_mode", deadline=5.0)
scheduler.add(logger, order=2, rate=10.0)
scheduler.add(monitor, order=3, failure_policy="skip")

add() Parameters:

ParameterTypeDefaultDescription
nodeNoderequiredNode instance
orderint100Execution order (lower = earlier)
ratefloatnode's ratePer-node tick rate in Hz
budgetintNoneTick budget in microseconds (auto-marks as RT)
on_missstrNone"warn", "skip", "safe_mode", or "stop"
deadlinefloatNoneSoft deadline in milliseconds
failure_policystrNone"fatal", "restart", "skip", or "ignore"

Execution:

MethodDescription
scheduler.run()Run until Ctrl+C or .stop()
scheduler.run_for(seconds)Run for a specific duration, then shut down
scheduler.tick(node_names)Tick specific nodes (list of name strings)
scheduler.tick_for(node_names, seconds)Tick specific nodes for a duration
scheduler.stop()Signal graceful shutdown
scheduler.is_running()Check if scheduler is actively ticking
scheduler.current_tick()Current tick count

Monitoring:

MethodDescription
scheduler.get_node_stats(name)Stats dict: total_ticks, errors_count, avg_tick_duration_ms, etc.
scheduler.set_node_rate(name, rate)Change a node's tick rate at runtime
scheduler.set_tick_budget(name, us)Update per-node tick budget (microseconds)
scheduler.get_all_nodes()List all nodes with their configuration
scheduler.get_node_count()Number of registered nodes
scheduler.has_node(name)Check if a node is registered
scheduler.get_node_names()List of registered node names
scheduler.remove_node(name)Remove a node (returns True if found)
scheduler.status()Formatted status string
scheduler.capabilities()Dict of RT capabilities
scheduler.has_full_rt()True if all RT features available
scheduler.safety_stats()Dict of budget overruns, deadline misses, watchdog expirations

Recording & Replay:

MethodDescription
scheduler.is_recording()Check if recording is active
scheduler.is_replaying()Check if replaying
scheduler.stop_recording()Stop recording, returns list of saved file paths
Scheduler.list_recordings()List available recordings (static method)
Scheduler.delete_recording(name)Delete a recording (static method)

horus.run() Convenience Function:

# Quick helper — creates a Scheduler, auto-assigns execution order, and runs
horus.run(sensor, controller, logger, duration=10)
horus.run(node)  # Single node, no duration limit

horus.run() uses smart priority assignment: subscriber-only nodes run first, then nodes with both pubs/subs, then publisher-only nodes.

Miss — Deadline Miss Policy

The Miss class defines what happens when a node exceeds its deadline:

from horus import Miss

# Available policies
Miss.WARN        # Log warning and continue (default)
Miss.SKIP        # Skip the node for this tick
Miss.SAFE_MODE   # Call enter_safe_state() on the node
Miss.STOP        # Stop the entire scheduler

Use via the scheduler:

# Via add()
scheduler.add(motor, order=0, budget=200, on_miss="safe_mode")

# Via fluent builder
scheduler.node(motor).order(0).budget(200).on_miss("safe_mode").build()

Scheduler Configuration

Configure via SchedulerConfig or direct constructor kwargs:

from horus import Scheduler, SchedulerConfig

# Development — lightweight
scheduler = Scheduler()

# Production — watchdog + monitoring
from horus._horus import SchedulerConfig
cfg = SchedulerConfig.with_watchdog()
cfg.tick_rate = 1000.0
scheduler = Scheduler(config=cfg)

# With blackbox
cfg = SchedulerConfig.with_watchdog()
cfg.tick_rate = 1000.0
cfg = cfg.blackbox_mb(64)
scheduler = Scheduler(config=cfg)

Testing with tick_once:

# Single-tick execution for unit tests and simulation
scheduler = Scheduler()
scheduler.add(sensor, order=0)
scheduler.add(controller, order=1)

# Run exactly one tick cycle — no loop, no sleep
scheduler.tick_once()

# Or tick specific nodes
scheduler.tick(["sensor"])

Automatic Timestamps

All messages automatically get microsecond-precision timestamps:

import horus
import time

def control_tick(node):
    if node.has_msg("sensor_data"):
        # Check message age
        age = node.get_message_age("sensor_data")
        if age > 0.1:  # More than 100ms old
            node.log_warning(f"Stale data: {age*1000:.1f}ms old")
            return

        # Or use built-in staleness detection
        if node.is_stale("sensor_data", max_age=0.1):
            return  # Skip stale data

        # Get message with timestamp
        msg, timestamp = node.get_with_timestamp("sensor_data")
        latency = time.time() - timestamp
        print(f"Latency: {latency*1000:.1f}ms")

        # Process fresh data
        data = node.get("sensor_data")
        process(data)

Timestamp Methods:

  • node.get_message_age(topic) - Get age of next message in seconds
  • node.is_stale(topic, max_age) - Check if message is too old
  • node.get_timestamp(topic) - Peek at timestamp without consuming
  • node.get_with_timestamp(topic) - Get message and timestamp together

Multiprocess Execution

Run Python nodes in separate processes for isolation and multi-language support:

# Run multiple Python files as separate processes
horus run node1.py node2.py node3.py

# Mix Python and Rust nodes
horus run sensor.rs controller.py visualizer.py

# Mix Rust and Python
horus run lidar_driver.rs planner.py motor_control.rs

All nodes in the same horus run session automatically communicate via shared memory!

Example - Distributed System:

# sensor_node.py
import horus

def sensor_tick(node):
    data = read_lidar()  # Your sensor code
    node.send("lidar_data", data)

sensor = horus.Node(name="lidar", pubs="lidar_data", tick=sensor_tick)
horus.run(sensor)
# controller_node.py
import horus

def control_tick(node):
    if node.has_msg("lidar_data"):
        data = node.get("lidar_data")
        cmd = compute_control(data)
        node.send("motor_cmd", cmd)

controller = horus.Node(
    name="controller",
    subs="lidar_data",
    pubs="motor_cmd",
    tick=control_tick
)
horus.run(controller)
# Run both in separate processes
horus run sensor_node.py controller_node.py

Benefits:

  • Process isolation: One crash doesn't kill everything
  • Multi-language: Mix Python and Rust nodes in the same application
  • Parallel execution: True multicore utilization
  • Zero configuration: Shared memory IPC automatically set up

Complete Example: All Features Together

import horus
import time

def sensor_tick(node):
    """High-frequency sensor (100Hz)"""
    imu = {"accel_x": 1.0, "accel_y": 0.0, "accel_z": 9.8}
    node.send("imu_data", imu)
    age = node.get_message_age("imu_data")
    node.log_info(f"Published IMU (age: {age:.3f}s)")

def control_tick(node):
    """Medium-frequency control (50Hz)"""
    if node.has_msg("imu_data"):
        # Check for stale data
        if node.is_stale("imu_data", max_age=0.05):
            node.log_warning("Stale IMU data!")
            return

        imu = node.get("imu_data")
        cmd = {"linear": 1.0, "angular": 0.0}
        node.send("cmd_vel", cmd)

def logger_tick(node):
    """Low-frequency logging (10Hz)"""
    if node.has_msg("cmd_vel"):
        msg, timestamp = node.get_with_timestamp("cmd_vel")
        latency = (time.time() - timestamp) * 1000
        node.log_info(f"Command latency: {latency:.1f}ms")

# Create nodes
sensor = horus.Node(name="imu", pubs="imu_data", tick=sensor_tick)
controller = horus.Node(name="ctrl", subs="imu_data", pubs="cmd_vel", tick=control_tick)
logger = horus.Node(name="log", subs="cmd_vel", tick=logger_tick)

# Configure with different rates and priorities
scheduler = horus.Scheduler()
scheduler.add(sensor, order=0, rate=100.0)
scheduler.add(controller, order=1, rate=50.0, budget=500)
scheduler.add(logger, order=2, rate=10.0)

scheduler.run(duration=5.0)

# Check statistics
stats = scheduler.get_node_stats("imu")
print(f"Sensor: {stats['total_ticks']} ticks in 5 seconds")

Network Communication

HORUS Python supports network communication for distributed multi-machine systems. Topic, and Router all work transparently over the network.

Topic Network Endpoints

Add an endpoint parameter to communicate over the network:

from horus import Topic, CmdVel

# Local (shared memory) - default
local_topic = Topic(CmdVel)

# Network (UDP direct)
network_topic = Topic(CmdVel, endpoint="cmdvel@192.168.1.100:8000")

# Router (TCP broker for WAN/NAT traversal)
router_topic = Topic(CmdVel, endpoint="cmdvel@router")

Endpoint Syntax:

  • "topic" - Local shared memory (~500ns latency)
  • "topic@host:port" - Direct UDP (<50μs latency)
  • "topic@router" - Router broker (auto-discovery on localhost:7777)
  • "topic@192.168.1.100:7777" - Router broker at specific address

Topic Methods

MethodDescription
topic.send(msg, node=None)Send a message. Pass optional node for automatic IPC logging. Returns True.
topic.recv(node=None)Receive one message. Returns the message or None if empty.
topic.nameProperty: the topic name string
topic.backend_typeProperty: the active backend name (e.g., "direct", "spsc_shm")
topic.is_network_topicProperty: True if this topic uses network transport
topic.endpointProperty: the endpoint string, or None for local topics
topic.stats()Returns a dict with messages_sent, messages_received, send_failures, recv_failures, is_network, backend
topic.is_generic()Returns True if this is a generic (string-name) topic

Example:

from horus import Topic, CmdVel

topic = Topic(CmdVel)

# Send and receive typed messages
topic.send(CmdVel(linear=1.0, angular=0.5))
msg = topic.recv()
if msg:
    print(f"linear={msg.linear}, angular={msg.angular}")

# Check topic properties
print(f"Name: {topic.name}")           # "cmd_vel"
print(f"Backend: {topic.backend_type}") # e.g. "mpmc_shm"
print(f"Stats: {topic.stats()}")

Generic Topics

When you create a Topic with a string name (instead of a typed class), you get a generic topic that accepts any JSON-serializable data:

from horus import Topic, CmdVel

# Generic topic (string name = dynamic typing)
topic = Topic("my_topic")

# Typed topic (class = static typing, better performance)
typed_topic = Topic(CmdVel)

Generic topics use the same send() and recv() methods as typed topics, but accept any JSON-serializable Python object. Data is serialized via MessagePack internally.

from horus import Topic

topic = Topic("sensor_data")

# Send dict, list, or any JSON-serializable data
topic.send({"temperature": 25.5, "humidity": 60.0})
topic.send([1.0, 2.0, 3.0, 4.0])
topic.send("status: OK")

# Receive (returns Python object)
msg = topic.recv()  # {"temperature": 25.5, "humidity": 60.0}

# Check if generic
print(topic.is_generic())  # True

Typed vs Generic Performance:

Topic TypeSerializationUse Case
Typed (Topic(CmdVel))Direct field extraction (no serde)Production, cross-language, high-frequency
Generic (Topic("name"))Python → JSON → MessagePackDynamic schemas, prototyping, Python-only

Backend Hints

Topic automatically selects the optimal backend, but you can override it with the backend parameter for fine-grained control:

from horus import Topic, CmdVel

# Auto-detected (default) — HORUS picks the best backend
topic = Topic(CmdVel)

# Explicit backend selection
fast = Topic(CmdVel, backend="direct")      # ~3ns, same-thread callbacks
spsc = Topic(CmdVel, backend="spsc")        # ~18ns, single producer/consumer
mpmc = Topic(CmdVel, backend="mpmc")        # ~36ns, multiple producers/consumers
shm  = Topic(CmdVel, backend="mpmc_shm")   # ~167ns, cross-process MPMC

Available Backends:

BackendLatencyDescription
"direct"~3nsSame-thread direct channel
"spsc"~18nsSame-process, single producer/consumer
"mpsc"~26nsSame-process, multiple producers
"mpmc"~36nsSame-process, multiple producers/consumers
"spsc_shm"~85nsCross-process, single producer/consumer
"mpmc_shm"~167nsCross-process, multiple producers/consumers

Router Client (WAN/NAT Traversal)

For communication across networks, through NAT, or for large-scale deployments, use the Router:

from horus import RouterClient, Topic, CmdVel

# Create router client for explicit connection management
router = RouterClient("192.168.1.100", 7777)

# Build endpoints through the router
cmd_endpoint = router.endpoint("cmdvel")  # Returns "cmdvel@192.168.1.100:7777"
pose_endpoint = router.endpoint("pose")

# Use endpoints with Topic
topic = Topic(CmdVel, endpoint=cmd_endpoint)

# Router properties
print(f"Address: {router.address}")        # "192.168.1.100:7777"
print(f"Connected: {router.is_connected}") # True
print(f"Topics: {router.topics}")          # ["cmdvel", "pose"]
print(f"Uptime: {router.uptime_seconds}s")

Helper Functions:

from horus import default_router_endpoint, router_endpoint

# Default router (localhost:7777)
ep1 = default_router_endpoint("cmdvel")  # "cmdvel@router"

# Custom router address
ep2 = router_endpoint("cmdvel", "192.168.1.100", 7777)  # "cmdvel@192.168.1.100:7777"

Router Server (for testing):

from horus import RouterServer

# Start a local router (for development/testing)
server = RouterServer(port=7777)
server.start()

# For production, use CLI instead:
# $ horus router start --port 7777

When to Use What

TransportLatencyUse Case
Local (Topic(CmdVel))~85-167nsSame-machine communication (auto-detected backend)
Local SPSC (backend="spsc_shm")~85nsSame-machine, single producer/consumer
Local MPMC (backend="mpmc_shm")~167nsSame-machine, multiple producers/consumers
Network (endpoint="topic@host:port")<50μsMulti-machine on LAN (direct UDP)
Router (endpoint="topic@router")10-50msWAN, NAT traversal, cloud deployments

Multi-Machine Example

# === ROBOT (192.168.1.50) ===
from horus import Topic, CmdVel, Imu, Odometry

# Local: Critical flight control (ultra-fast)
imu_topic = Topic(Imu)  # ~85ns local shared memory

# Network: Telemetry to ground station
telemetry = Topic(Odometry, endpoint="telem@192.168.1.100:8000")

# Network: Commands from ground station
commands = Topic(CmdVel, endpoint="cmd@0.0.0.0:8001")


# === GROUND STATION (192.168.1.100) ===
from horus import Topic, CmdVel, Odometry

# Receive telemetry from robot
telemetry_sub = Topic(Odometry, endpoint="telem@0.0.0.0:8000")

# Send commands to robot
command_pub = Topic(CmdVel, endpoint="cmd@192.168.1.50:8001")

Integration with Python Ecosystem

NumPy Integration

import horus
import numpy as np

def process_array(node):
    if node.has_msg("raw_data"):
        data = node.get("raw_data")
        # Convert to NumPy array
        arr = np.array(data)
        # Process with NumPy
        result = np.fft.fft(arr)
        node.send("fft_result", result.tolist())

processor = horus.Node(
    subs="raw_data",
    pubs="fft_result",
    tick=process_array
)

OpenCV Integration

import horus
import cv2
import numpy as np

def process_image(node):
    if node.has_msg("camera"):
        img_data = node.get("camera")
        # Convert to OpenCV format
        img = np.array(img_data, dtype=np.uint8).reshape((480, 640, 3))

        # Apply OpenCV processing
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        edges = cv2.Canny(gray, 50, 150)

        # Publish result
        node.send("edges", edges.flatten().tolist())

vision = horus.Node(
    subs="camera",
    pubs="edges",
    tick=process_image,
    rate=30
)

scikit-learn Integration

import horus
from sklearn.linear_model import LinearRegression
import numpy as np

model = LinearRegression()

def train_model(node):
    if node.has_msg("training_data"):
        data = node.get("training_data")
        X = np.array(data['features'])
        y = np.array(data['labels'])

        # Train model
        model.fit(X, y)
        score = model.score(X, y)

        node.send("model_score", score)

trainer = horus.Node(
    subs="training_data",
    pubs="model_score",
    tick=train_model
)

Advanced Patterns

State Management

import horus

class RobotState:
    def __init__(self):
        self.position = {"x": 0.0, "y": 0.0}
        self.velocity = 0.0
        self.last_update = 0

state = RobotState()

def update_state(node):
    if node.has_msg("velocity"):
        state.velocity = node.get("velocity")

    if node.has_msg("position"):
        state.position = node.get("position")

    # Publish combined state
    node.send("robot_state", {
        "pos": state.position,
        "vel": state.velocity
    })

state_manager = horus.Node(
    subs=["velocity", "position"],
    pubs="robot_state",
    tick=update_state
)

Rate Limiting

import horus
import time

class RateLimiter:
    def __init__(self, min_interval):
        self.min_interval = min_interval
        self.last_send = 0

limiter = RateLimiter(min_interval=0.1)  # 100ms minimum

def rate_limited_publish(node):
    current_time = time.time()

    if current_time - limiter.last_send >= limiter.min_interval:
        node.send("output", "data")
        limiter.last_send = current_time

node = horus.Node(
    pubs="output",
    tick=rate_limited_publish,
    rate=100  # Node runs at 100Hz, but publishes at max 10Hz
)

Error Handling

import horus

def safe_processing(node):
    try:
        if node.has_msg("input"):
            data = node.get("input")
            result = risky_operation(data)
            node.send("output", result)
    except Exception as e:
        node.send("errors", str(e))
        print(f"Error: {e}")

processor = horus.Node(
    subs="input",
    pubs=["output", "errors"],
    tick=safe_processing
)

Performance Tips

1. Use Per-Node Rate Control

# NEW: Use scheduler with per-node rates for optimal performance
scheduler = horus.Scheduler()

# High-frequency sensor (100Hz)
scheduler.add(sensor, order=0, rate=100.0)

# Medium-frequency control (50Hz)
scheduler.add(controller, order=1, rate=50.0)

# Low-frequency logging (10Hz)
scheduler.add(logger, order=2, rate=10.0)

scheduler.run()

# Monitor performance with get_node_stats()
stats = scheduler.get_node_stats("sensor")
print(f"Sensor executed {stats['total_ticks']} ticks")

2. Monitor Message Staleness

def control_tick(node):
    # Skip stale data to maintain real-time performance
    if node.is_stale("sensor_data", max_age=0.1):
        node.log_warning("Skipping stale sensor data")
        return

    # Process fresh data only
    data = node.get("sensor_data")
    process(data)

3. Use Dicts for Messages

# Send messages as Python dicts (automatically serialized to JSON)
cmd = {"linear": 1.5, "angular": 0.8}
node.send("cmd_vel", cmd)

# Check message age using node method
if node.get_message_age("cmd_vel") > 0.1:
    print("Message is stale")

4. Batch Processing

# Use node.get_all() to process all available messages at once
def batch_processor(node):
    messages = node.get_all("input")
    if messages:
        results = [process(msg) for msg in messages]
        for result in results:
            node.send("output", result)

5. Keep tick() Fast

# GOOD: Fast tick
def good_tick(node):
    if node.has_msg("input"):
        data = node.get("input")
        result = quick_operation(data)
        node.send("output", result)

# BAD: Slow tick
def bad_tick(node):
    time.sleep(1)  # Don't block!
    data = requests.get("http://api.example.com")  # Don't do I/O!

6. Offload Heavy Processing

from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=4)

def heavy_processing_node(node):
    if node.has_msg("input"):
        data = node.get("input")
        # Offload to thread pool
        future = executor.submit(expensive_operation, data)
        # Don't block - check result later or use callback

7. Use Multiprocess for CPU-Intensive Tasks

# Isolate heavy processing in separate processes
horus run sensor.py heavy_vision.py light_controller.py

# Each node gets its own CPU core

Development

Building from Source

# Debug build (fast compile, slow runtime)
cd horus_py
maturin develop

# Release build (slow compile, fast runtime)
maturin develop --release

# Build wheel for distribution
maturin build --release

Running Tests

# Install test dependencies
pip install pytest

# Run all tests
pytest tests/

# Run specific feature tests
horus run tests/test_rate_control.py    # Phase 1: Per-node rates
horus run tests/test_timestamps.py      # Phase 2: Timestamps
horus run tests/test_typed_messages.py  # Phase 3: Typed messages

# With coverage
pytest --cov=horus tests/

# Test multiprocess execution (Phase 4)
horus run tests/multiprocess_publisher.py tests/multiprocess_subscriber.py

Mock Mode

HORUS Python includes a mock mode for testing without Rust bindings:

# If Rust bindings aren't available, automatically falls back to mock
# You'll see: "Warning: Rust bindings not available. Running in mock mode."

# Use for unit testing Python logic without HORUS running

Debugging Tips

# Check node statistics
scheduler = horus.Scheduler()
scheduler.add(my_node, order=0)

# Check node statistics
stats = scheduler.get_node_stats("my_node")
print(f"Ticks: {stats['total_ticks']}, Errors: {stats['errors_count']}")

# Monitor message timestamps
msg, timestamp = node.get_with_timestamp("topic")
age = time.time() - timestamp
print(f"Message age: {age*1000:.1f}ms")

Interoperability

With Rust Nodes

Important: For cross-language communication, use typed topics by passing a message type to Topic().

Cross-Language with Typed Topics

# Python node with typed topic
from horus import Topic, CmdVel

cmd_topic = Topic(CmdVel)  # Typed topic
cmd_topic.send(CmdVel(linear=1.0, angular=0.5))
// Rust node receives
use horus::prelude::*;

let topic: Topic<CmdVel> = Topic::new("cmd_vel")?;
if let Some(cmd) = topic.recv() {
    println!("Got: linear={}, angular={}", cmd.linear, cmd.angular);
}

Generic Topic (String Topics)

# Generic Topic - for custom topics
from horus import Topic

topic = Topic("my_topic")  # Pass string for generic topic
topic.send({"linear": 1.0, "angular": 0.5})  # Uses JSON serialization

Typed topics: Use Topic(CmdVel), Topic(Pose2D) for cross-language communication. See Python Message Library for details.


Common Patterns

Producer-Consumer

# Producer
producer = horus.Node(
    pubs="queue",
    tick=lambda n: n.send("queue", generate_work())
)

# Consumer
consumer = horus.Node(
    subs="queue",
    tick=lambda n: process_work(n.get("queue")) if n.has_msg("queue") else None
)

horus.run(producer, consumer)

Request-Response

def request_node(node):
    node.send("requests", {"id": 1, "query": "data"})

def response_node(node):
    if node.has_msg("requests"):
        req = node.get("requests")
        response = handle_request(req)
        node.send("responses", response)

req = horus.Node(pubs="requests", tick=request_node)
res = horus.Node(subs="requests", pubs="responses", tick=response_node)

Periodic Tasks

import time

class PeriodicTask:
    def __init__(self, interval):
        self.interval = interval
        self.last_run = 0

task = PeriodicTask(interval=5.0)  # Every 5 seconds

def periodic_tick(node):
    current = time.time()
    if current - task.last_run >= task.interval:
        node.send("periodic", "task_executed")
        task.last_run = current

node = horus.Node(pubs="periodic", tick=periodic_tick, rate=10)

Troubleshooting

Import Errors

# If you see: ModuleNotFoundError: No module named 'horus'
# Rebuild and install:
cd horus_py
maturin develop --release

Slow Performance

# Use release build (not debug)
maturin develop --release

# Check tick rate isn't too high
node = horus.Node(tick=fn, rate=30)  # 30Hz is reasonable

Memory Issues

# Avoid accumulating data in closures
# BAD:
all_data = []
def bad_tick(node):
    all_data.append(node.get("input"))  # Memory leak!

# GOOD:
def good_tick(node):
    data = node.get("input")
    process_and_discard(data)  # Process immediately

Monitor Integration and Logging

Current Limitations

Python nodes currently do NOT appear in the HORUS monitor logs.

The Python bindings do not integrate with the Rust logging system:

# Python nodes use standard print() for logging
print("Debug message")  # Visible in console, not in monitor

What this means:

  • Python nodes communicate via shared memory
  • All message passing functionality works
  • Python log messages don't appear in monitor logs
  • Use print() for Python-side debugging

Monitoring Python Nodes

Since Python nodes don't integrate with the monitor logging system, use these alternatives:

  1. Node-level logging methods:
def tick(node):
    node.log_info("Processing sensor data")
    node.log_warning("Sensor reading is stale")
    node.log_error("Failed to process data")
    node.log_debug("Debug information")

# These print to console, not monitor
  1. Manual topic monitoring:
def tick(node):
    if node.has_msg("input"):
        data = node.get("input")
        print(f"[{node.name}] Received: {data}")
        node.send("output", result)
        print(f"[{node.name}] Published: {result}")
  1. Node statistics:
scheduler = horus.Scheduler()
scheduler.add(node, order=0)
scheduler.run(duration=10)

# Get stats after running
stats = scheduler.get_node_stats("my_node")
print(f"Ticks: {stats['total_ticks']}")
print(f"Errors: {stats['errors_count']}")

Future Improvements

Monitor integration for Python nodes is planned for a future release. This will require:

  • Passing NodeInfo context through Python bindings
  • Implementing LogSummary for Python message types
  • Enabling monitor to read Python node logs from /dev/shm/horus_logs

Custom Exceptions

HORUS defines three custom exception types plus maps internal errors to standard Python exceptions:

from horus import HorusNotFoundError, HorusTransformError, HorusTimeoutError

try:
    result = some_horus_operation()
except HorusNotFoundError:
    print("Resource not found")
except HorusTransformError:
    print("Transform computation failed")
except HorusTimeoutError:
    print("Operation timed out")

Custom exceptions (inherit from Exception):

ExceptionWhen RaisedRust Source
HorusNotFoundErrorTopic, frame, node, or parent frame not foundHorusError::NotFound
HorusTransformErrorTransform extrapolation or stale dataHorusError::Transform
HorusTimeoutErrorBlocking operation exceeded time limitHorusError::Timeout

Standard Python exceptions raised by HORUS operations:

Python ExceptionWhen RaisedRust Source
IOErrorFile or IPC I/O failuresHorusError::Io
MemoryErrorShared memory or pool allocation failuresHorusError::Memory
ValueErrorInvalid parameters, bad config, parse errorsHorusError::InvalidInput, InvalidDescriptor, Parse, Config
TypeErrorSerialization/deserialization failuresHorusError::Serialization
RuntimeErrorInternal or unmapped errorsAll other variants

All exceptions preserve the original Rust error message, so you get full context:

try:
    tf = tf_tree.tf("nonexistent", "world")
except HorusNotFoundError as e:
    print(e)  # "Frame not found: nonexistent"

try:
    img = Image(height=-1, width=640, encoding="rgb8")
except ValueError as e:
    print(e)  # "Invalid input: height must be positive"

Catch hierarchy — order matters when catching:

try:
    result = horus_operation()
except HorusNotFoundError:
    pass  # Specific: missing resource
except HorusTransformError:
    pass  # Specific: TF failure
except HorusTimeoutError:
    pass  # Specific: deadline exceeded
except (ValueError, TypeError):
    pass  # Bad input or serialization
except (IOError, MemoryError):
    pass  # System-level failures
except RuntimeError:
    pass  # Catch-all for internal errors

See Also


Remember: With HORUS Python, you focus on what your robot does, not how the framework works!