← Dynamical Systems as ROS Nodes
Crazyflie Multi-Sensor Fusion: From Python to ROS2
In the Crazyflie Sensor Fusion tutorial, we designed a multi-sensor Kalman filter system in Python. Now we’ll deploy this system as ROS2 nodes, demonstrating a more complex architecture with three separate sensor nodes.
Key Insight: Multi-sensor fusion maps naturally to ROS!
Unlike single-sensor systems (like TurtleBot), the Crazyflie has:
Three sensor nodes: Motion capture, barometer, IMU
One fusion node: Kalman filter subscribing to all three
This demonstrates how ROS handles multi-rate, heterogeneous sensor systems.
Conceptual Foundation: From Dynamical Systems to ROS Nodes
Recall from the Crazyflie Sensor Fusion tutorial that we represented our multi-sensor fusion system as a composition of dynamical systems:
Each block is a DynamicalSystem with state evolution (f) and observation (h) functions. The sensor fusion node combines data from three separate sensor nodes (mocap, barometer, IMU).
The ROS Mapping
There is a direct correspondence between this dynamical system representation and a ROS node architecture:
The correspondence:
Each dynamical system block becomes a ROS node
Each data connection becomes a ROS topic
The parameter dictionary is replaced by message passing
The multi-sensor fusion becomes multi-topic subscription
Note: In the Python version, we had three separate sensor nodes publishing to different topics. In ROS, this multi-rate sensor fusion is handled naturally through the ROSNode staleness policies.
Adding Teleoperation
For aerial vehicles like the Crazyflie, we use joystick teleoperation instead of keyboard input. Teleoperation provides direct velocity control, bypassing the high-level navigation pipeline entirely:
Key Architectural Difference:
Autonomous Navigation: Setpoint Generator → Position Controller →
/cmd_vel→ PlantTeleoperation: Joystick Teleop →
/cmd_vel→ Plant
The Joystick Teleop node publishes velocity commands directly to /cmd_vel, completely bypassing both the setpoint generator and position controller. The multi-sensor Kalman filter continues running to provide state estimates for monitoring and visualization.
This design pattern is common in aerial robotics: teleoperation provides low-level velocity control for manual flight, while autonomous mode uses the full planning and control pipeline for waypoint following.
Note
This tutorial implements the basic ROS node architecture (without teleop). The teleop version will be demonstrated in a later tutorial.
Individual Node Blocks
Each ROS node wraps a dynamical system block. Here are the individual components:
Setpoint Generator
Position Controller
Crazyflie Plant
Kalman Filter Observer
In the following sections, we’ll implement each of these blocks as ROSNode instances, demonstrating how multi-sensor fusion from the theory notebook translates directly to ROS’s multi-topic subscription pattern.
Architecture Comparison: Python vs ROS2 Multi-Sensor System
Python Simulation (theory notebook):
# Concatenate measurements from 3 sensors
y_mocap = mocap_sensor(xk) + noise_mocap
y_baro = baro_sensor(xk) + noise_baro
y_imu = imu_sensor(xk) + noise_imu
yk_combined = np.vstack([y_mocap, y_baro, y_imu])
# Single KF update with stacked measurements
xhat_P = observer.step(y=yk_combined, ...)
ROS2 Nodes (this notebook):
setpoint_node → /setpoint (Vector3)
↓
controller_node → /cmd_vel (Twist)
↓
crazyflie_simulator_node → /mocap (Vector3)
→ /baro (Float64)
→ /imu (Vector3)
→ /true_state (Odometry)
↓ ↓ ↓
└──────────────────┴──────────────────┘
↓
kalman_filter_node → /estimate (Odometry)
Note
The three sensor topics converge at the Kalman filter node. This is the hallmark of sensor fusion in ROS!
ROS2 Multi-Sensor System Architecture
Our system consists of five ROS nodes (vs four for TurtleBot):
Node 1: Setpoint Generator
Publishes:
/setpoint(Vector3)Function: Generates 3D position references
Node 2: Position Controller
Subscribes:
/setpoint,/estimatePublishes:
/cmd_vel(Twist)Function: 3D proportional position control
Node 3: Crazyflie Simulator
Subscribes:
/cmd_velPublishes:
/mocap,/baro,/imu,/true_stateFunction: Simulates dynamics + three sensors
Node 4: Multi-Sensor Kalman Filter
Subscribes:
/mocap,/baro,/imu,/cmd_velPublishes:
/estimate(Odometry)Function: Fuses 3 heterogeneous sensors
Setup: Imports and Core Functions
import numpy as np
import matplotlib.pyplot as plt
from pykal import DynamicalSystem
from pykal.ros.ros_node import ROSNode
from pykal.algorithm_library.estimators.kf import KF
from pykal.data_change import corrupt
# ROS message types
from geometry_msgs.msg import Twist, Vector3
from nav_msgs.msg import Odometry
from std_msgs.msg import Float64
import rclpy
import time
print("Imports successful! Ready to deploy Crazyflie to ROS.")
Core Crazyflie Functions (from Theory Notebook)
# ============================================================================
# Crazyflie Dynamics
# ============================================================================
def crazyflie_f(xk: np.ndarray, uk: np.ndarray, dt: float) -> np.ndarray:
"""3D constant-velocity dynamics: [x, y, z, vx, vy, vz]."""
pos = xk[:3]
vel = xk[3:]
pos_new = pos + vel * dt
vel_new = uk # Assume instantaneous velocity response
return np.vstack([pos_new, vel_new])
def crazyflie_h_mocap(xk: np.ndarray) -> np.ndarray:
"""Motion capture: measures [x, y, z]."""
return xk[:3]
def crazyflie_h_baro(xk: np.ndarray) -> np.ndarray:
"""Barometer: measures z only."""
return xk[2:3]
def crazyflie_h_imu(xk: np.ndarray) -> np.ndarray:
"""IMU: measures velocity [vx, vy, vz]."""
return xk[3:]
def h_multisensor(xk: np.ndarray) -> np.ndarray:
"""Combined sensor model: [mocap(3), baro(1), imu(3)] = 7D."""
return np.vstack([crazyflie_h_mocap(xk), crazyflie_h_baro(xk), crazyflie_h_imu(xk)])
# ============================================================================
# KF Jacobians
# ============================================================================
def compute_F_crazyflie(dt: float) -> np.ndarray:
"""State transition Jacobian (constant for linear system)."""
I3 = np.eye(3)
return np.block([[I3, I3 * dt], [np.zeros((3, 3)), I3]])
def compute_H_multisensor() -> np.ndarray:
"""Measurement Jacobian for multi-sensor fusion (7x6)."""
return np.array(
[
[1, 0, 0, 0, 0, 0], # mocap x
[0, 1, 0, 0, 0, 0], # mocap y
[0, 0, 1, 0, 0, 0], # mocap z
[0, 0, 1, 0, 0, 0], # baro z
[0, 0, 0, 1, 0, 0], # imu vx
[0, 0, 0, 0, 1, 0], # imu vy
[0, 0, 0, 0, 0, 1], # imu vz
]
)
# ============================================================================
# Noise Covariances
# ============================================================================
Q_crazyflie = np.diag([0.001, 0.001, 0.001, 0.1, 0.1, 0.1]) # Process noise
# Individual sensor noise
R_mocap = np.diag([0.005, 0.005, 0.005]) # 5mm std
R_baro = np.array([[0.1]]) # 10cm std
R_imu = np.diag([0.1, 0.1, 0.1]) # 0.1 m/s std
# Combined (block-diagonal)
R_combined = np.block(
[
[R_mocap, np.zeros((3, 1)), np.zeros((3, 3))],
[np.zeros((1, 3)), R_baro, np.zeros((1, 3))],
[np.zeros((3, 3)), np.zeros((3, 1)), R_imu],
]
)
print("Core Crazyflie functions defined!")
print(f" State dimension: 6 (position + velocity)")
print(f" Measurement dimension: 7 (mocap:3 + baro:1 + imu:3)")
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[1], line 6
1 # ============================================================================
2 # Crazyflie Dynamics
3 # ============================================================================
----> 6 def crazyflie_f(xk: np.ndarray, uk: np.ndarray, dt: float) -> np.ndarray:
7 """3D constant-velocity dynamics: [x, y, z, vx, vy, vz]."""
8 pos = xk[:3]
NameError: name 'np' is not defined
Node 1: Setpoint Generator
ROS Wrapper Pattern: Publishes 3D position setpoints.
Message Format: Vector3 = (3,) array [x, y, z]
Key Difference from TurtleBot: No orientation (just position for quadrotor)
def create_setpoint_node(initial_position, transitions=None, rate_hz=10.0):
"""
Create ROS node that publishes position setpoints.
Parameters
----------
initial_position : np.ndarray
Starting position [x, y, z]
transitions : list of tuples
Each tuple is (time, new_position)
rate_hz : float
Publishing rate
"""
if transitions is None:
transitions = []
current_setpoint = np.array(initial_position).reshape(-1, 1)
transition_idx = 0
def setpoint_callback(tk):
nonlocal current_setpoint, transition_idx
# Check for transitions
if transition_idx < len(transitions):
t_switch, new_pos = transitions[transition_idx]
if tk >= t_switch:
current_setpoint = np.array(new_pos).reshape(-1, 1)
transition_idx += 1
# Return as Vector3
return {"setpoint": current_setpoint.flatten()}
node = ROSNode(
node_name="setpoint_generator",
callback=setpoint_callback,
subscribes_to=[],
publishes_to=[
("setpoint", Vector3, "/setpoint"),
],
rate_hz=rate_hz)
return node
# Create setpoint node
setpoint_node = create_setpoint_node(
initial_position=[0.0, 0.0, 1.0],
transitions=[(20.0, [1.0, 1.0, 1.5]), (40.0, [0.0, 0.0, 1.0])],
rate_hz=10.0)
print("Setpoint generator node created!")
print(f" Publishes: /setpoint (Vector3)")
print(f" Initial: [0, 0, 1.0] m")
print(f" Transitions: t=20s → [1, 1, 1.5], t=40s → [0, 0, 1.0]")
Node 2: Position Controller
ROS Wrapper Pattern: 3D proportional control.
Controller Law:
where \(\vec{r} \in \mathbb{R}^3\) is the reference position and \(\hat{\vec{p}} \in \mathbb{R}^3\) is the estimated position.
def create_position_controller_node(Kp=0.8, max_vel=0.5, rate_hz=50.0):
"""
Create ROS node for proportional position controller.
"""
def controller_callback(tk, setpoint, estimate):
"""
Args:
tk (float): Current time
setpoint (np.ndarray): (3) [x_r, y_r, z_r]
estimate (np.ndarray): (13) Odometry
Returns:
dict: {'cmd_vel': (6) Twist}
"""
# Extract position from setpoint
r = setpoint
# Extract position from estimate
phat = estimate[:3]
# Proportional control
error = r - phat
v_cmd = Kp * error
v_cmd = np.clip(v_cmd, -max_vel, max_vel)
# Pack as Twist: [vx, vy, vz, wx, wy, wz]
cmd_vel = np.concatenate([v_cmd, np.zeros(3)])
return {"cmd_vel": cmd_vel}
node = ROSNode(
node_name="position_controller",
callback=controller_callback,
subscribes_to=[
("/setpoint", Vector3, "setpoint"),
("/estimate", Odometry, "estimate"),
],
publishes_to=[
("cmd_vel", Twist, "/cmd_vel"),
],
rate_hz=rate_hz,
required_topics={"setpoint", "estimate"})
return node
controller_node = create_position_controller_node(Kp=0.8, max_vel=0.5, rate_hz=50.0)
print("Position controller node created!")
print(f" Subscribes: /setpoint, /estimate")
print(f" Publishes: /cmd_vel (Twist)")
print(f" Gain: Kp={0.8}")
Node 3: Crazyflie Simulator with Multi-Sensor Output
ROS Wrapper Pattern: Publishes three separate sensor topics.
Dynamics: Linear 3D motion
Three Sensor Models:
Motion Capture: \(y_{mocap} = \vec{p} + \mathcal{N}(0, R_{mocap})\) (high precision)
Barometer: \(y_{baro} = p_z + \text{bias} + \mathcal{N}(0, R_{baro})\) (drift + noise)
IMU: \(y_{imu} = \vec{v} + \mathcal{N}(0, R_{imu}) + \text{spikes}\) (noise + vibration)
Note
Unlike TurtleBot’s single /odom topic, we publish three separate sensor topics to simulate realistic multi-sensor hardware.
def create_crazyflie_simulator_node(dt=0.01, rate_hz=100.0):
"""
Create ROS node that simulates Crazyflie with multi-sensor output.
"""
# Internal state: [x, y, z, vx, vy, vz]
xk = np.array([[0.0], [0.0], [1.0], [0.0], [0.0], [0.0]])
def simulator_callback(tk, cmd_vel):
"""
Simulate dynamics and generate noisy sensor measurements.
Args:
tk (float): Current time
cmd_vel (np.ndarray): (6) Twist
Returns:
dict: {'mocap', 'baro', 'imu', 'true_state'}
"""
nonlocal xk
# Extract velocity commands
uk = cmd_vel[:3].reshape(-1, 1)
# Update state
xk = crazyflie_f(xk, uk, dt)
# Extract state components
pos = xk[:3].flatten()
vel = xk[3:].flatten()
# Generate true sensor measurements
mocap_clean = pos
baro_clean = pos[2]
imu_clean = vel
# Add realistic noise
# Mocap: Gaussian noise (high precision)
mocap_noisy = mocap_clean + np.random.multivariate_normal(np.zeros(3), R_mocap)
# Barometer: Bias + drift + Gaussian noise
baro_noisy = corrupt.with_bias(baro_clean, bias=0.02) # 2cm offset
baro_noisy = baro_noisy + np.random.normal(0, np.sqrt(R_baro[0, 0]))
# IMU: Gaussian noise + occasional spikes (vibration)
imu_noisy = imu_clean + np.random.multivariate_normal(np.zeros(3), R_imu)
if np.random.rand() < 0.03: # 3% spike rate
spike_idx = np.random.randint(0, 3)
imu_noisy[spike_idx] += np.random.choice([-1, 1]) * 0.2
# True state as Odometry (for visualization)
true_state = np.concatenate(
[
pos, # position
[0.0, 0.0, 0.0, 1.0], # quaternion (identity)
vel, # linear velocity
[0.0, 0.0, 0.0], # angular velocity
]
)
return {
"mocap": mocap_noisy,
"baro": np.array([baro_noisy]),
"imu": imu_noisy,
"true_state": true_state,
}
node = ROSNode(
node_name="crazyflie_simulator",
callback=simulator_callback,
subscribes_to=[
("/cmd_vel", Twist, "cmd_vel"),
],
publishes_to=[
("mocap", Vector3, "/mocap"),
("baro", Float64, "/baro"),
("imu", Vector3, "/imu"),
("true_state", Odometry, "/true_state"),
],
rate_hz=rate_hz)
return node
simulator_node = create_crazyflie_simulator_node(dt=0.01, rate_hz=100.0)
print("Crazyflie simulator node created!")
print(f" Subscribes: /cmd_vel (Twist)")
print(f" Publishes:")
print(f" - /mocap (Vector3): 3D position, ~5mm noise")
print(f" - /baro (Float64): Altitude, ~10cm noise + drift")
print(f" - /imu (Vector3): Velocity, ~0.1 m/s noise + spikes")
print(f" - /true_state (Odometry): Ground truth")
print(f" Rate: 100 Hz (conceptually represents fastest sensor)")
Node 4: Multi-Sensor Kalman Filter
ROS Wrapper Pattern: Subscribes to three sensor topics + /cmd_vel.
Sensor Fusion: Stacks measurements into 7D vector
Measurement Jacobian:
Block-Diagonal Noise: \(R = \text{diag}(R_{mocap}, R_{baro}, R_{imu})\)
Note
This is where sensor fusion happens! The KF callback receives all three sensor messages and combines them optimally based on their noise characteristics.
def create_kalman_filter_node(dt=0.01, rate_hz=100.0, Q=None, R=None):
"""
Create ROS node for multi-sensor Kalman filter.
"""
if Q is None:
Q = Q_crazyflie
if R is None:
R = R_combined
# Initial estimate
xhat = np.array([[0.0], [0.0], [1.0], [0.0], [0.0], [0.0]])
P = np.diag([0.1, 0.1, 0.1, 1.0, 1.0, 1.0])
def filter_callback(tk, mocap, baro, imu, cmd_vel):
"""
Multi-sensor KF update.
Args:
tk (float): Current time
mocap (np.ndarray): (3) [x, y, z]
baro (np.ndarray): (1) [z]
imu (np.ndarray): (3) [vx, vy, vz]
cmd_vel (np.ndarray): (6) Twist
Returns:
dict: {'estimate': (13) Odometry}
"""
nonlocal xhat, P
# Concatenate measurements: [mocap(3), baro(1), imu(3)] = 7D
yk = np.vstack([mocap.reshape(-1, 1), baro.reshape(-1, 1), imu.reshape(-1, 1)])
# Extract control input
uk = cmd_vel[:3].reshape(-1, 1)
# Compute Jacobians
Fk = compute_F_crazyflie(dt)
Hk = compute_H_multisensor()
# Run KF update
f_params = {"xk": xhat, "uk": uk, "dt": dt}
h_params = {"xk": xhat}
xhat_new, P_new = KF.f(
x_P=(xhat, P),
y=yk,
u=uk,
f=crazyflie_f,
F=lambda **params: Fk,
Q=lambda **params: Q,
h=h_multisensor,
H=lambda **params: Hk,
R=lambda **params: R,
f_params=f_params,
F_params={},
Q_params={},
h_params=h_params,
H_params={},
R_params={})
# Update state
xhat = xhat_new
P = P_new
# Extract estimate
pos_est = xhat[:3].flatten()
vel_est = xhat[3:].flatten()
# Convert to Odometry
estimate = np.concatenate(
[
pos_est,
[0.0, 0.0, 0.0, 1.0], # quaternion
vel_est,
[0.0, 0.0, 0.0], # angular velocity
]
)
return {"estimate": estimate}
node = ROSNode(
node_name="kalman_filter",
callback=filter_callback,
subscribes_to=[
("/mocap", Vector3, "mocap"),
("/baro", Float64, "baro"),
("/imu", Vector3, "imu"),
("/cmd_vel", Twist, "cmd_vel"),
],
publishes_to=[
("estimate", Odometry, "/estimate"),
],
rate_hz=rate_hz,
required_topics={"mocap", "baro", "imu", "cmd_vel"},
stale_config={
"mocap": {"after": 0.2, "policy": "hold"}, # 10 Hz sensor
"baro": {"after": 0.1, "policy": "hold"}, # 20 Hz sensor
"imu": {"after": 0.02, "policy": "hold"}, # 100 Hz sensor
})
return node
kf_node = create_kalman_filter_node(dt=0.01, rate_hz=100.0)
print("Multi-sensor Kalman filter node created!")
print(f" Subscribes:")
print(f" - /mocap (Vector3)")
print(f" - /baro (Float64)")
print(f" - /imu (Vector3)")
print(f" - /cmd_vel (Twist)")
print(f" Publishes: /estimate (Odometry)")
print(f" Fusion: Combines 3 sensors with optimal weighting")
print(f" Staleness policies: mocap(200ms), baro(100ms), imu(20ms)")
Running the Multi-Sensor ROS2 System
# Initialize ROS2
rclpy.init()
# Create all nodes
print("Creating ROS2 nodes...")
setpoint_node.create_node()
controller_node.create_node()
simulator_node.create_node()
kf_node.create_node()
print("All nodes created!")
# Start all nodes
print("\nStarting nodes...")
setpoint_node.start()
print(" ✓ Setpoint generator running")
simulator_node.start()
print(" ✓ Crazyflie simulator running (publishing 3 sensor topics!)")
kf_node.start()
print(" ✓ Multi-sensor Kalman filter running")
controller_node.start()
print(" ✓ Position controller running")
print("\n🚀 Crazyflie multi-sensor ROS system is live!")
print("\nROS2 Topic Graph:")
print(" /setpoint ← setpoint_generator")
print(" ↓")
print(" position_controller → /cmd_vel")
print(" ↓")
print(" crazyflie_simulator → /mocap, /baro, /imu, /true_state")
print(" ↓ ↓ ↓")
print(" └─────────┴────────┘")
print(" ↓")
print(" kalman_filter (FUSION!) → /estimate")
print(" ↓ (feedback)")
print(" position_controller")
print("\n💡 Tip: Run 'rqt_graph' to see the multi-sensor fusion architecture!")
print(" You'll see THREE sensor topics converging at kalman_filter.")
Data Collection
# Data logger node
def create_data_logger_node():
data_log = {
"time": [],
"setpoint": [],
"cmd_vel": [],
"true_state": [],
"mocap": [],
"baro": [],
"imu": [],
"estimate": [],
}
def logger_callback(tk, setpoint, cmd_vel, true_state, mocap, baro, imu, estimate):
data_log["time"].append(tk)
data_log["setpoint"].append(setpoint.copy())
data_log["cmd_vel"].append(cmd_vel.copy())
data_log["true_state"].append(true_state.copy())
data_log["mocap"].append(mocap.copy())
data_log["baro"].append(baro.copy())
data_log["imu"].append(imu.copy())
data_log["estimate"].append(estimate.copy())
return {}
node = ROSNode(
node_name="data_logger",
callback=logger_callback,
subscribes_to=[
("/setpoint", Vector3, "setpoint"),
("/cmd_vel", Twist, "cmd_vel"),
("/true_state", Odometry, "true_state"),
("/mocap", Vector3, "mocap"),
("/baro", Float64, "baro"),
("/imu", Vector3, "imu"),
("/estimate", Odometry, "estimate"),
],
publishes_to=[],
rate_hz=100.0)
return node, data_log
logger_node, data_log = create_data_logger_node()
logger_node.create_node()
logger_node.start()
print("Data logger started!")
# Run simulation
import time as pytime
T_sim = 60.0
print(f"\nRunning multi-sensor simulation for {T_sim} seconds...")
pytime.sleep(T_sim)
print(f"\nSimulation complete! Collected {len(data_log['time'])} samples.")
Stopping the System
print("Stopping nodes...")
logger_node.stop()
setpoint_node.stop()
controller_node.stop()
simulator_node.stop()
kf_node.stop()
rclpy.shutdown()
print("All nodes stopped. ROS2 shutdown complete.")
Visualization: Multi-Sensor Fusion Analysis
Summary: Multi-Sensor Python → ROS2 Deployment
We’ve successfully deployed the Crazyflie multi-sensor fusion system to ROS2!
Key Difference from TurtleBot:
Aspect |
TurtleBot (Single-Sensor) |
Crazyflie (Multi-Sensor) |
|---|---|---|
Sensors |
1 topic ( |
3 topics ( |
KF Subscribes |
2 topics |
4 topics |
Measurement dim |
3D |
7D (stacked) |
Noise matrix |
3×3 |
7×7 (block-diagonal) |
ROS pattern |
Simple |
Sensor fusion convergence |
Multi-Sensor ROS Pattern:
Python: Concatenate arrays in code
y = np.vstack([y1, y2, y3])
ROS: Topics converge at subscriber
/sensor1 ──┐
/sensor2 ──┼──→ kalman_filter (fusion!)
/sensor3 ──┘
Architecture Preservation:
Block diagram → ROS graph (with 3 sensor branches)
Sensor fusion visible in
rqt_graphSame KF algorithm as theory notebook
Sensor Characteristics Preserved:
Mocap: High precision (5mm)
Barometer: Drift + bias (10cm)
IMU: Noise + spikes (0.1 m/s)
Next Step: In the Gazebo tutorial, we’ll adapt this architecture to work with Gazebo’s actual sensor suite!
Architecture 2: Teleoperation Mode
In the previous section, we demonstrated autonomous navigation with setpoint tracking. Now we’ll demonstrate teleoperation mode, where a human operator directly controls the Crazyflie’s velocity using a joystick.
Key Architectural Difference:
Autonomous (previous): Setpoint Gen → Position Controller →
/cmd_vel→ Plant → Multi-Sensor ObserverTeleoperation (this section): Joystick Teleop →
/cmd_vel→ Plant → Multi-Sensor Observer
The teleoperation node (shown in RED in diagrams) is an external ROS2 tool, not wrapped in pykal.ROSNode. For aerial vehicles like the Crazyflie, we use joystick control instead of keyboard for smoother 3D maneuvering.
Nodes Used in Teleoperation Mode:
✅ Crazyflie Simulator - Receives
/cmd_velfrom teleop✅ Multi-Sensor Kalman Filter - Continues fusing mocap, barometer, IMU data
❌ Setpoint Generator - Not needed (bypassed)
❌ Position Controller - Not needed (bypassed)
🔴 Joystick Teleop - External tool (
joy_node+teleop_twist_joy)
# Re-initialize ROS2 (if needed after previous shutdown)
if not rclpy.ok():
rclpy.init()
# Create fresh simulator and KF nodes for teleoperation
print("Creating nodes for teleoperation mode...")
simulator_teleop_cf = create_crazyflie_simulator_node(dt=0.01, rate_hz=50.0)
kf_teleop_cf = create_kalman_filter_node(dt=0.01, rate_hz=50.0, Q=Q_crazyflie, R=R_multisensor)
simulator_teleop_cf.create_node()
kf_teleop_cf.create_node()
print("✓ Simulator and multi-sensor Kalman filter created")
# Start nodes
print("\nStarting nodes...")
simulator_teleop_cf.start()
print(" ✓ Crazyflie simulator running")
kf_teleop_cf.start()
print(" ✓ Multi-sensor Kalman filter running")
print("\n🚀 System ready for teleoperation!")
print("\nActive ROS2 Topics:")
print(" /cmd_vel ← (waiting for joystick teleop)")
print(" /mocap, /baro, /imu ← crazyflie_simulator (3 sensor topics!)")
print(" /estimate ← kalman_filter")
print("\nNOTE: Setpoint generator and position controller are NOT running (bypassed!)")
# Create a simulated joystick teleop node
def create_simulated_joystick_teleop():
"""
Simulate joystick teleop with programmed 3D maneuvers.
This mimics what teleop_twist_joy would publish,
demonstrating the teleoperation architecture for aerial vehicles.
"""
import time
# Programmed 3D maneuver sequence (aerial flight)
maneuvers = [
(0, 5, (0.3, 0.0, 0.0)), # 0-5s: Forward
(5, 10, (0.2, 0.2, 0.1)), # 5-10s: Forward-right + climb
(10, 15, (0.0, 0.0, 0.0)), # 10-15s: Hover
(15, 20, (-0.2, 0.0, 0.0)), # 15-20s: Backward
(20, 25, (0.0, 0.3, 0.0)), # 20-25s: Strafe right
(25, 30, (0.0, 0.0, -0.1)), # 25-30s: Descend
(30, 35, (0.2, 0.0, 0.0)), # 30-35s: Forward
]
start_time = None
def teleop_callback(tk):
nonlocal start_time
if start_time is None:
start_time = tk
elapsed = tk - start_time
# Find current maneuver
vx, vy, vz = 0.0, 0.0, 0.0
for t_start, t_end, (vx_cmd, vy_cmd, vz_cmd) in maneuvers:
if t_start <= elapsed < t_end:
vx, vy, vz = vx_cmd, vy_cmd, vz_cmd
break
# Pack as Twist: [vx, vy, vz, wx, wy, wz]
cmd_vel = np.array([vx, vy, vz, 0.0, 0.0, 0.0])
return {"cmd_vel": cmd_vel}
node = ROSNode(
node_name="simulated_joystick_teleop",
callback=teleop_callback,
subscribes_to=[],
publishes_to=[
("cmd_vel", Twist, "/cmd_vel"),
],
rate_hz=50.0)
return node
# Create and start simulated joystick teleop
print("Creating simulated joystick teleop...")
joystick_teleop = create_simulated_joystick_teleop()
joystick_teleop.create_node()
joystick_teleop.start()
print("✓ Simulated joystick teleop running (mimics joy_node + teleop_twist_joy)")
print("\nThis simulates a human operator flying the Crazyflie with a joystick!")
print("In real usage, you would run: ros2 run joy joy_node && ros2 run teleop_twist_joy teleop_node")
# Stop all teleoperation nodes
print("Stopping teleoperation nodes...")
teleop_logger_cf.stop()
joystick_teleop.stop()
simulator_teleop_cf.stop()
kf_teleop_cf.stop()
# Shutdown ROS2
rclpy.shutdown()
print("All nodes stopped. ROS2 shutdown complete.")
# Convert teleoperation data to arrays
time_teleop_cf = np.array(teleop_data_cf["time"])
cmd_teleop_cf = np.array(teleop_data_cf["cmd_vel"])
mocap_teleop = np.array(teleop_data_cf["mocap"])
baro_teleop = np.array(teleop_data_cf["baro"])
imu_teleop = np.array(teleop_data_cf["imu"])
est_teleop_cf = np.array(teleop_data_cf["estimate"])
# Extract 3D positions (ground truth from mocap)
mocap_x = mocap_teleop[:, 0]
mocap_y = mocap_teleop[:, 1]
mocap_z = mocap_teleop[:, 2]
# Extract estimates
est_x_cf = est_teleop_cf[:, 0]
est_y_cf = est_teleop_cf[:, 1]
est_z_cf = est_teleop_cf[:, 2]
# Extract velocity commands
vx_cmd_cf = cmd_teleop_cf[:, 0]
vy_cmd_cf = cmd_teleop_cf[:, 1]
vz_cmd_cf = cmd_teleop_cf[:, 2]
# Plotting
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure(figsize=(15, 10))
# Plot 1: 3D Trajectory (teleoperation)
ax = fig.add_subplot(2, 3, 1, projection='3d')
ax.plot(mocap_x, mocap_y, mocap_z, 'b-', linewidth=2, label='Mocap (Ground Truth)', alpha=0.7)
ax.plot(est_x_cf, est_y_cf, est_z_cf, 'r--', linewidth=2, label='KF Estimate', alpha=0.7)
ax.scatter(mocap_x[0], mocap_y[0], mocap_z[0], c='green', s=150, marker='o', label='Start')
ax.scatter(mocap_x[-1], mocap_y[-1], mocap_z[-1], c='red', s=150, marker='X', label='End')
ax.set_xlabel('X (m)', fontsize=10)
ax.set_ylabel('Y (m)', fontsize=10)
ax.set_zlabel('Z (m)', fontsize=10)
ax.set_title('Teleoperation: 3D Flight Path', fontsize=13, fontweight='bold')
ax.legend()
# Plot 2: XY Trajectory (top view)
ax = fig.add_subplot(2, 3, 2)
ax.plot(mocap_x, mocap_y, 'b-', linewidth=2, label='Mocap', alpha=0.7)
ax.plot(est_x_cf, est_y_cf, 'r--', linewidth=2, label='KF Estimate', alpha=0.7)
ax.scatter(mocap_x[0], mocap_y[0], c='green', s=150, marker='o')
ax.scatter(mocap_x[-1], mocap_y[-1], c='red', s=150, marker='X')
ax.set_xlabel('X (m)', fontsize=10)
ax.set_ylabel('Y (m)', fontsize=10)
ax.set_title('XY Trajectory (Top View)', fontsize=13, fontweight='bold')
ax.legend()
ax.grid(True, alpha=0.3)
ax.axis('equal')
# Plot 3: Altitude (Z)
ax = fig.add_subplot(2, 3, 3)
ax.plot(time_teleop_cf, mocap_z, 'b-', label='Mocap Z', alpha=0.7)
ax.plot(time_teleop_cf, est_z_cf, 'r--', linewidth=2, label='KF Est. Z', alpha=0.7)
ax.plot(time_teleop_cf, baro_teleop, 'g:', alpha=0.5, label='Barometer')
ax.set_xlabel('Time (s)', fontsize=10)
ax.set_ylabel('Altitude (m)', fontsize=10)
ax.set_title('Altitude: Multi-Sensor Fusion', fontsize=13, fontweight='bold')
ax.legend()
ax.grid(True, alpha=0.3)
# Plot 4: Velocity Commands
ax = fig.add_subplot(2, 3, 4)
ax.plot(time_teleop_cf, vx_cmd_cf, label='vx', alpha=0.7)
ax.plot(time_teleop_cf, vy_cmd_cf, label='vy', alpha=0.7)
ax.plot(time_teleop_cf, vz_cmd_cf, label='vz', alpha=0.7)
ax.set_xlabel('Time (s)', fontsize=10)
ax.set_ylabel('Command (m/s)', fontsize=10)
ax.set_title('Joystick Commands to /cmd_vel', fontsize=13, fontweight='bold')
ax.legend()
ax.grid(True, alpha=0.3)
# Plot 5: Position Error
ax = fig.add_subplot(2, 3, 5)
pos_error_cf = np.sqrt((mocap_x - est_x_cf)**2 + (mocap_y - est_y_cf)**2 + (mocap_z - est_z_cf)**2)
ax.plot(time_teleop_cf, pos_error_cf * 1000, 'm-', linewidth=1.5)
ax.set_xlabel('Time (s)', fontsize=10)
ax.set_ylabel('Error (mm)', fontsize=10)
ax.set_title('Multi-Sensor KF Error', fontsize=13, fontweight='bold')
ax.grid(True, alpha=0.3)
# Plot 6: Sensor Data (IMU velocity)
ax = fig.add_subplot(2, 3, 6)
ax.plot(time_teleop_cf, imu_teleop[:, 0], label='IMU vx', alpha=0.6)
ax.plot(time_teleop_cf, imu_teleop[:, 1], label='IMU vy', alpha=0.6)
ax.plot(time_teleop_cf, imu_teleop[:, 2], label='IMU vz', alpha=0.6)
ax.set_xlabel('Time (s)', fontsize=10)
ax.set_ylabel('Velocity (m/s)', fontsize=10)
ax.set_title('IMU Sensor Data', fontsize=13, fontweight='bold')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"\nTeleoperation Performance (3D Flight):")
print(f" Mean position error: {np.mean(pos_error_cf)*1000:.2f} mm")
print(f" Max position error: {np.max(pos_error_cf)*1000:.2f} mm")
print(f" Total 3D distance: {np.sum(np.sqrt(np.diff(mocap_x)**2 + np.diff(mocap_y)**2 + np.diff(mocap_z)**2)):.2f} m")
print(f" Altitude range: [{np.min(mocap_z):.2f}, {np.max(mocap_z):.2f}] m")
Summary: Two ROS Architectures with Multi-Sensor Fusion
This notebook demonstrated two distinct control architectures for the Crazyflie ROS system, both utilizing multi-sensor fusion:
Architecture 2: Teleoperation (Manual Joystick Control)
Nodes Active:
🔴 Joystick Teleop (external tools:
joy_node+teleop_twist_joy)✅ Crazyflie Simulator (
pykal.ROSNode) - publishes 3 sensor topics✅ Multi-Sensor Kalman Filter (
pykal.ROSNode) - fuses mocap + baro + IMU
Nodes Bypassed:
❌ Setpoint Generator (not needed)
❌ Position Controller (not needed)
Data Flow: Joystick Teleop → /cmd_vel → Plant → [mocap, baro, IMU] → Observer
Use Case: Direct manual 3D flight control for intervention or testing
Key Insights
Multi-Sensor Fusion Preserved: The 7D measurement fusion (mocap + baro + IMU) works in both modes
Modularity: Easy mode switching by activating/deactivating nodes
Red Nodes: External teleop tools shown in RED to distinguish from
pykal.ROSNodewrappersObserver Persistence: Kalman filter provides state estimation in both autonomous and teleop modes
3D Control: Aerial vehicles benefit from joystick’s analog input for smooth 3D maneuvering
Common Pattern: Dual-mode architecture (autonomous + teleop) is standard for drones
Architectural Consistency:
Theory (sensor fusion notebook): Multi-sensor fusion as DynamicalSystem
ROS (this notebook): Multi-sensor fusion as ROSNode with 3 topic subscriptions
Gazebo (next tutorial): Same architecture with physics simulation
The multi-sensor architecture remains consistent across the entire pipeline: Theory → Software → ROS → Gazebo → Hardware!
Visualization: Teleoperation Results
Stopping Teleoperation Nodes
# Create logger for teleoperation data
def create_teleop_logger_cf():
"""Logger for teleoperation mode (no setpoint reference)."""
teleop_data_cf = {
"time": [],
"cmd_vel": [],
"mocap": [],
"baro": [],
"imu": [],
"estimate": [],
}
def logger_callback(tk, cmd_vel, mocap, baro, imu, estimate):
teleop_data_cf["time"].append(tk)
teleop_data_cf["cmd_vel"].append(cmd_vel.copy())
teleop_data_cf["mocap"].append(mocap.copy())
teleop_data_cf["baro"].append(baro.copy())
teleop_data_cf["imu"].append(imu.copy())
teleop_data_cf["estimate"].append(estimate.copy())
return {}
node = ROSNode(
node_name="teleop_logger_cf",
callback=logger_callback,
subscribes_to=[
("/cmd_vel", Twist, "cmd_vel"),
("/mocap", Vector3, "mocap"),
("/baro", Float64, "baro"),
("/imu", Vector3, "imu"),
("/estimate", Odometry, "estimate"),
],
publishes_to=[],
rate_hz=50.0)
return node, teleop_data_cf
# Create and start logger
from std_msgs.msg import Float64
from geometry_msgs.msg import Vector3
teleop_logger_cf, teleop_data_cf = create_teleop_logger_cf()
teleop_logger_cf.create_node()
teleop_logger_cf.start()
print("Data logger started!")
# Run teleoperation for 35 seconds
import time as pytime
T_teleop_cf = 35.0
print(f"\nRunning teleoperation for {T_teleop_cf} seconds...")
print("Crazyflie is being controlled via simulated joystick input!")
print("Multi-sensor fusion (mocap + baro + IMU) continues in background...")
pytime.sleep(T_teleop_cf)
print(f"\nTeleoperation complete! Collected {len(teleop_data_cf['time'])} samples.")
Data Collection During Teleoperation
Running the Joystick Teleop Tool
To control the Crazyflie manually with a joystick, we use the standard ROS2 joy and teleop_twist_joy packages.
In separate terminals, run:
# Terminal 1: Start joystick node
ros2 run joy joy_node
# Terminal 2: Start teleop twist joy node
ros2 run teleop_twist_joy teleop_node
Joystick Controls (typical configuration):
Left stick Y-axis - Forward/backward velocity
Left stick X-axis - Left/right velocity
Right stick X-axis - Yaw rotation
Buttons - Enable/disable, speed scaling
Note
For aerial vehicles, joystick control provides smoother 3D velocity commands compared to keyboard discrete inputs. The teleop publishes 6-DOF Twist messages: [vx, vy, vz, wx, wy, wz].
For this notebook demo, we’ll simulate joystick commands programmatically to demonstrate the architecture:
Setting Up Teleoperation Mode
We only need to run the simulator and multi-sensor observer nodes. The setpoint generator and position controller are not used in teleoperation mode.