Correct telemetry rate to 110Hz and refactor Mahony AHRS API

- Update documentation across all components to reflect actual GD32
  telemetry rate of 110Hz (was incorrectly documented as 500Hz)
- Add RawImuData and CalibratedImuData structs for cleaner IMU data
  handling in Mahony AHRS filter
- Refactor MahonyAhrs::update() and update_calibrated() to accept
  IMU data structs instead of individual parameters
- Adjust calibration samples from 1500 to 330 for ~3 second calibration
  at the correct 110Hz rate
This commit is contained in:
codetiger
2025-12-08 19:39:10 +05:30
parent 0c13b7bb12
commit 6ba27b98ea
36 changed files with 242 additions and 172 deletions
+4 -4
View File
@@ -25,7 +25,7 @@ VacuumTiger is an open-source robotic vacuum firmware project for the CRL-200S h
│ TCP 5555/5556 │
┌─────────────▼───────────────▼───────────────────┐
│ SangamIO Daemon (Robot) │
│ • Telemetry streaming @ 500Hz (sensors) │
│ • Telemetry streaming @ 110Hz (sensors) │
│ • Lidar streaming @ 5Hz (360° scans) │
│ • Command processing (motion/actuators) │
│ • Real-time control loop (50Hz) │
@@ -123,7 +123,7 @@ See `sangam-io/proto/sangamio.proto` for SangamIO messages (sensors, commands) a
### Topics
**Outbound (Daemon → Client)**:
- `sensors/sensor_status`: Sensor data @ 500Hz (all 13 sensors)
- `sensors/sensor_status`: Sensor data @ 110Hz (all 13 sensors)
- `sensors/device_version`: Version info (one-time)
- `sensors/lidar`: LidarScan @ 5Hz (point cloud)
@@ -248,7 +248,7 @@ VacuumTiger/
### GD32 Protocol
- **Format**: `[0xFA 0xFB] [LEN] [CMD] [PAYLOAD] [CRC]`
- **CRC**: 16-bit big-endian word sum checksum
- **Status**: CMD=0x15, 96 bytes @ 500Hz
- **Status**: CMD=0x15, 96 bytes @ 110Hz
- **Location**: `src/devices/crl200s/gd32/protocol.rs`
### Delta-2D Protocol
@@ -264,7 +264,7 @@ VacuumTiger/
- **CPU Usage**: <1% on Allwinner A33
- **Latency**: <25ms command-to-action
- **Streaming Rates**:
- Telemetry: 500Hz (every GD32 packet)
- Telemetry: 110Hz (every GD32 packet)
- Lidar: 5Hz (native scan rate)
- Commands: On-demand
+1 -1
View File
@@ -9,7 +9,7 @@ VacuumTiger is a modular, configuration-driven firmware stack that makes it easy
- **Configurable**: Define sensors, actuators, and hardware in a single TOML file
- **Extensible**: Add new robot platforms by implementing one trait
- **Generic Protocol**: TCP streaming works with any SLAM application
- **Real-time**: 500Hz sensor updates, <25ms command latency
- **Real-time**: 110Hz sensor updates, <25ms command latency
- **Hackable**: Small codebase (~4K lines Rust), no dependencies on proprietary SDKs
## How It Works
+2 -2
View File
@@ -40,7 +40,7 @@ DhruvaSLAM computes odometry from wheel encoders and gyroscopes, then performs o
|----------|-------|
| Language | Rust 2021 edition |
| Version | 0.1.0 |
| Input Rate | 500Hz (sensors), 5Hz (lidar) |
| Input Rate | 110Hz (sensors), 5Hz (lidar) |
| Output Rate | Configurable (default 50Hz odometry) |
## Architecture
@@ -230,7 +230,7 @@ dhruva-slam/
## Data Flow
```
SangamIO (500Hz sensors, 5Hz lidar)
SangamIO (110Hz sensors, 5Hz lidar)
│
▼
┌───────────────────────────────────────┐
+2 -2
View File
@@ -9,7 +9,7 @@ sangam_address = "192.168.68.101:5555"
[output]
# TCP port for visualization clients (Drishti connects here)
bind_port = 5557
# Output rate in Hz (decimated from 500Hz input)
# Output rate in Hz (decimated from 110Hz input)
odometry_rate_hz = 50
# SLAM status/diagnostics rate (Hz)
slam_status_rate_hz = 5.0
@@ -36,7 +36,7 @@ alpha = 0.8
gyro_scale = 0.0001745
# Gyroscope Z bias in raw units
# Auto-calibrated at startup (1500 samples ~3 seconds)
# Auto-calibrated at startup (330 samples ~3 seconds)
gyro_bias_z = 0.0
[slam]
+27 -11
View File
@@ -12,7 +12,9 @@ use dhruva_slam::core::types::{LaserScan, Pose2D};
use dhruva_slam::io::bag::{BagMessage, BagPlayer};
use dhruva_slam::sensors::odometry::{WheelOdometry, WheelOdometryConfig};
use dhruva_slam::sensors::preprocessing::{PreprocessorConfig, ScanPreprocessor};
use dhruva_slam::utils::{GYRO_SCALE, GYRO_SIGN, I16Stats, WHEEL_BASE, WHEEL_TICKS_PER_METER, std_dev_i16};
use dhruva_slam::utils::{
GYRO_SCALE, GYRO_SIGN, I16Stats, WHEEL_BASE, WHEEL_TICKS_PER_METER, std_dev_i16,
};
use std::env;
fn main() {
@@ -59,8 +61,8 @@ fn show_pose_analysis(bag_path: &str) {
let mut matcher = HybridP2LMatcher::new(HybridP2LMatcherConfig::default());
// Track poses
let mut odom_only_pose = Pose2D::identity(); // Pure odometry
let mut slam_pose = Pose2D::identity(); // Scan-matched pose (like in benchmark)
let mut odom_only_pose = Pose2D::identity(); // Pure odometry
let mut slam_pose = Pose2D::identity(); // Scan-matched pose (like in benchmark)
let mut prev_odom_at_scan = Pose2D::identity();
let mut odom_pose = Pose2D::identity();
@@ -122,8 +124,9 @@ fn show_pose_analysis(bag_path: &str) {
// EXPERIMENT: Try using odom_delta directly without inversion
// The matcher should find the transform that aligns source→target
// which is the robot motion itself
let initial_guess = odom_delta; // Changed from odom_delta.inverse()
let match_result = matcher.match_scans(&processed, prev_scan, &initial_guess);
let initial_guess = odom_delta; // Changed from odom_delta.inverse()
let match_result =
matcher.match_scans(&processed, prev_scan, &initial_guess);
// The match result gives transform from current→previous
// Invert to get motion from previous→current
@@ -180,11 +183,15 @@ fn show_pose_analysis(bag_path: &str) {
if scan_count > 1 {
println!(
" SLAM pose: (x={:.4}m, y={:.4}m, θ={:.2}°)",
slam_pose.x, slam_pose.y, slam_pose.theta.to_degrees()
slam_pose.x,
slam_pose.y,
slam_pose.theta.to_degrees()
);
println!(
" Odom pose: (x={:.4}m, y={:.4}m, θ={:.2}°)",
odom_only_pose.x, odom_only_pose.y, odom_only_pose.theta.to_degrees()
odom_only_pose.x,
odom_only_pose.y,
odom_only_pose.theta.to_degrees()
);
}
}
@@ -225,15 +232,21 @@ fn show_pose_analysis(bag_path: &str) {
println!("Final Poses:");
println!(
" Raw odom_pose: (x={:.4}m, y={:.4}m, θ={:.2}°)",
odom_pose.x, odom_pose.y, odom_pose.theta.to_degrees()
odom_pose.x,
odom_pose.y,
odom_pose.theta.to_degrees()
);
println!(
" Odom-only accumulated: (x={:.4}m, y={:.4}m, θ={:.2}°)",
odom_only_pose.x, odom_only_pose.y, odom_only_pose.theta.to_degrees()
odom_only_pose.x,
odom_only_pose.y,
odom_only_pose.theta.to_degrees()
);
println!(
" SLAM pose (scan-matched): (x={:.4}m, y={:.4}m, θ={:.2}°)",
global_pose.x, global_pose.y, global_pose.theta.to_degrees()
global_pose.x,
global_pose.y,
global_pose.theta.to_degrees()
);
println!();
@@ -254,7 +267,10 @@ fn show_pose_analysis(bag_path: &str) {
println!();
if delta_left > 0 && delta_right > 0 {
println!("Motion detected: FORWARD ({} ticks)", (delta_left + delta_right) / 2);
println!(
"Motion detected: FORWARD ({} ticks)",
(delta_left + delta_right) / 2
);
println!("Expected X position: {:.4}m", expected_x);
println!("Actual X position: {:.4}m", global_pose.x);
+7 -9
View File
@@ -40,7 +40,7 @@ struct ImuBiasCalibrator {
impl ImuBiasCalibrator {
fn new() -> Self {
Self {
samples: Vec::with_capacity(1500), // ~3 seconds at 500Hz
samples: Vec::with_capacity(330), // ~3 seconds at 110Hz
}
}
@@ -66,11 +66,7 @@ impl ImuBiasCalibrator {
)
});
[
(sum.0 / n) as f32,
(sum.1 / n) as f32,
(sum.2 / n) as f32,
]
[(sum.0 / n) as f32, (sum.1 / n) as f32, (sum.2 / n) as f32]
}
}
@@ -263,7 +259,10 @@ fn run(config: Config) -> Result<(), Box<dyn std::error::Error>> {
last_progress = Instant::now();
}
}
println!("\r Sensors stabilized. (discarded {} msgs) ", discarded_count);
println!(
"\r Sensors stabilized. (discarded {} msgs) ",
discarded_count
);
// Flush any buffered messages before calibration
let mut flushed = 0;
@@ -332,8 +331,7 @@ fn run(config: Config) -> Result<(), Box<dyn std::error::Error>> {
}
// ===== MOTION SETUP (if requested) =====
let motion_enabled =
config.linear_velocity.is_some() || config.angular_velocity.is_some();
let motion_enabled = config.linear_velocity.is_some() || config.angular_velocity.is_some();
if motion_enabled {
let linear = config.linear_velocity.unwrap_or(0.0);
let angular = config.angular_velocity.unwrap_or(0.0);
+1 -1
View File
@@ -341,7 +341,7 @@ fn run_main_loop(
},
output_rate_hz: config.output.odometry_rate_hz,
// Skip gyro calibration for wheel-only mode
calibration_samples: if use_wheel_only { 0 } else { 1500 },
calibration_samples: if use_wheel_only { 0 } else { 330 },
};
let mut pipeline = OdometryPipeline::new(pipeline_config);
+19 -16
View File
@@ -53,7 +53,7 @@ use dhruva_slam::metrics::{
};
use dhruva_slam::sensors::odometry::{
ComplementaryConfig, ComplementaryFilter, Eskf, EskfConfig, MahonyAhrs, MahonyConfig,
WheelOdometry, WheelOdometryConfig,
RawImuData, WheelOdometry, WheelOdometryConfig,
};
use dhruva_slam::sensors::preprocessing::{PreprocessorConfig, ScanPreprocessor};
use dhruva_slam::utils::{GYRO_SCALE, WHEEL_BASE, WHEEL_TICKS_PER_METER};
@@ -751,9 +751,9 @@ fn run_single_full_slam(
let integrator = MapIntegrator::new(MapIntegratorConfig::default());
// Separate tracking for raw odometry and scan-matched SLAM pose
let mut odom_pose = Pose2D::identity(); // Raw odometry (always accumulating)
let mut prev_odom_at_scan = Pose2D::identity(); // Odometry at previous scan
let mut slam_pose = Pose2D::identity(); // Scan-matched pose
let mut odom_pose = Pose2D::identity(); // Raw odometry (always accumulating)
let mut prev_odom_at_scan = Pose2D::identity(); // Odometry at previous scan
let mut slam_pose = Pose2D::identity(); // Scan-matched pose
let mut previous_scan: Option<PointCloud2D> = None;
let mut scans_processed = 0u64;
let mut failed_matches = 0u64;
@@ -766,9 +766,9 @@ fn run_single_full_slam(
let pid = sysinfo::get_current_pid().ok();
// Thresholds for accepting scan match results
const MIN_MATCH_SCORE: f32 = 0.3; // Minimum acceptable match score
const MAX_CORRECTION_DIST: f32 = 0.15; // Max 15cm correction from odometry
const MAX_CORRECTION_ANGLE: f32 = 0.35; // Max ~20 degrees correction
const MIN_MATCH_SCORE: f32 = 0.3; // Minimum acceptable match score
const MAX_CORRECTION_DIST: f32 = 0.15; // Max 15cm correction from odometry
const MAX_CORRECTION_ANGLE: f32 = 0.35; // Max ~20 degrees correction
let start_time = Instant::now();
@@ -782,7 +782,7 @@ fn run_single_full_slam(
status.gyro_raw[2],
status.timestamp_us,
) {
odom_pose = pose; // Always raw odometry
odom_pose = pose; // Always raw odometry
}
}
BagMessage::Lidar(timestamped) => {
@@ -810,7 +810,8 @@ fn run_single_full_slam(
// FIX: Pass odom_delta directly (NOT inverted) as initial guess,
// and use match result directly (NOT inverted) as matched_delta.
let initial_guess = odom_delta;
let match_result = matcher.match_scans(&processed, prev_scan, &initial_guess);
let match_result =
matcher.match_scans(&processed, prev_scan, &initial_guess);
// The match result transform is the robot motion from prev→current
let matched_delta = match_result.transform;
@@ -871,7 +872,9 @@ fn run_single_full_slam(
0.0
};
let successful_matches = scans_processed.saturating_sub(failed_matches).saturating_sub(1); // -1 for first scan
let successful_matches = scans_processed
.saturating_sub(failed_matches)
.saturating_sub(1); // -1 for first scan
let avg_match_score = if successful_matches > 0 {
total_score / successful_matches as f64
} else {
@@ -1099,9 +1102,9 @@ impl MahonyOdometry {
ticks_per_meter: WHEEL_TICKS_PER_METER,
wheel_base: WHEEL_BASE,
};
// Use shorter calibration for benchmark (500 samples = 1 second at 500Hz)
// Use shorter calibration for benchmark (110 samples = 1 second at 110Hz)
let mahony_config = MahonyConfig {
calibration_samples: 500,
calibration_samples: 110,
..MahonyConfig::default()
};
Self {
@@ -1122,11 +1125,11 @@ impl MahonyOdometry {
// Update Mahony AHRS (uses gyro_yaw as Z-axis rotation)
// For 2D odometry, we only need yaw from the AHRS
// Pass zeros for other axes and gravity (simplified 2D case)
let (_roll, _pitch, yaw) = self.ahrs.update(
gyro_yaw, 0, 0, // gyro: yaw on X input (Mahony remaps internally)
0, 0, 1000, // tilt: assume level (gravity pointing down)
timestamp_us,
let imu = RawImuData::new(
[gyro_yaw, 0, 0], // gyro: yaw on X input (Mahony remaps internally)
[0, 0, 1000], // tilt: assume level (gravity pointing down)
);
let (_roll, _pitch, yaw) = self.ahrs.update(&imu, timestamp_us);
// Get wheel odometry delta
if let Some(encoder_delta) = self.wheel.update(left, right) {
+1 -1
View File
@@ -10,7 +10,7 @@ message Message {
}
}
// Sensor data from robot (outbound @ 500Hz for telemetry, 5Hz for lidar)
// Sensor data from robot (outbound @ 110Hz for telemetry, 5Hz for lidar)
message SensorGroup {
string group_id = 1;
uint64 timestamp_us = 2;
@@ -589,12 +589,14 @@ impl PointToLineIcp {
let final_transform = if score > 0.95 {
let init_trans = (initial_guess.x.powi(2) + initial_guess.y.powi(2)).sqrt();
let init_rot = initial_guess.theta.abs();
let curr_trans = (current_transform.x.powi(2) + current_transform.y.powi(2)).sqrt();
let curr_trans =
(current_transform.x.powi(2) + current_transform.y.powi(2)).sqrt();
let curr_rot = current_transform.theta.abs();
// If both initial guess and result are near-identity, snap to initial
// This prevents P2L aperture problem from causing rotation drift
if init_trans < 0.03 && init_rot < 0.05 && curr_trans < 0.03 && curr_rot < 0.05 {
if init_trans < 0.03 && init_rot < 0.05 && curr_trans < 0.03 && curr_rot < 0.05
{
*initial_guess
} else {
current_transform
@@ -618,9 +620,11 @@ impl PointToLineIcp {
let final_transform = if score > 0.95 {
let init_trans = (initial_guess.x.powi(2) + initial_guess.y.powi(2)).sqrt();
let init_rot = initial_guess.theta.abs();
let curr_trans = (current_transform.x.powi(2) + current_transform.y.powi(2)).sqrt();
let curr_trans =
(current_transform.x.powi(2) + current_transform.y.powi(2)).sqrt();
let curr_rot = current_transform.theta.abs();
if init_trans < 0.03 && init_rot < 0.05 && curr_trans < 0.03 && curr_rot < 0.05 {
if init_trans < 0.03 && init_rot < 0.05 && curr_trans < 0.03 && curr_rot < 0.05
{
*initial_guess
} else {
current_transform
+2 -2
View File
@@ -280,7 +280,7 @@ let config = OdometryPipelineConfig {
let mut pipeline = OdometryPipeline::new(config);
// Process each sensor update (500Hz)
// Process each sensor update (110Hz)
if let Some(pose) = pipeline.process(left_ticks, right_ticks, gyro_z, timestamp_us) {
// Decimated output at configured rate
publish_pose(&pose, timestamp_us);
@@ -297,7 +297,7 @@ let current = pipeline.pose();
**Features:**
- Auto-calibrates gyro bias on startup (1500 samples)
- Decimates 500Hz input to configurable output rate
- Decimates 110Hz input to configurable output rate
- Provides timing and quality diagnostics
## File Structure
+2 -2
View File
@@ -242,7 +242,7 @@ mod tests {
for i in 0..100 {
recorder
.record_sensor_status(&SensorStatusMsg {
timestamp_us: i * 2000, // 500 Hz
timestamp_us: i * 9091, // 110 Hz
encoder: EncoderTicks::new(i as u16, i as u16),
gyro_raw: [0, 0, 0],
accel_raw: [0, 0, 1638],
@@ -256,7 +256,7 @@ mod tests {
assert_eq!(info.message_count, 100);
assert_eq!(info.sensor_count, 100);
assert_eq!(info.lidar_count, 0);
assert_eq!(info.duration_us, 99 * 2000);
assert_eq!(info.duration_us, 99 * 9091);
}
#[test]
+8 -8
View File
@@ -22,7 +22,7 @@ impl BagTestFixture {
/// Create a synthetic bag file with stationary robot (5 seconds).
///
/// Contains:
/// - 2500 sensor status messages at 500 Hz
/// - 550 sensor status messages at 110 Hz
/// - 25 lidar scans at 5 Hz
/// - Robot stationary at origin
pub fn synthetic_5_seconds() -> Self {
@@ -33,14 +33,14 @@ impl BagTestFixture {
let start_time_us = 1_000_000_000u64; // 1 second since epoch
let duration_us = 5_000_000u64; // 5 seconds
let sensor_period_us = 2000u64; // 500 Hz
let sensor_period_us = 9091u64; // 110 Hz
let lidar_period_us = 200_000u64; // 5 Hz
let mut time = start_time_us;
let encoder_value = 1000u16;
while time < start_time_us + duration_us {
// Sensor status at 500Hz
// Sensor status at 110Hz
recorder
.record_sensor_status(&SensorStatusMsg {
timestamp_us: time,
@@ -91,14 +91,14 @@ impl BagTestFixture {
let duration_s = distance_m / speed_mps;
let duration_us = (duration_s * 1_000_000.0) as u64;
let sensor_period_us = 2000u64; // 500 Hz
let sensor_period_us = 9091u64; // 110 Hz
let start_time_us = 1_000_000_000u64;
let mut time = start_time_us;
let mut position = 0.0f32;
while time < start_time_us + duration_us {
let dt = 0.002; // 2ms = 500Hz
let dt = 0.00909; // 9.09ms = 110Hz
position += speed_mps * dt;
let ticks = (position * ticks_per_meter) as u16;
@@ -138,7 +138,7 @@ impl BagTestFixture {
let duration_s = angle_rad.abs() / angular_velocity_radps.abs();
let duration_us = (duration_s * 1_000_000.0) as u64;
let sensor_period_us = 2000u64; // 500 Hz
let sensor_period_us = 9091u64; // 110 Hz
let start_time_us = 1_000_000_000u64;
let mut time = start_time_us;
@@ -191,7 +191,7 @@ impl BagTestFixture {
let straight_time_s = side_length_m / linear_speed_mps;
let turn_time_s = (std::f32::consts::FRAC_PI_2) / angular_speed_radps;
let sensor_period_us = 2000u64;
let sensor_period_us = 9091u64; // 110 Hz
let start_time_us = 1_000_000_000u64;
let mut time = start_time_us;
@@ -204,7 +204,7 @@ impl BagTestFixture {
let segment_end_time = time + segment_duration_us;
while time < segment_end_time {
let dt = 0.002;
let dt = 0.00909; // 9.09ms = 110Hz
total_distance += linear_speed_mps * dt;
let ticks = (total_distance * ticks_per_meter) as u16;
+2 -2
View File
@@ -94,7 +94,7 @@ impl EncoderTicks {
/// Sensor status message from SangamIO.
///
/// Contains wheel encoder and IMU data at 500Hz.
/// Contains wheel encoder and IMU data at 110Hz.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorStatusMsg {
/// Timestamp in microseconds since epoch
@@ -133,7 +133,7 @@ impl SensorStatusMsg {
/// Each variant corresponds to a different sensor stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BagMessage {
/// Sensor status (500 Hz): encoders + IMU
/// Sensor status (110 Hz): encoders + IMU
SensorStatus(SensorStatusMsg),
/// LiDAR scan (5 Hz): point cloud
Lidar(Timestamped<LidarScan>),
@@ -11,7 +11,7 @@
//! let config = OdometryPipelineConfig::default();
//! let mut pipeline = OdometryPipeline::new(config);
//!
//! // In sensor loop (500Hz from SangamIO)
//! // In sensor loop (110Hz from SangamIO)
//! if let Some(pose) = pipeline.process(left_ticks, right_ticks, gyro_z, timestamp_us) {
//! // New pose available at output rate (e.g., 50Hz)
//! publisher.publish_pose(&pose, timestamp_us);
@@ -35,13 +35,13 @@ pub struct OdometryPipelineConfig {
/// Output rate in Hz (decimation from input rate)
///
/// Input is typically 500Hz from SangamIO, output should be
/// Input is typically 110Hz from SangamIO, output should be
/// lower (e.g., 50Hz) for visualization.
pub output_rate_hz: f32,
/// Number of samples to collect for gyro bias calibration at startup.
///
/// At 500Hz, 1500 samples = 3 seconds of calibration.
/// At 110Hz, 330 samples = 3 seconds of calibration.
/// Set to 0 to disable auto-calibration.
pub calibration_samples: u32,
}
@@ -52,7 +52,7 @@ impl Default for OdometryPipelineConfig {
wheel_odom: WheelOdometryConfig::default(),
filter: ComplementaryConfig::default(),
output_rate_hz: 50.0,
calibration_samples: 1500, // ~3 seconds at 500Hz
calibration_samples: 330, // ~3 seconds at 110Hz
}
}
}
+4 -3
View File
@@ -131,9 +131,10 @@ pub use core::types::{Point2D, Pose2D, Timestamped};
// Sensors - Odometry
pub use sensors::odometry::{
ComplementaryConfig, ComplementaryFilter, Eskf, EskfConfig, EulerAngles, EvaluationResult,
MahonyAhrs, MahonyConfig, MeasurementNoise, OdometryEvaluator, ProcessNoise, Quaternion,
ScenarioBounds, Stats, TestScenario, WheelOdometry, WheelOdometryConfig,
CalibratedImuData, ComplementaryConfig, ComplementaryFilter, Eskf, EskfConfig, EulerAngles,
EvaluationResult, MahonyAhrs, MahonyConfig, MeasurementNoise, OdometryEvaluator, ProcessNoise,
Quaternion, RawImuData, ScenarioBounds, Stats, TestScenario, WheelOdometry,
WheelOdometryConfig,
};
// Sensors - Preprocessing
+1 -1
View File
@@ -47,7 +47,7 @@ impl GyroBiasEstimator {
///
/// * `target_samples` - Number of samples to collect before computing bias.
/// More samples = more accurate but longer calibration time.
/// Typical values: 50-200 samples at 500Hz = 100-400ms
/// Typical values: 10-45 samples at 110Hz = 100-400ms
pub fn new(target_samples: usize) -> Self {
Self {
target_samples: target_samples.max(2), // Minimum 2 samples for variance calculation
+1 -1
View File
@@ -99,7 +99,7 @@ impl Default for EskfConfig {
gyro_bias_z: 0.0,
gyro_sign: 1.0, // SangamIO provides REP-103 compliant data (CCW positive)
initial_position_variance: 0.01, // 10cm std dev
initial_heading_variance: 0.01, // ~6° std dev
initial_heading_variance: 0.01, // ~6° std dev
}
}
}
+102 -57
View File
@@ -24,18 +24,15 @@
//! # Example
//!
//! ```ignore
//! use dhruva_slam::sensors::odometry::{MahonyAhrs, MahonyConfig};
//! use dhruva_slam::sensors::odometry::{MahonyAhrs, MahonyConfig, RawImuData};
//!
//! let config = MahonyConfig::default();
//! let mut ahrs = MahonyAhrs::new(config);
//!
//! // Process IMU data at sensor rate (~500Hz)
//! // Process IMU data at sensor rate (~110Hz)
//! loop {
//! let (roll, pitch, yaw) = ahrs.update(
//! gyro_x, gyro_y, gyro_z,
//! tilt_x, tilt_y, tilt_z,
//! timestamp_us,
//! );
//! let imu = RawImuData::new(gyro_raw, tilt_raw);
//! let (roll, pitch, yaw) = ahrs.update(&imu, timestamp_us);
//!
//! if ahrs.is_calibrated() {
//! // Use orientation for odometry fusion
@@ -75,7 +72,7 @@ pub struct MahonyConfig {
pub gravity_scale: f32,
/// Number of samples for initial bias calibration.
/// At 500Hz, 1500 samples = 3 seconds.
/// At 110Hz, 330 samples = 3 seconds.
pub calibration_samples: u32,
/// Sample rate in Hz (used for fixed dt if timestamps unavailable).
@@ -85,12 +82,12 @@ pub struct MahonyConfig {
impl Default for MahonyConfig {
fn default() -> Self {
Self {
kp: 5.0, // Proportional gain (responsive but stable)
ki: 0.1, // Integral gain (slow bias adaptation)
kp: 5.0, // Proportional gain (responsive but stable)
ki: 0.1, // Integral gain (slow bias adaptation)
gyro_scale: CRL200S_GYRO_SCALE,
gravity_scale: GRAVITY_SCALE,
calibration_samples: 1500, // ~3 seconds at 500Hz
sample_rate_hz: 500.0,
calibration_samples: 330, // ~3 seconds at 110Hz
sample_rate_hz: 110.0,
}
}
}
@@ -176,6 +173,68 @@ impl EulerAngles {
}
}
/// Raw IMU data from sensor (before calibration/scaling).
#[derive(Debug, Clone, Copy, Default)]
pub struct RawImuData {
/// Raw gyroscope X (roll rate) in sensor units
pub gyro_x: i16,
/// Raw gyroscope Y (pitch rate) in sensor units
pub gyro_y: i16,
/// Raw gyroscope Z (yaw rate) in sensor units
pub gyro_z: i16,
/// LP-filtered gravity/tilt X
pub tilt_x: i16,
/// LP-filtered gravity/tilt Y
pub tilt_y: i16,
/// LP-filtered gravity/tilt Z
pub tilt_z: i16,
}
impl RawImuData {
/// Create new raw IMU data.
pub fn new(gyro: [i16; 3], tilt: [i16; 3]) -> Self {
Self {
gyro_x: gyro[0],
gyro_y: gyro[1],
gyro_z: gyro[2],
tilt_x: tilt[0],
tilt_y: tilt[1],
tilt_z: tilt[2],
}
}
}
/// Calibrated IMU data in physical units.
#[derive(Debug, Clone, Copy, Default)]
pub struct CalibratedImuData {
/// Gyroscope X (roll rate) in rad/s
pub gx: f32,
/// Gyroscope Y (pitch rate) in rad/s
pub gy: f32,
/// Gyroscope Z (yaw rate) in rad/s
pub gz: f32,
/// Accelerometer/gravity X (normalized or m/s²)
pub ax: f32,
/// Accelerometer/gravity Y (normalized or m/s²)
pub ay: f32,
/// Accelerometer/gravity Z (normalized or m/s²)
pub az: f32,
}
impl CalibratedImuData {
/// Create new calibrated IMU data.
pub fn new(gyro: [f32; 3], accel: [f32; 3]) -> Self {
Self {
gx: gyro[0],
gy: gyro[1],
gz: gyro[2],
ax: accel[0],
ay: accel[1],
az: accel[2],
}
}
}
/// Gyro bias calibration state.
#[derive(Debug)]
struct BiasCalibration {
@@ -326,34 +385,20 @@ impl MahonyAhrs {
self.bias_cal.reset();
}
/// Update the filter with new IMU data.
/// Update the filter with new raw IMU data.
///
/// # Arguments
///
/// * `gyro_x` - Raw gyro X (roll rate, ROS REP-103 frame)
/// * `gyro_y` - Raw gyro Y (pitch rate, ROS REP-103 frame)
/// * `gyro_z` - Raw gyro Z (yaw rate, ROS REP-103 frame)
/// * `tilt_x` - LP-filtered gravity X
/// * `tilt_y` - LP-filtered gravity Y
/// * `tilt_z` - LP-filtered gravity Z
/// * `imu` - Raw IMU data (gyro and tilt in sensor units, ROS REP-103 frame)
/// * `timestamp_us` - Timestamp in microseconds
///
/// # Returns
///
/// Euler angles (roll, pitch, yaw) in radians, or (0, 0, 0) during calibration.
pub fn update(
&mut self,
gyro_x: i16,
gyro_y: i16,
gyro_z: i16,
tilt_x: i16,
tilt_y: i16,
tilt_z: i16,
timestamp_us: u64,
) -> (f32, f32, f32) {
pub fn update(&mut self, imu: &RawImuData, timestamp_us: u64) -> (f32, f32, f32) {
// Calibration phase - collect bias samples
if !self.bias_cal.is_calibrated() {
self.bias_cal.add_sample(gyro_x, gyro_y, gyro_z);
self.bias_cal.add_sample(imu.gyro_x, imu.gyro_y, imu.gyro_z);
self.last_timestamp_us = Some(timestamp_us);
return (0.0, 0.0, 0.0);
}
@@ -382,9 +427,9 @@ impl MahonyAhrs {
// gyro_x = Roll rate (X axis rotation)
// gyro_y = Pitch rate (Y axis rotation)
// gyro_z = Yaw rate (Z axis rotation)
let gx = (gyro_x as f32 - bias[0]) * self.config.gyro_scale; // Roll
let gy = (gyro_y as f32 - bias[1]) * self.config.gyro_scale; // Pitch
let gz = (gyro_z as f32 - bias[2]) * self.config.gyro_scale; // Yaw
let gx = (imu.gyro_x as f32 - bias[0]) * self.config.gyro_scale; // Roll
let gy = (imu.gyro_y as f32 - bias[1]) * self.config.gyro_scale; // Pitch
let gz = (imu.gyro_z as f32 - bias[2]) * self.config.gyro_scale; // Yaw
// Get current quaternion
let q0 = self.q.w;
@@ -393,9 +438,9 @@ impl MahonyAhrs {
let q3 = self.q.z;
// Normalize gravity vector
let mut ax = tilt_x as f32 * self.config.gravity_scale;
let mut ay = tilt_y as f32 * self.config.gravity_scale;
let mut az = tilt_z as f32 * self.config.gravity_scale;
let mut ax = imu.tilt_x as f32 * self.config.gravity_scale;
let mut ay = imu.tilt_y as f32 * self.config.gravity_scale;
let mut az = imu.tilt_z as f32 * self.config.gravity_scale;
let accel_norm = (ax * ax + ay * ay + az * az).sqrt();
@@ -449,19 +494,15 @@ impl MahonyAhrs {
self.q.to_euler()
}
/// Update with pre-calibrated gyro values in rad/s.
/// Update with pre-calibrated IMU data in physical units.
///
/// Use this if you've already applied bias correction and scaling.
pub fn update_calibrated(
&mut self,
gx_rad_s: f32,
gy_rad_s: f32,
gz_rad_s: f32,
ax: f32,
ay: f32,
az: f32,
dt: f32,
) -> (f32, f32, f32) {
///
/// # Arguments
///
/// * `imu` - Calibrated IMU data (gyro in rad/s, accel normalized or in m/s²)
/// * `dt` - Time delta in seconds
pub fn update_calibrated(&mut self, imu: &CalibratedImuData, dt: f32) -> (f32, f32, f32) {
if dt <= 0.0 || dt > 0.1 {
return self.q.to_euler();
}
@@ -471,14 +512,14 @@ impl MahonyAhrs {
let q2 = self.q.y;
let q3 = self.q.z;
let (mut gx, mut gy, mut gz) = (gx_rad_s, gy_rad_s, gz_rad_s);
let (mut gx, mut gy, mut gz) = (imu.gx, imu.gy, imu.gz);
// Normalize accelerometer
let accel_norm = (ax * ax + ay * ay + az * az).sqrt();
let accel_norm = (imu.ax * imu.ax + imu.ay * imu.ay + imu.az * imu.az).sqrt();
if accel_norm > 0.01 {
let ax_n = ax / accel_norm;
let ay_n = ay / accel_norm;
let az_n = az / accel_norm;
let ax_n = imu.ax / accel_norm;
let ay_n = imu.ay / accel_norm;
let az_n = imu.az / accel_norm;
// Estimated gravity from quaternion
let vx = 2.0 * (q1 * q3 - q0 * q2);
@@ -569,8 +610,9 @@ mod tests {
let mut ahrs = MahonyAhrs::new(config);
// During calibration, should return zeros
let imu = RawImuData::new([100, 50, -30], [0, 0, 1000]);
for i in 0..10 {
let (r, p, y) = ahrs.update(100, 50, -30, 0, 0, 1000, i * 2000);
let (r, p, y) = ahrs.update(&imu, i * 2000);
if i < 9 {
assert!(!ahrs.is_calibrated());
}
@@ -594,13 +636,14 @@ mod tests {
let mut ahrs = MahonyAhrs::new(config);
// Calibration phase
let imu = RawImuData::new([0, 0, 0], [0, 0, 1000]);
for i in 0..5 {
ahrs.update(0, 0, 0, 0, 0, 1000, i * 2000);
ahrs.update(&imu, i * 2000);
}
// Stationary updates (gyro at bias = 0)
for i in 5..20 {
let (roll, pitch, yaw) = ahrs.update(0, 0, 0, 0, 0, 1000, i * 2000);
let (roll, pitch, yaw) = ahrs.update(&imu, i * 2000);
// Should stay near zero when stationary
assert!(roll.abs() < 0.1);
assert!(pitch.abs() < 0.1);
@@ -644,8 +687,9 @@ mod tests {
let mut ahrs = MahonyAhrs::new(config);
// Complete calibration
let imu1 = RawImuData::new([100, 0, 0], [0, 0, 1000]);
for i in 0..5 {
ahrs.update(100, 0, 0, 0, 0, 1000, i * 2000);
ahrs.update(&imu1, i * 2000);
}
assert!(ahrs.is_calibrated());
assert_relative_eq!(ahrs.gyro_bias()[0], 100.0, epsilon = 0.1);
@@ -655,8 +699,9 @@ mod tests {
assert!(!ahrs.is_calibrated());
// New calibration with different bias
let imu2 = RawImuData::new([50, 0, 0], [0, 0, 1000]);
for i in 0..5 {
ahrs.update(50, 0, 0, 0, 0, 1000, (i + 10) * 2000);
ahrs.update(&imu2, (i + 10) * 2000);
}
assert!(ahrs.is_calibrated());
assert_relative_eq!(ahrs.gyro_bias()[0], 50.0, epsilon = 0.1);
+5 -2
View File
@@ -31,7 +31,8 @@
//!
//! // Use Mahony AHRS for orientation with automatic bias calibration
//! let mut ahrs = MahonyAhrs::new(MahonyConfig::default());
//! let (roll, pitch, yaw) = ahrs.update(gyro_x, gyro_y, gyro_z, tilt_x, tilt_y, tilt_z, timestamp_us);
//! let imu = RawImuData::new(gyro_raw, tilt_raw);
//! let (roll, pitch, yaw) = ahrs.update(&imu, timestamp_us);
//! ```
mod complementary;
@@ -43,5 +44,7 @@ mod wheel_odometry;
pub use complementary::{CRL200S_GYRO_SCALE, ComplementaryConfig, ComplementaryFilter};
pub use eskf::{Eskf, EskfConfig, MeasurementNoise, ProcessNoise};
pub use evaluator::{EvaluationResult, OdometryEvaluator, ScenarioBounds, Stats, TestScenario};
pub use mahony::{EulerAngles, MahonyAhrs, MahonyConfig, Quaternion};
pub use mahony::{
CalibratedImuData, EulerAngles, MahonyAhrs, MahonyConfig, Quaternion, RawImuData,
};
pub use wheel_odometry::{WheelOdometry, WheelOdometryConfig};
+3 -3
View File
@@ -310,7 +310,7 @@ fn test_filter_straight_line_no_gyro() {
let mut timestamp = 0u64;
for (left, right) in ticks {
if let Some(delta) = odom.update(left, right) {
timestamp += 2000; // 2ms per update
timestamp += 9091; // 9.09ms per update (110Hz)
filter.update(delta, 0, timestamp);
}
}
@@ -472,7 +472,7 @@ fn run_straight_line_evaluation(
for (left, right) in ticks {
if let Some(delta) = odom.update(left, right) {
pose = pose.compose(&delta);
timestamp += 2000; // 2ms per update
timestamp += 9091; // 9.09ms per update (110Hz)
evaluator.add_estimate(pose, timestamp);
}
}
@@ -483,7 +483,7 @@ fn run_straight_line_evaluation(
for (left, right) in return_ticks {
if let Some(delta) = odom.update(left, right) {
pose = pose.compose(&delta);
timestamp += 2000;
timestamp += 9091;
evaluator.add_estimate(pose, timestamp);
}
}
+6 -6
View File
@@ -91,7 +91,7 @@ fn test_sangam_connection() {
println!("Encoder messages: {}", encoder_count);
println!("Gyro messages: {}", gyro_count);
// We should receive some encoder data at least (500Hz from sensor_status)
// We should receive some encoder data at least (110Hz from sensor_status)
assert!(encoder_count > 0, "Should receive encoder data");
}
@@ -153,8 +153,8 @@ fn test_wheel_odometry_hardware() {
println!("Collecting wheel odometry data for ~1 second...");
println!("Keep the robot stationary for baseline test.");
// Collect ~500 samples (at 500Hz, this is ~1 second)
for _ in 0..500 {
// Collect ~110 samples (at 110Hz, this is ~1 second)
for _ in 0..110 {
match client.recv() {
Ok(msg) => {
if let Some((left, right)) = msg.encoder_ticks()
@@ -224,7 +224,7 @@ fn test_complementary_filter_hardware() {
alpha: 0.98,
gyro_scale: 0.001, // Estimated, needs calibration based on IMU datasheet
gyro_bias_z: 0.0, // Should be calibrated at startup when stationary
..ComplementaryConfig::default() // Uses gyro_sign=-1.0 for CRL-200S
..ComplementaryConfig::default() // Uses gyro_sign=-1.0 for CRL-200S
};
let mut filter = ComplementaryFilter::new(filter_config);
@@ -234,8 +234,8 @@ fn test_complementary_filter_hardware() {
println!("Running complementary filter for ~2 seconds...");
println!("Move the robot to see pose changes, or keep stationary for baseline.");
// Collect ~1000 samples (at 500Hz, this is ~2 seconds)
for _ in 0..1000 {
// Collect ~220 samples (at 110Hz, this is ~2 seconds)
for _ in 0..220 {
match client.recv() {
Ok(msg) => {
// Get encoder data
+1 -1
View File
@@ -31,7 +31,7 @@ python drishti.py --robot 192.168.68.101
## Features
- Full-screen robot diagram with sensor overlays
- Real-time telemetry monitoring (500Hz)
- Real-time telemetry monitoring (110Hz)
- JSON protocol over TCP
- Visual indicators for all 13 sensors
- Drive control with arrow keys
+2 -2
View File
@@ -35,7 +35,7 @@ class IMUProcessor:
# Gravity scale (LP filtered gravity vector)
GRAVITY_SCALE = 1.0 / 1000.0
def __init__(self, sample_rate_hz: float = 500.0):
def __init__(self, sample_rate_hz: float = 110.0):
self.dt = 1.0 / sample_rate_hz
# Quaternion [w, x, y, z]
@@ -48,7 +48,7 @@ class IMUProcessor:
self.gyro_bias = [0.0, 0.0, 0.0]
self._calibration_samples = []
self._calibration_count = 0
self._calibration_duration = 1500 # ~3 seconds at 500Hz
self._calibration_duration = 330 # ~3 seconds at 110Hz
# UI decimation
self._counter = 0
+1 -1
View File
@@ -1062,7 +1062,7 @@ class Robot3DView(QWidget):
"""
Update orientation from IMU data.
Called at 500Hz from telemetry thread. Updates every call for
Called at 110Hz from telemetry thread. Updates every call for
smooth real-time response (labels decimated separately).
Args:
+1 -1
View File
@@ -94,7 +94,7 @@ fn checksum(data: &[u8]) -> u16 {
| 0x07 | Version Response | Variable | 2x | HIGH | Response to version request |
| 0x08 | Init Ack | None | 1x | HIGH | Echo acknowledgment |
| 0x0C | Protocol Sync Ack | 1 byte | 1x | HIGH | Echo of 0x0C command (~270ms delay) |
| 0x15 | Status Packet | 96 bytes | 12993x | HIGH | Continuous sensor data @ ~500Hz |
| 0x15 | Status Packet | 96 bytes | 12993x | HIGH | Continuous sensor data @ ~110Hz |
| 0x17 | Lidar Config Ack | 1 byte | 1x | LOW | Response `[0x01]` |
| 0x18 | Lidar Query Response | 8 bytes | 1x | LOW | Lidar state data |
| 0xA2 | IMU Cal Response | 1 byte | 3x | HIGH | Response `[0x01]` |
+3 -3
View File
@@ -4,7 +4,7 @@ Hardware abstraction daemon for the CRL-200S robotic vacuum platform, providing
## Overview
SangamIO acts as a bridge between high-level clients (SLAM, visualization) and low-level hardware (GD32 motor controller, Delta-2D lidar). It runs as a standalone daemon on the robot, streaming sensor data at 500Hz and accepting commands over TCP.
SangamIO acts as a bridge between high-level clients (SLAM, visualization) and low-level hardware (GD32 motor controller, Delta-2D lidar). It runs as a standalone daemon on the robot, streaming sensor data at 110Hz (limited by 115200 baud serial) and accepting commands over TCP.
```
┌─────────────────────────────────────────────────┐
@@ -13,7 +13,7 @@ SangamIO acts as a bridge between high-level clients (SLAM, visualization) and l
│ TCP 5555 │
┌─────────────▼───────────────▼───────────────────┐
│ SangamIO Daemon (Robot) │
│ • Telemetry streaming @ 500Hz (sensors) │
│ • Telemetry streaming @ 110Hz (sensors) │
│ • Lidar streaming @ 5Hz (360° scans) │
│ • Command processing (motion/actuators) │
│ • Real-time control loop (20ms heartbeat) │
@@ -167,7 +167,7 @@ See `proto/sangamio.proto` for the complete schema.
| Direction | Topic | Rate | Description |
|-----------|-------|------|-------------|
| Out | `sensors/sensor_status` | 500Hz | All sensor data |
| Out | `sensors/sensor_status` | 110Hz | All sensor data |
| Out | `sensors/device_version` | Once | Version info |
| Out | `sensors/lidar` | 5Hz | 360° point cloud |
| In | `command` | On-demand | Robot commands |
+3 -3
View File
@@ -1,13 +1,13 @@
# GD32 Sensor Status Packet (0x15)
This document details the 96-byte sensor status packet sent by the GD32 motor controller at approximately 500Hz.
This document details the 96-byte sensor status packet sent by the GD32 motor controller at approximately 110Hz (limited by 115200 baud serial).
## Packet Structure
```
Packet Format: [0xFA 0xFB] [LEN] [0x15] [PAYLOAD (96 bytes)] [CRC_H] [CRC_L]
Total Size: 102 bytes (6 header/CRC + 96 payload)
Frequency: ~500 Hz (every 2ms)
Frequency: ~110 Hz (every ~9ms, limited by 115200 baud)
```
## Payload Byte Map
@@ -272,7 +272,7 @@ All multi-byte fields use **little-endian** encoding:
### Sensor Update Frequency
- **Status Packets:** ~500 Hz (every 2ms)
- **Status Packets:** ~110 Hz (every ~9ms)
- **All sensors updated:** Per packet (real-time)
- **Encoder Resolution:** Incremental ticks (wrapping u16 counters)
+1 -1
View File
@@ -15,7 +15,7 @@ message Message {
}
// Sensor data container published by the daemon.
// Streaming rates: 500Hz for telemetry, 5Hz for lidar scans.
// Streaming rates: 110Hz for telemetry, 5Hz for lidar scans.
message SensorGroup {
// Group identifier matching topic suffix (e.g., "sensor_status", "lidar")
string group_id = 1;
+2 -2
View File
@@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex};
pub struct DriverInitResult {
/// Shared sensor data for polling-based access (for low-rate sensors like lidar)
pub sensor_data: HashMap<String, Arc<Mutex<SensorGroupData>>>,
/// Optional streaming channels for high-rate sensors (e.g., 500Hz GD32 status)
/// Optional streaming channels for high-rate sensors (e.g., 110Hz GD32 status)
/// Key is group_id (e.g., "sensor_status")
pub stream_receivers: HashMap<String, StreamReceiver>,
}
@@ -63,7 +63,7 @@ pub trait DeviceDriver: Send {
///
/// Returns [`DriverInitResult`] containing:
/// - `sensor_data`: Sensor groups keyed by group ID (e.g., "sensor_status", "lidar")
/// - `stream_receivers`: Optional streaming channels for high-rate sensors (e.g., 500Hz)
/// - `stream_receivers`: Optional streaming channels for high-rate sensors (e.g., 110Hz)
///
/// The TCP publisher uses sensor_data for low-rate polling and stream_receivers
/// for high-rate streaming without data loss.
+3 -3
View File
@@ -100,7 +100,7 @@ impl SensorGroupData {
/// Update timestamp to current time and increment sequence number.
///
/// The sequence number ensures each update is detected by the TCP publisher
/// even when timestamps appear equal (e.g., at high update rates like 500Hz).
/// even when timestamps appear equal (e.g., at high update rates like 110Hz).
#[inline]
pub fn touch(&mut self) {
self.timestamp_us = std::time::SystemTime::now()
@@ -189,8 +189,8 @@ pub enum Command {
}
/// Bounded channel capacity for streaming sensor data.
/// At 500Hz with ~150 bytes per message, 1000 messages ≈ 150KB buffer.
/// This allows ~2 seconds of buffering before dropping messages.
/// At 110Hz with ~150 bytes per message, 1000 messages ≈ 150KB buffer.
/// This allows ~9 seconds of buffering before dropping messages.
pub const STREAM_CHANNEL_CAPACITY: usize = 1000;
/// Streaming channel for sensor data.
+2 -2
View File
@@ -17,7 +17,7 @@
//! - Uses blocking mutex locks to guarantee timing
//!
//! 2. **Reader Thread** (continuous):
//! - Parses incoming status packets (CMD=0x15 @ ~500Hz)
//! - Parses incoming status packets (CMD=0x15 @ ~110Hz)
//! - Updates shared sensor data in-place (no allocations)
//! - Requests version info after first packet
//! - Handles protocol synchronization and checksums
@@ -153,7 +153,7 @@ impl GD32Driver {
/// # Arguments
/// - `sensor_data`: Shared mutex for latest sensor values (for polling-based access)
/// - `version_data`: Shared mutex for version info (one-time update)
/// - `stream_tx`: Optional channel sender for streaming sensor data at full 500Hz rate
/// - `stream_tx`: Optional channel sender for streaming sensor data at full 110Hz rate
/// - `gyro_transform`: Axis transform for gyroscope data (identity = no change)
/// - `accel_transform`: Axis transform for accelerometer data (identity = no change)
pub fn start(
@@ -22,7 +22,7 @@ pub const MAX_PAYLOAD_SIZE: usize = 128;
/// Zero-allocation parsed packet from GD32
///
/// Uses a fixed-size array instead of `Vec<u8>` to eliminate heap allocations.
/// At 500Hz packet rate, this saves ~48KB/sec of allocations.
/// At 110Hz packet rate, this saves ~11KB/sec of allocations.
#[derive(Debug, Clone, Copy)]
pub struct RxPacket {
pub cmd: u8,
@@ -72,7 +72,7 @@ impl Default for RxPacket {
/// Simple Vec-based packet reader for reliable parsing
///
/// Uses a plain Vec<u8> with drain() for simplicity and correctness.
/// At 500Hz with ~100 byte packets, the O(n) drain is negligible.
/// At 110Hz with ~100 byte packets, the O(n) drain is negligible.
pub struct PacketReader {
buffer: Vec<u8>,
/// Reusable packet buffer - avoids allocation on every read
+3 -3
View File
@@ -35,7 +35,7 @@ use std::time::Duration;
///
/// # Packet Handling
///
/// - **Status packets (CMD=0x15)**: Arrive at ~500Hz, contain all sensor data (bumpers, encoders, etc.)
/// - **Status packets (CMD=0x15)**: Arrive at ~110Hz, contain all sensor data (bumpers, encoders, etc.)
/// - **Version response (CMD=0x07)**: Sent once after we request it following first packet
///
/// # Version Request Flow
@@ -129,7 +129,7 @@ pub(super) fn reader_loop(
&gyro_transform,
&accel_transform,
) {
// Push to streaming channel if available (for 500Hz TCP streaming)
// Push to streaming channel if available (for 110Hz TCP streaming)
if let Some(ref tx) = stream_tx {
// Use try_send to avoid blocking - drop message if channel full
if tx.try_send(cloned).is_err() {
@@ -150,7 +150,7 @@ pub(super) fn reader_loop(
}
}
// Note: No sleep here! The serial port read is blocking with timeout.
// At 500Hz, we need to process every packet immediately.
// At 110Hz, we need to process every packet immediately.
}
log::info!("Reader thread exiting");
+3 -3
View File
@@ -2,7 +2,7 @@
//!
//! Supports two modes for different data rates:
//! - **Polling mode**: For low-rate sensors (lidar @ 5Hz), polls shared mutex
//! - **Streaming mode**: For high-rate sensors (GD32 @ 500Hz), consumes from channel
//! - **Streaming mode**: For high-rate sensors (GD32 @ 110Hz), consumes from channel
use crate::core::types::{SensorGroupData, StreamReceiver};
use crate::error::Result;
@@ -20,7 +20,7 @@ pub struct TcpPublisher {
serializer: Serializer,
/// Mutex-based sensor data for low-rate polling (lidar, version)
sensor_data: HashMap<String, Arc<Mutex<SensorGroupData>>>,
/// Channel-based receivers for high-rate streaming (sensor_status @ 500Hz)
/// Channel-based receivers for high-rate streaming (sensor_status @ 110Hz)
stream_receivers: HashMap<String, StreamReceiver>,
running: Arc<AtomicBool>,
}
@@ -67,7 +67,7 @@ impl TcpPublisher {
while self.running.load(Ordering::Relaxed) {
let mut sent_any = false;
// Phase 1: Drain all streaming channels (high-rate sensors like GD32 @ 500Hz)
// Phase 1: Drain all streaming channels (high-rate sensors like GD32 @ 110Hz)
for (group_id, rx) in &self.stream_receivers {
loop {
match rx.try_recv() {
+3 -3
View File
@@ -22,7 +22,7 @@
//!
//! - **Latency**: <1ms serialization time (typical)
//! - **Throughput**: ~50,000 messages/sec
//! - **Bandwidth**: Sensor stream @ 500Hz ≈ 60KB/s
//! - **Bandwidth**: Sensor stream @ 110Hz ≈ 13KB/s
use crate::core::types::{Command, SensorGroupData};
use crate::error::{Error, Result};
@@ -79,7 +79,7 @@ pub fn create_serializer() -> Serializer {
impl proto::Message {
/// Create a sensor group message from internal types
///
/// Uses references where possible to minimize allocations in hot path (~500Hz).
/// Uses references where possible to minimize allocations in hot path (~110Hz).
pub fn from_sensor_group(data: &SensorGroupData) -> Self {
use std::collections::HashMap;
@@ -107,7 +107,7 @@ impl proto::Message {
impl proto::SensorValue {
/// Convert from a reference to SensorValue - avoids cloning for primitive types
///
/// This is the preferred method in hot paths (~500Hz) as it only clones
/// This is the preferred method in hot paths (~110Hz) as it only clones
/// heap-allocated data (String, Bytes, PointCloud2D) when necessary.
pub fn from_ref(value: &crate::core::types::SensorValue) -> Self {
use crate::core::types::SensorValue as SV;