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: the same shared-memory transport Rust uses, reached across the PyO3 boundary — expect single-digit microseconds per send/recv from Python, not the 40-85ns the backend itself costs
- Per-Node Rate Control: Different nodes at different frequencies (100Hz sensor, 10Hz logger)
- Message Timestamps: most typed messages carry a nanosecond
timestamp_nsfield you set on publish - 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
The HORUS installer (./install.sh) installs only the horus CLI binary — it does not build
the Python bindings. Install them yourself with maturin:
# 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.90+
- 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] | dict, optional): Topics to publish to. A dict maps a topic name to its message type —{"cmd": horus.CmdVel}— which is how you declare a typed topicsubs(str | list[str] | dict, optional): Topics to subscribe from (same forms aspubs)tick(callable): Function called each cycle, receives(node)as argumentrate(float, optional): Execution rate in Hz (default: 30)init(callable, optional): Setup function, called once at startshutdown(callable, optional): Cleanup function, called once at endon_error(callable, optional): Error handler, called if tick raises an exceptiondefault_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.recv("input") # Get one message
# Get all messages
all_msgs = node.recv_all("input")
# Send messages
node.send("output", {"value": 42})
Node Methods:
| Method | Description |
|---|---|
node.send(topic, data) | Publish a message to a topic |
node.recv(topic) | Get one message (returns None if empty) |
node.recv_all(topic) | Get all available messages as a list |
node.has_msg(topic) | Check if messages are available |
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.publishers() | List of topics this node publishes to |
node.subscribers() | List of topics this node subscribes to |
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.recv("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.recv("raw")
result = data * 2.0
node.send("processed", result)
def display(node):
if node.has_msg("processed"):
value = node.recv("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.recv("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.recv("lidar")
if node.has_msg("camera"):
camera_data = node.recv("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:
# Real-time mode is a scheduler-wide setting
scheduler = horus.Scheduler(rt=True)
Adding Nodes:
add() takes the node and nothing else — every per-node knob lives on the Node(...)
constructor. It returns the scheduler, so calls chain:
from horus import us
motor_ctrl = horus.Node(name="motor", tick=drive, order=0, rate=1000, budget=300 * us)
planner = horus.Node(name="planner", tick=plan, order=5, compute=True)
telemetry = horus.Node(name="telem", tick=report, order=10, rate=1)
scheduler = horus.Scheduler(rt=True)
scheduler.add(motor_ctrl).add(planner).add(telemetry)
Per-Node Scheduling Parameters (set on Node(...)):
| Parameter | Type | Default | Description |
|---|---|---|---|
order | int | 0 | Execution order (lower = earlier) |
rate | float | 30 | Tick rate in Hz |
budget | float | None | Tick budget in seconds (auto: 80% of period) |
deadline | float | None | Tick deadline in seconds (auto: 95% of period) |
on_miss | str | None | Deadline miss policy: "warn", "skip", "safe_mode", "stop" |
failure_policy | str | None | "fatal", "restart", "skip", or "ignore" |
compute | bool | False | CPU-bound execution on a thread pool |
on | str | None | Event-driven: tick when this topic receives data |
priority | int | None | SCHED_FIFO priority, 1-99 (higher = more urgent) |
core | int | None | Pin to a CPU core index |
watchdog | float | None | Per-node watchdog timeout in seconds |
Async ticks need no flag — an async def tick is auto-detected and run on the async I/O pool.
Safe state
on_miss="safe_mode" calls enter_safe_state() on the node. Define it on a
class-based node to stop actuators when the node misses its deadline:
import horus
class MotorNode(horus.Node):
def __init__(self):
super().__init__(name="motor", rate=1000,
budget=300e-6, deadline=900e-6,
on_miss="safe_mode")
self.pwm = 0.0
def tick(self, info=None):
self.pwm = self.compute_pwm()
def compute_pwm(self):
return 0.0
def enter_safe_state(self):
self.pwm = 0.0 # motors off
It takes no arguments, matching the Rust and C++ signatures, and is optional — a node that does not define one is left alone.
The hook fires on the transition into safe mode, not on every miss, so a
node under sustained overload is safed once rather than re-safed every cycle.
scheduler.safety_stats()["degrade_activations"] counts those transitions.
Running and Monitoring:
| Method | Description |
|---|---|
scheduler.run(duration=None) | Run the scheduler. Pass duration in seconds, or None to run until Ctrl+C. |
scheduler.stop() | Stop the scheduler |
scheduler.get_node_stats(name) | Get stats dict for a node (total_ticks, errors_count, etc.) |
scheduler.set_node_rate(name, rate) | Change a node's tick rate at runtime (Hz) |
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.status() | Scheduler status string |
scheduler.capabilities() | Dict of scheduler capabilities |
horus.run() Convenience Function:
# Quick helper — creates a Scheduler, adds each node, and runs it
horus.run(sensor, controller, logger, duration=10)
horus.run(node, verbose=False) # Quieter output
horus.run() creates a Scheduler, adds each node in the order given, and runs it. Execution order is whatever you set via Node(order=N) (lower runs earlier, default 0) — it is not inferred from pubs/subs.
Message Timestamps
Most typed messages (CmdVel, Imu, Odometry, …) carry a nanosecond timestamp_ns field, but
nothing stamps it for you. The publisher sets it on the message it sends, and the subscriber compares it against
horus.timestamp_ns() to work out the message's age:
import horus
def sensor_tick(node):
# The publisher stamps the message
node.send("sensor_data", horus.CmdVel(1.0, 0.0, timestamp_ns=horus.timestamp_ns()))
def control_tick(node):
msg = node.recv("sensor_data")
if msg is None:
return
# Check message age
age = (horus.timestamp_ns() - msg.timestamp_ns) / 1e9
if age > 0.1: # More than 100ms old
node.log_warning(f"Stale data: {age*1000:.1f}ms old")
return
# Process fresh data
process(msg)
# timestamp_ns lives on the typed message, so declare the topic with its type
sensor = horus.Node(name="sensor", pubs={"sensor_data": horus.CmdVel},
tick=sensor_tick, rate=20, order=0)
control = horus.Node(name="control", subs={"sensor_data": horus.CmdVel},
tick=control_tick, rate=20, order=1)
horus.run(sensor, control, duration=5)
horus.timestamp_ns() returns the current time in nanoseconds from the same clock the message
field uses. A message whose publisher never set timestamp_ns reports 0. A string-named
(generic) topic carries no timestamp_ns field — put your own key in the dict instead.
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.recv("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
def sensor_tick(node):
"""High-frequency sensor (100Hz)"""
# The publisher stamps the message — nothing stamps it for you
imu = {"accel_x": 1.0, "accel_y": 0.0, "accel_z": 9.8,
"timestamp_ns": horus.timestamp_ns()}
node.send("imu_data", imu)
node.log_info("Published IMU")
def control_tick(node):
"""Medium-frequency control (50Hz)"""
if node.has_msg("imu_data"):
imu = node.recv("imu_data")
# Check for stale data
age = (horus.timestamp_ns() - imu["timestamp_ns"]) / 1e9
if age > 0.05:
node.log_warning("Stale IMU data!")
return
cmd = {"linear": 1.0, "angular": 0.0, "timestamp_ns": horus.timestamp_ns()}
node.send("cmd_vel", cmd)
def logger_tick(node):
"""Low-frequency logging (10Hz)"""
if node.has_msg("cmd_vel"):
msg = node.recv("cmd_vel")
latency = (horus.timestamp_ns() - msg["timestamp_ns"]) / 1e6
node.log_info(f"Command latency: {latency:.1f}ms")
# Create nodes — rate and execution order live on the constructor
sensor = horus.Node(name="imu", pubs="imu_data", tick=sensor_tick, order=0, rate=100)
controller = horus.Node(name="ctrl", subs="imu_data", pubs="cmd_vel", tick=control_tick, order=1, rate=50)
logger = horus.Node(name="log", subs="cmd_vel", tick=logger_tick, order=2, rate=10)
# add() takes the node and nothing else, and returns the scheduler so calls chain
scheduler = horus.Scheduler()
scheduler.add(sensor).add(controller).add(logger)
scheduler.run(duration=5.0)
# Check statistics
stats = scheduler.get_node_stats("imu")
print(f"Sensor: {stats['total_ticks']} ticks in 5 seconds")
The endpoint Parameter
The Python bindings have no network transport. horus_py contains no socket code and
does not link horus_net, so a Python topic never leaves the machine it was created on.
endpoint= names the topic; it does not choose a transport. If the string contains an
@, everything from the @ onward is discarded and a normal shared-memory topic is
created from the part before it. Topic(CmdVel, endpoint="cmdvel@192.168.1.100:8000")
constructs successfully, opens no socket, and talks only to other processes on the same
machine. Earlier versions of this page documented UDP, Unix-socket and router forms;
none of them were ever implemented.
For cross-machine topics today, use the Rust replicator — build with --features net or
run horus run --net, described in
Network Backends.
from horus import Topic, CmdVel
# Shared memory, name taken from the message type
local_topic = Topic(CmdVel)
# Shared memory under an explicit name
named_topic = Topic(CmdVel, endpoint="cmdvel")
Topic Methods
| Method | Description |
|---|---|
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.name | Property: the topic name string |
topic.backend_type | Property: the active backend name (e.g., "PodShm", "SpmcShm"); "Unknown" until the backend resolves |
topic.is_network_topic | Property: True when the endpoint string contains an @. Cosmetic only — no topic uses a network transport (see above) |
topic.endpoint | Property: 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. messages_sent/messages_received stay at 0 unless verbose content logging is on — use horus topic hz for traffic |
topic.is_generic() | Returns True if this is a generic (string-name) topic |
topic.try_recv() | Like recv(), without the logging path |
topic.read_latest() | Newest value without consuming the queue — for state-like data |
topic.pending_count() | Messages waiting to be read |
topic.pub_count() / topic.sub_count() | Publishers and subscribers currently attached |
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. "SpmcShm"
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 Type | Serialization | Use Case |
|---|---|---|
Typed (Topic(CmdVel)) | Direct field extraction (no serde) | Production, cross-language, high-frequency |
Generic (Topic("name")) | Python → JSON → MessagePack | Dynamic schemas, prototyping, Python-only |
Backend Selection
Topic selects the optimal backend automatically from the topology. It is not overridable from
Python — the only constructor knobs are capacity= and endpoint=:
from horus import Topic, CmdVel
# Backend is chosen for you
topic = Topic(CmdVel)
# Custom ring buffer capacity
big = Topic(CmdVel, capacity=4096)
Backends HORUS Can Select:
The latencies below are the backends' own transport cost, measured from Rust. A Python send()/recv() pays the PyO3 and GIL crossing on top, which dominates — budget microseconds, not nanoseconds, for Python-to-Python traffic.
Every backend is shared memory and cross-process — there is no same-thread or intra-process path.
| Backend | Latency | Description |
|---|---|---|
FanoutShm | ~40ns | Cross-process, contention-free MPMC via a shared-memory SPSC matrix |
PodShm | ~50ns | Cross-process broadcast of a POD message type |
MpscShm | ~65ns | Cross-process, multiple producers, single consumer |
SpscShm | ~85ns | Cross-process, single producer, single consumer |
A single producer with several consumers gets PodShm (fixed-size messages) or
FanoutShm (everything else), so each consumer sees the stream independently. SpmcShm
exists in the enum but backend detection never chooses it — its consumers share one tail
and compete for messages, which is queue semantics rather than pub/sub.
topic.backend_type reports the resolved cross-process backend once it is known — one of
"Unknown", "PodShm", "SpscShm", "SpmcShm", "MpscShm", or "FanoutShm".
When to Use What
| Transport | Latency | Use Case |
|---|---|---|
Local (Topic(CmdVel)) | ~40-85ns transport, µs from Python | Same-machine communication (auto-detected backend) |
Local SPSC (auto-selected SpscShm) | ~85ns | Same-machine, single producer/consumer |
Local MPMC (auto-selected PodShm for POD types, FanoutShm otherwise) | ~40-50ns | Same-machine, multiple producers/consumers |
Multi-Machine Systems
Python cannot do this on its own. A Python node reaches another machine only when a Rust
process built with the net feature replicates the topic for it — the replicator is what
carries the bytes, and the Python side simply publishes to, or subscribes from, the
ordinary shared-memory topic on its own host.
# On each machine: the replicator runs alongside your nodes and mirrors topics over UDP
horus run --net
The Python program itself stays exactly as it is above — Topic(Odometry) — with no
endpoint. Which topics cross the network is decided by the [network] section of
horus.toml, not in Python: see
Networking for import, deny_export
and the peer filter.
Integration with Python Ecosystem
The examples in this section use bare-string topics (subs="raw_data"), which serialise
through the generic path. That path rejects anything whose encoded form exceeds 4096
bytes:
ValueError: Invalid input: 'data' out of range: expected [0..4096], got 14619
That is roughly a thousand floats — fine for feature vectors, spectra and control
values, and far too small for an image. A 480x640x3 frame is 921,600 values and cannot
travel this way at all. For images use horus.Image with a typed topic, which is
pool-backed and never goes through this encoder.
Generic topics also carry only JSON-shaped values: dicts, lists, strings, numbers and
booleans. bytes and complex both raise TypeError.
NumPy Integration
import horus
import numpy as np
def process_array(node):
if node.has_msg("raw_data"):
data = node.recv("raw_data")
# Convert to NumPy array
arr = np.array(data)
# Process with NumPy
result = np.fft.fft(arr)
# np.fft returns complex128 — a generic topic cannot carry Python
# `complex` ("unsupported type complex"), so send magnitudes instead
node.send("fft_result", np.abs(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"):
frame = node.recv("camera") # horus.Image
img = frame.to_numpy() # zero-copy view, (H, W, 3) uint8
# Apply OpenCV processing
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)
# Publish result as an Image, not a list
node.send("edges", horus.Image.from_numpy(edges))
vision = horus.Node(
subs={"camera": {"type": horus.Image}},
pubs={"edges": {"type": horus.Image}},
tick=process_image,
rate=30
)
Images go over typed topics carrying horus.Image, which is pool-backed: the pixels
stay in shared memory and only a descriptor moves. Flattening a frame to a list and
sending it over a generic topic cannot work — a 480x640x3 frame is 921,600 values against
a 4096-byte ceiling.
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.recv("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.recv("velocity")
if node.has_msg("position"):
state.position = node.recv("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.recv("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
# Per-node rate and order are set on the Node constructor
sensor = horus.Node(name="sensor", tick=sensor_tick, order=0, rate=100) # 100Hz
controller = horus.Node(name="ctrl", tick=control_tick, order=1, rate=50) # 50Hz
logger = horus.Node(name="log", tick=logger_tick, order=2, rate=10) # 10Hz
scheduler = horus.Scheduler()
scheduler.add(sensor).add(controller).add(logger)
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
# "sensor_data" is a typed topic and its publisher set timestamp_ns
def control_tick(node):
data = node.recv("sensor_data")
if data is None:
return
# Skip stale data to maintain real-time performance
age = (horus.timestamp_ns() - data.timestamp_ns) / 1e9
if age > 0.1:
node.log_warning("Skipping stale sensor data")
return
# Process fresh data only
process(data)
3. Use Dicts for Messages
# Send messages as Python dicts (serialized via MessagePack)
cmd = {"linear": 1.5, "angular": 0.8}
node.send("cmd_vel", cmd)
4. Batch Processing
# Use node.recv_all() to process all available messages at once
def batch_processor(node):
messages = node.recv_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.recv("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.recv("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
pytest tests/test_rate_control.py # Per-node rates
pytest tests/test_timestamps.py # Timestamps
pytest tests/test_message_integrity.py # Typed messages
pytest tests/test_cross_language_parity.py # Typed messages across languages
# With coverage
pytest --cov=horus tests/
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: RuntimeWarning: horus Rust extension not available — running in mock
# mode. Most functionality will not work. Install with: maturin develop
# Use for unit testing Python logic without HORUS running
Debugging Tips
# Add nodes to a scheduler (all per-node config lives on Node(...))
scheduler = horus.Scheduler()
scheduler.add(my_node)
# Check node statistics
stats = scheduler.get_node_stats("my_node")
print(f"Ticks: {stats['total_ticks']}, Errors: {stats['errors_count']}")
# Monitor message timestamps (typed topic; the publisher must have set timestamp_ns)
msg = node.recv("topic")
if msg:
age = (horus.timestamp_ns() - msg.timestamp_ns) / 1e9
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}) # JSON-shaped value, MessagePack on the wire
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.recv("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.recv("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.recv("input")) # Memory leak!
# GOOD:
def good_tick(node):
data = node.recv("input")
process_and_discard(data) # Process immediately
Monitor Integration and Logging
Node Logging
Python node logs go through the same Rust hlog system the rest of HORUS uses, so they
are visible to horus log and horus monitor — no print() workaround needed:
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")
log_warning() and log_error() additionally increment the node's warning and error counters
in its metrics.
Limitation: these methods only work while the scheduler is driving the node — that is,
inside init, tick, or shutdown. Called from anywhere else they emit a RuntimeWarning
and the message is dropped.
Other Ways to Inspect Python Nodes
- Manual topic monitoring:
def tick(node):
if node.has_msg("input"):
data = node.recv("input")
print(f"[{node.name}] Received: {data}")
result = process(data) # your processing
node.send("output", result)
print(f"[{node.name}] Published: {result}")
- Node statistics:
scheduler = horus.Scheduler()
scheduler.add(node)
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']}")
See Also
- Examples - More code examples
- Core Concepts - Understanding HORUS architecture
- Monitor - Real-time monitoring and visualization
- Python Message Library - Typed message classes
- Multi-Language Support - Cross-language communication
- Performance - Optimization guide
Remember: With HORUS Python, you focus on what your robot does, not how the framework works!