Add SLAM round-trip test framework and update docs for UDP/TCP architecture

- Add slam-test/ framework for testing DhruvaSLAM mapping accuracy
- Includes test orchestrator, map analysis, and command scripts
- Update CLAUDE.md to reflect hybrid UDP/TCP streaming architecture
This commit is contained in:
codetiger
2025-12-17 08:08:33 +05:30
parent 2d15609fb9
commit d6c029825d
10 changed files with 1383 additions and 52 deletions
+93 -52
View File
@@ -12,7 +12,7 @@ VacuumTiger is an open-source robotic vacuum firmware project for the CRL-200S h
**Target Platform**: Embedded Linux (ARM) - Allwinner A33 running Tina Linux
**Primary Language**: Rust (edition 2024) for SangamIO, Python 3.8+ for Drishti
**Cross-compilation Target**: `armv7-unknown-linux-musleabihf`
**Architecture**: Daemon-based with TCP streaming (not a library)
**Architecture**: Daemon-based with UDP/TCP streaming (not a library)
## System Architecture
@@ -22,19 +22,20 @@ VacuumTiger is an open-source robotic vacuum firmware project for the CRL-200S h
┌─────────────────────────────────────────────────┐
│ Client Applications (SLAM/Drishti) │
└─────────────┬───────────────┬───────────────────┘
│ TCP 5555/5556 │
│ UDP 5555 │ TCP 5555
│ (sensors) │ (commands)
┌─────────────▼───────────────▼───────────────────┐
│ SangamIO Daemon (Robot) │
│ • Telemetry streaming @ 110Hz (sensors) │
│ • Telemetry streaming @ 110Hz (UDP unicast) │
│ • Lidar streaming @ 5Hz (360° scans) │
│ • Command processing (motion/actuators) │
│ • Real-time control loop (50Hz) │
│ • Command processing (TCP, reliable) │
│ • Real-time control loop (20ms heartbeat) │
└─────────────┬───────────────┬───────────────────┘
│ /dev/ttyS3 │ /dev/ttyS1
┌──────▼─────────┐ ┌──▼────────────┐
│ GD32F103 MCU │ │ Delta-2D Lidar │
│ (Motor Control)│ │ (360° Scanning)│
└────────────────┘ └────────────────┘
│ GD32F103 MCU │ │ Delta-2D Lidar│
│ (Motor Control)│ │ (360° Scan) │
└────────────────┘ └───────────────┘
```
### Key Design Principles
@@ -43,7 +44,7 @@ VacuumTiger is an open-source robotic vacuum firmware project for the CRL-200S h
2. **Concrete Over Abstract**: Direct implementations instead of trait objects (except LidarDriver)
3. **Lock-Free Streaming**: Uses lock-free queues for sensor data flow
4. **Real-Time Guarantees**: OS threads for heartbeat (20ms) instead of async
5. **TCP Protocol**: Protobuf over TCP for all communication
5. **Hybrid Protocol**: UDP for sensor streaming (low latency), TCP for commands (reliable)
## Build and Deployment
@@ -103,7 +104,13 @@ sshpass -p "vacuum@123" ssh root@vacuum "RUST_LOG=info /usr/sbin/sangamio"
**CRITICAL**: Always overwrite `/usr/sbin/sangamio` directly. Do NOT write to `/tmp` folder.
## TCP Protocol
## Network Protocol
SangamIO uses a hybrid UDP/TCP architecture:
- **UDP** for sensor streaming (low latency, no head-of-line blocking)
- **TCP** for commands (reliable delivery)
Both use the same port (5555) for simpler configuration.
### Message Format
@@ -120,41 +127,61 @@ All messages use length-prefixed framing with Protobuf payloads:
See `sangam-io/proto/sangamio.proto` for SangamIO messages (sensors, commands) and `dhruva-slam/proto/dhruva.proto` for SLAM output messages (odometry, map, scan).
### Topics
### Topics (UDP Streaming)
**Outbound (Daemon → Client)**:
**Outbound (Daemon → Client via UDP)**:
- `sensors/sensor_status`: Sensor data @ 110Hz (all 13 sensors)
- `sensors/device_version`: Version info (one-time)
- `sensors/lidar`: LidarScan @ 5Hz (point cloud)
**Inbound (Client → Daemon)**:
**Inbound (Client → Daemon via TCP)**:
- `command`: RobotCommand (SetVelocity, Stop, actuator control, etc.)
### Client Registration
UDP uses unicast (not broadcast). Clients are registered automatically:
1. Client connects TCP to port 5555
2. Server records client IP address
3. UDP packets are sent to client on same port
4. When TCP disconnects, UDP streaming stops
### Example Python Client
```python
import socket
import struct
import threading
from proto import sangamio_pb2
# Connect to daemon
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('192.168.68.101', 5555))
ROBOT_IP = '192.168.68.101'
PORT = 5555
# Read messages
while True:
# Read length prefix
length = struct.unpack('>I', sock.recv(4))[0]
# TCP for commands
tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_sock.connect((ROBOT_IP, PORT))
# Read Protobuf message
data = sock.recv(length)
msg = sangamio_pb2.Message()
msg.ParseFromString(data)
# UDP for sensor data (same port)
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.bind(('0.0.0.0', PORT))
if msg.topic == 'sensors/sensor_status':
sg = msg.sensor_group
charging = sg.values['is_charging'].bool_val
print(f"Charging: {charging}")
def receive_sensors():
while True:
data, addr = udp_sock.recvfrom(4096)
length = struct.unpack('>I', data[:4])[0]
msg = sangamio_pb2.Message()
msg.ParseFromString(data[4:4+length])
if msg.topic == 'sensors/sensor_status':
sg = msg.sensor_group
print(f"Battery: {sg.values['battery_level'].u8_val}%")
# Start receiving in background
threading.Thread(target=receive_sensors, daemon=True).start()
# Send commands via TCP
def send_command(cmd):
payload = cmd.SerializeToString()
tcp_sock.send(struct.pack('>I', len(payload)) + payload)
```
## Configuration
@@ -180,40 +207,54 @@ bind_address = "0.0.0.0:5555"
VacuumTiger/
├── sangam-io/ # Hardware abstraction daemon
│ ├── src/
│ │ ├── main.rs # Entry point, arg parsing
│ │ ├── config.rs # Configuration structures
│ │ ├── main.rs # Entry point, TCP/UDP listener
│ │ ├── config.rs # TOML configuration loading
│ │ ├── error.rs # Error types
│ │ ├── core/ # Core abstractions
│ │ │ ├── mod.rs
│ │ │ ├── driver.rs # DeviceDriver trait
│ │ │ └── types.rs # SensorValue, Command types
│ │ ├── devices/ # Hardware drivers
│ │ │ ├── mod.rs # Device factory
│ │ │ └── crl200s/ # CRL-200S robot platform
│ │ │ ├── mod.rs
│ │ │ ├── constants.rs # Offsets, flags, timing
│ │ │ ├── gd32/ # Motor controller driver
│ │ │ │ ├── mod.rs # Driver orchestration
│ │ │ │ ├── protocol.rs # RxPacket, PacketReader
│ │ │ │ ├── packet.rs # TxPacket, checksum
│ │ │ │ ├── ring_buffer.rs # Zero-copy ring buffer
│ │ │ │ ├── heartbeat.rs # 20ms watchdog loop
│ │ │ │ ├── reader.rs # Status parsing
│ │ │ │ ├── commands.rs # Command handlers
│ │ │ │ └── state.rs # Shared atomic state
│ │ │ └── delta2d/ # 3iRobotix lidar
│ │ │ ├── mod.rs # LidarDriver impl
│ │ │ └── protocol.rs # Packet parsing
│ │ └── streaming/ # TCP communication
│ │ │ ├── mod.rs # Device factory (crl200s, mock)
│ │ │ ├── crl200s/ # Real hardware driver
│ │ │ │ ├── mod.rs
│ │ │ │ ├── constants.rs # Offsets, flags, timing
│ │ │ │ ├── COMMANDS.md # GD32 command reference
│ │ │ │ ├── SENSORSTATUS.md # Sensor packet docs
│ │ │ │ ├── gd32/ # Motor controller driver
│ │ │ │ │ ├── mod.rs # Driver orchestration
│ │ │ │ │ ├── protocol.rs # RxPacket, PacketReader
│ │ │ │ │ ├── packet.rs # TxPacket, checksum
│ │ │ │ │ ├── ring_buffer.rs # Zero-copy ring buffer
│ │ │ │ │ ├── heartbeat.rs # 20ms watchdog loop
│ │ │ │ │ ├── reader.rs # Status parsing
│ │ │ │ │ ├── commands.rs # Command handlers
│ │ │ │ │ └── state.rs # Shared atomic state
│ │ │ │ └── delta2d/ # 3iRobotix lidar
│ │ │ │ ├── mod.rs # LidarDriver impl
│ │ │ │ └── protocol.rs # Packet parsing
│ │ │ └── mock/ # Simulation driver (--features mock)
│ │ │ ├── mod.rs # MockDriver, simulation loop
│ │ │ ├── config.rs # SimulationConfig structs
│ │ │ ├── physics.rs # Differential drive kinematics
│ │ │ ├── lidar_sim.rs # Ray-casting lidar
│ │ │ ├── imu_sim.rs # IMU with noise
│ │ │ ├── encoder_sim.rs # Encoder with slip
│ │ │ ├── sensor_sim.rs # Bumpers, cliffs
│ │ │ ├── map_loader.rs # PGM+YAML loader
│ │ │ └── noise.rs # Noise generator
│ │ └── streaming/ # Network communication
│ │ ├── mod.rs # Re-exports
│ │ ├── wire.rs # Protobuf serialization
│ │ ├── tcp_publisher.rs # Outbound stream
│ │ └── tcp_receiver.rs # Command handler
│ │ ├── udp_publisher.rs # UDP sensor streaming
│ │ └── tcp_receiver.rs # TCP command handler
│ ├── proto/ # Protobuf schema
│ │ └── sangamio.proto # Message definitions
│ ├── sangamio.toml # Configuration file
│ ├── COMMANDS.md # GD32 command reference
│ └── SENSORSTATUS.md # Sensor packet documentation
│ ├── maps/ # Example simulation maps
│ ├── docs/ # Documentation
│ │ └── mock-device-guide.md
│ ├── sangamio.toml # Hardware configuration
│ └── mock.toml # Simulation configuration
│
└── drishti/ # Python visualization client
├── drishti.py # Console client
+17
View File
@@ -0,0 +1,17 @@
# Test results
results/
# Python
__pycache__/
*.pyc
.venv/
venv/
# Generated proto files
scripts/proto/*.py
!scripts/proto/__init__.py
# Editor
.vscode/
.idea/
*.swp
+40
View File
@@ -0,0 +1,40 @@
# SLAM Test Framework Makefile
.PHONY: proto test clean help
# Default target
help:
@echo "SLAM Round-Trip Test Framework"
@echo ""
@echo "Targets:"
@echo " make proto - Compile protobuf files"
@echo " make test - Run test with defaults (2 min)"
@echo " make test-quick - Run quick test (30 sec)"
@echo " make test-long - Run long test (5 min)"
@echo " make clean - Clean results directory"
@echo ""
@echo "Manual run:"
@echo " ./test_slam.sh --duration 60 --output ./results/mytest"
# Compile protobuf
proto:
@echo "Compiling protobuf..."
protoc --python_out=scripts/proto \
-I ../dhruva-slam/proto \
../dhruva-slam/proto/dhruva.proto
@echo "Done: scripts/proto/dhruva_pb2.py"
# Run tests
test:
./test_slam.sh --duration 120
test-quick:
./test_slam.sh --duration 30
test-long:
./test_slam.sh --duration 300
# Clean results
clean:
rm -rf results/*
@echo "Cleaned results/"
+154
View File
@@ -0,0 +1,154 @@
# SLAM Round-Trip Test Framework
Integration testing for DhruvaSLAM mapping accuracy against mock simulation ground truth.
## Quick Start
```bash
cd slam-test
# First time: compile protobuf
make proto
# Run with defaults (2 min duration)
make test
# Or run directly with custom options
./test_slam.sh --output ./results/mytest --duration 60
```
## Requirements
- Rust toolchain (for building sangam-io and dhruva-slam)
- Python 3.8+ with packages:
```bash
pip install protobuf numpy pillow matplotlib
```
- protoc (protocol buffer compiler):
```bash
# macOS
brew install protobuf
# Linux
apt install protobuf-compiler
```
## Available Commands
```bash
make proto # Compile protobuf files (required first time)
make test # Run test (2 min duration)
make test-quick # Run quick test (30 sec)
make test-long # Run long test (5 min)
make clean # Clean results directory
```
## Manual Test Options
```bash
./test_slam.sh [OPTIONS]
Options:
--output DIR Output directory (default: results/TIMESTAMP)
--duration SEC Test duration in seconds (default: 120)
--config FILE DhruvaSLAM config file (default: dhruva-test.toml)
--help Show help message
```
## Structure
```
slam-test/
├── README.md # This file
├── Makefile # Build and test commands
├── test_slam.sh # Main test orchestrator
├── test_config.toml # Test parameters and thresholds
├── dhruva-test.toml # SLAM config (resolution=0.05m)
├── round-trip-test-proposal.md # Detailed design doc
├── scripts/
│ ├── send_command.py # Send commands to DhruvaSLAM
│ ├── analyze_map.py # Compare maps, generate reports
│ └── proto/
│ └── dhruva_pb2.py # Generated protobuf (run make proto)
└── results/ # Test outputs (gitignored)
```
## Test Flow
1. **Setup**: Start SangamIO (mock) and DhruvaSLAM
2. **Mapping**: Send StartMapping, robot explores autonomously
3. **Capture**: Stop mapping, save output map
4. **Analysis**: Compare against ground truth, generate reports
## Outputs
Each test run creates a timestamped directory with:
| File | Description |
|------|-------------|
| `sangamio.log` | SangamIO process logs |
| `dhruva.log` | DhruvaSLAM process logs |
| `ground_truth.pgm` | Input map copy |
| `slam_output.pgm` | SLAM generated map |
| `comparison.png` | Visual diff overlay |
| `analysis.json` | Numeric metrics |
| `summary.txt` | Human-readable results |
## Visual Diff Colors
- **White**: Free space (both agree)
- **Green**: Occupied (both agree - correct detection)
- **Red**: False positive (SLAM occupied, ground truth free)
- **Blue**: False negative (SLAM free, ground truth occupied)
- **Gray**: Unknown in SLAM output
## Success Criteria
| Metric | Pass | Warning |
|--------|------|---------|
| Occupied overlap | > 70% | > 60% |
| Free space overlap | > 85% | > 75% |
| Coverage ratio | > 75% | > 60% |
## Configuration
### Test Config (`test_config.toml`)
Test parameters and pass/fail thresholds.
### SLAM Config (`dhruva-test.toml`)
DhruvaSLAM configuration with **resolution matching ground truth** (0.05m = 5cm).
Key settings that must match `sangam-io/mock.toml`:
- `[map] resolution = 0.05`
- `[lidar]` mounting offsets
- `[odometry]` encoder parameters
## Troubleshooting
### "Proto files not compiled"
```bash
make proto
```
### "Could not connect to port 5557"
Check if DhruvaSLAM started:
```bash
tail -f results/*/dhruva.log
```
### "SangamIO failed to start"
Check if port 5555 is in use:
```bash
lsof -i :5555
```
## See Also
- [Detailed Proposal](round-trip-test-proposal.md)
- [DhruvaSLAM README](../dhruva-slam/README.md)
- [SangamIO Mock Guide](../sangam-io/docs/mock-device-guide.md)
+147
View File
@@ -0,0 +1,147 @@
# DhruvaSLAM Test Configuration
#
# This config is used for round-trip testing against mock simulation.
# Key difference: resolution matches ground truth map (0.05m = 5cm)
[source]
sangam_address = "localhost:5555"
[output]
bind_port = 5557
odometry_rate_hz = 50
map_rate_hz = 5.0
feature_rate_hz = 1.0
[stream]
robot_status_hz = 10.0
sensor_status_hz = 10.0
map_hz = 2.0
navigation_hz = 5.0
[map_storage]
# Use temp directory for test isolation
path = "/tmp/dhruva_test_maps"
[odometry]
# Use wheel-only odometry to avoid Mahony calibration issues
algorithm = "wheel"
ticks_per_meter = 4464.0
wheel_base = 0.233
[lidar]
# Must match mock.toml lidar configuration
mounting_x = -0.110
mounting_y = 0.0
optical_offset = 0.025
angle_offset = 0.2182
[preprocessing]
min_range = 0.15
max_range = 5.0
target_points = 180
min_angle_step = 0.0175
[slam]
min_match_score = 0.3
lost_threshold = 0.1
min_scan_points = 50
encoder_weight = 0.8
use_submap_matching = false
[matcher]
algorithm = "hybrid_p2l"
always_correlative = true
encoder_weight = 1.0
search_window_x = 0.3
search_window_y = 0.3
search_window_theta = 0.5
linear_resolution = 0.02
angular_resolution = 0.01
grid_resolution = 0.05
correlative_min_score = 0.4
icp_max_iterations = 50
icp_translation_epsilon = 0.001
icp_rotation_epsilon = 0.001
icp_max_correspondence_distance = 0.5
icp_min_correspondences = 10
icp_outlier_ratio = 0.1
icp_robust_kernel = "welsch"
icp_kernel_scale = 0.1
[keyframe]
min_translation = 0.5
min_rotation = 0.5
max_keyframes = 1000
min_interval_ms = 500
[submap]
scans_per_submap = 100
overlap_scans = 10
max_active_submaps = 2
[map]
# CRITICAL: Must match ground truth resolution (5cm)
resolution = 0.05
initial_width = 20.0
initial_height = 20.0
log_odds_occupied = 0.9
log_odds_free = -0.7
log_odds_max = 50.0
log_odds_min = -50.0
occupied_threshold = 0.5
free_threshold = -0.5
[loop_closure]
enabled = true
detection_interval = 5
min_keyframe_gap = 20
optimization_threshold = 3
max_candidates = 5
[loop_closure.iris]
num_rows = 80
num_cols = 360
max_range = 8.0
signature_bits = 640
[loop_closure.matching]
max_hamming_distance = 100
min_icp_score = 0.5
[optimization]
solver = "sparse_cg"
max_iterations = 50
tolerance = 1e-6
[exploration]
# Enabled for autonomous frontier-based exploration
enabled = true
strategy = "frontier"
loop_rate_hz = 10.0
[exploration.motion]
max_linear_vel = 0.3
max_angular_vel = 0.5
obstacle_clearance = 0.15
linear_vel = 0.2
angular_vel = 0.3
[exploration.frontier]
min_frontier_size = 3 # Smaller frontiers still worth exploring
detection_range = 10.0
blocked_radius = 0.15 # Reduced to allow closer exploration to obstacles
clustering_threshold = 0.5
[navigation]
enabled = true
update_rate_hz = 10.0
robot_radius = 0.18
max_linear_vel = 0.3
max_angular_vel = 0.5
waypoint_threshold = 0.15
goal_threshold = 0.08
heading_threshold = 0.15
[session]
state_path = "/tmp/dhruva_test_session.json"
auto_load_last_map = false
+463
View File
@@ -0,0 +1,463 @@
#!/usr/bin/env python3
"""
Analyze SLAM output map against ground truth.
Generates:
- comparison.png: Visual diff overlay
- trajectory.png: Robot path (if available)
- analysis.json: Numeric metrics
- summary.txt: Human-readable summary
"""
import argparse
import json
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
from PIL import Image
# Try to import matplotlib for visualization
try:
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
print("Warning: matplotlib not available, skipping visualizations")
@dataclass
class MapData:
"""Loaded map data."""
pixels: np.ndarray # 2D array of pixel values (0-255)
resolution: float # meters per pixel
origin_x: float # world X of pixel (0,0)
origin_y: float # world Y of pixel (0,0)
width: int
height: int
@dataclass
class ComparisonResult:
"""Map comparison metrics."""
# Cell counts
total_gt_occupied: int
total_gt_free: int
total_slam_occupied: int
total_slam_free: int
total_slam_unknown: int
# Overlap metrics
true_positive: int # Both say occupied
true_negative: int # Both say free
false_positive: int # SLAM occupied, GT free
false_negative: int # SLAM free, GT occupied
# Computed metrics
occupied_overlap_pct: float
free_overlap_pct: float
false_positive_pct: float
false_negative_pct: float
# Coverage
explored_cells: int
coverage_ratio: float
def load_pgm(path: Path, resolution: float = 0.05,
origin_x: float = 0.0, origin_y: float = 0.0) -> MapData:
"""Load PGM map file."""
img = Image.open(path).convert('L')
pixels = np.array(img)
return MapData(
pixels=pixels,
resolution=resolution,
origin_x=origin_x,
origin_y=origin_y,
width=pixels.shape[1],
height=pixels.shape[0]
)
def load_yaml_metadata(yaml_path: Path) -> dict:
"""Load map metadata from YAML file."""
metadata = {}
try:
import yaml
with open(yaml_path) as f:
data = yaml.safe_load(f)
if 'resolution' in data:
metadata['resolution'] = float(data['resolution'])
if 'origin' in data:
origin = data['origin']
if isinstance(origin, list) and len(origin) >= 2:
metadata['origin_x'] = float(origin[0])
metadata['origin_y'] = float(origin[1])
except ImportError:
# Fallback to simple parsing if PyYAML not available
try:
with open(yaml_path) as f:
for line in f:
if ':' in line:
key, value = line.split(':', 1)
key = key.strip()
value = value.strip()
if key == 'resolution':
metadata['resolution'] = float(value)
elif key == 'origin' and value:
# Parse [x, y, theta] format
value = value.strip('[]')
parts = [float(x.strip()) for x in value.split(',')]
metadata['origin_x'] = parts[0]
metadata['origin_y'] = parts[1]
except Exception as e:
print(f"Warning: Could not parse YAML metadata: {e}")
except Exception as e:
print(f"Warning: Could not parse YAML metadata: {e}")
return metadata
def classify_pixel(value: int) -> str:
"""Classify pixel as free, occupied, or unknown."""
if value >= 240: # White = free
return 'free'
elif value <= 20: # Black = occupied
return 'occupied'
else: # Gray = unknown
return 'unknown'
def align_maps(ground_truth: MapData, slam_map: MapData,
robot_start: Tuple[float, float, float]) -> Tuple[np.ndarray, np.ndarray]:
"""
Align SLAM map to ground truth using robot start position.
The SLAM system builds maps relative to the robot's starting position,
treating it as (0,0) in SLAM coordinates. The robot_start parameter
provides the robot's actual starting position in world coordinates,
allowing us to translate between coordinate systems:
- SLAM (0,0) = World (robot_start.x, robot_start.y)
- SLAM origin (-10,-10) in SLAM coords = World origin + robot_start
Returns aligned arrays of same dimensions as ground truth.
"""
# Robot start position in world coordinates
start_x, start_y, _ = robot_start
# Convert SLAM origin from SLAM-local to world coordinates
# SLAM's (0,0) corresponds to world's (start_x, start_y)
slam_world_origin_x = slam_map.origin_x + start_x
slam_world_origin_y = slam_map.origin_y + start_y
# Create output array matching ground truth dimensions
aligned_slam = np.full_like(ground_truth.pixels, 128, dtype=np.uint8) # Unknown
# For each pixel in ground truth, find corresponding SLAM pixel
for gt_py in range(ground_truth.height):
for gt_px in range(ground_truth.width):
# Ground truth pixel to world coordinates
# Note: PGM has Y=0 at top, but world has Y=0 at bottom
world_x = ground_truth.origin_x + gt_px * ground_truth.resolution
world_y = ground_truth.origin_y + (ground_truth.height - 1 - gt_py) * ground_truth.resolution
# World to SLAM pixel coordinates (using world-adjusted origin)
slam_px = int((world_x - slam_world_origin_x) / slam_map.resolution)
slam_py = slam_map.height - 1 - int((world_y - slam_world_origin_y) / slam_map.resolution)
# Check bounds
if 0 <= slam_px < slam_map.width and 0 <= slam_py < slam_map.height:
aligned_slam[gt_py, gt_px] = slam_map.pixels[slam_py, slam_px]
return ground_truth.pixels, aligned_slam
def compare_maps(gt_pixels: np.ndarray, slam_pixels: np.ndarray) -> ComparisonResult:
"""Compare aligned maps and compute metrics."""
# Classify all pixels
gt_free = gt_pixels >= 240
gt_occupied = gt_pixels <= 20
slam_free = slam_pixels >= 240
slam_occupied = slam_pixels <= 20
slam_unknown = (slam_pixels > 20) & (slam_pixels < 240)
# Count cells
total_gt_occupied = np.sum(gt_occupied)
total_gt_free = np.sum(gt_free)
total_slam_occupied = np.sum(slam_occupied)
total_slam_free = np.sum(slam_free)
total_slam_unknown = np.sum(slam_unknown)
# Compute overlaps (only where SLAM has data)
slam_has_data = ~slam_unknown
true_positive = np.sum(gt_occupied & slam_occupied)
true_negative = np.sum(gt_free & slam_free)
false_positive = np.sum(gt_free & slam_occupied)
false_negative = np.sum(gt_occupied & slam_free)
# Compute percentages
if total_gt_occupied > 0:
occupied_overlap_pct = 100.0 * true_positive / total_gt_occupied
false_negative_pct = 100.0 * false_negative / total_gt_occupied
else:
occupied_overlap_pct = 0.0
false_negative_pct = 0.0
if total_gt_free > 0:
free_overlap_pct = 100.0 * true_negative / total_gt_free
false_positive_pct = 100.0 * false_positive / total_gt_free
else:
free_overlap_pct = 0.0
false_positive_pct = 0.0
# Coverage ratio
explored_cells = np.sum(slam_has_data)
total_navigable = total_gt_free + total_gt_occupied
coverage_ratio = explored_cells / total_navigable if total_navigable > 0 else 0.0
return ComparisonResult(
total_gt_occupied=int(total_gt_occupied),
total_gt_free=int(total_gt_free),
total_slam_occupied=int(total_slam_occupied),
total_slam_free=int(total_slam_free),
total_slam_unknown=int(total_slam_unknown),
true_positive=int(true_positive),
true_negative=int(true_negative),
false_positive=int(false_positive),
false_negative=int(false_negative),
occupied_overlap_pct=occupied_overlap_pct,
free_overlap_pct=free_overlap_pct,
false_positive_pct=false_positive_pct,
false_negative_pct=false_negative_pct,
explored_cells=int(explored_cells),
coverage_ratio=coverage_ratio
)
def create_diff_image(gt_pixels: np.ndarray, slam_pixels: np.ndarray,
output_path: Path) -> None:
"""Create color-coded diff image."""
if not HAS_MATPLOTLIB:
return
height, width = gt_pixels.shape
diff_rgb = np.zeros((height, width, 3), dtype=np.uint8)
# Classify pixels
gt_free = gt_pixels >= 240
gt_occupied = gt_pixels <= 20
slam_free = slam_pixels >= 240
slam_occupied = slam_pixels <= 20
slam_unknown = (slam_pixels > 20) & (slam_pixels < 240)
# White: Both free
both_free = gt_free & slam_free
diff_rgb[both_free] = [255, 255, 255]
# Green: Both occupied (correct detection)
both_occupied = gt_occupied & slam_occupied
diff_rgb[both_occupied] = [0, 200, 0]
# Red: False positive (SLAM occupied, GT free)
false_pos = gt_free & slam_occupied
diff_rgb[false_pos] = [255, 0, 0]
# Blue: False negative (SLAM free, GT occupied)
false_neg = gt_occupied & slam_free
diff_rgb[false_neg] = [0, 0, 255]
# Gray: Unknown in SLAM
diff_rgb[slam_unknown] = [180, 180, 180]
# Light gray: GT unknown (neither free nor occupied)
gt_unknown = ~gt_free & ~gt_occupied
diff_rgb[gt_unknown & ~slam_unknown] = [220, 220, 220]
# Save image
fig, ax = plt.subplots(figsize=(12, 10))
ax.imshow(diff_rgb)
ax.set_title("Map Comparison: SLAM vs Ground Truth")
ax.axis('off')
# Legend
legend_elements = [
mpatches.Patch(color='white', label='Free (both agree)'),
mpatches.Patch(color='green', label='Occupied (both agree)'),
mpatches.Patch(color='red', label='False positive'),
mpatches.Patch(color='blue', label='False negative'),
mpatches.Patch(color='gray', label='Unknown (SLAM)'),
]
ax.legend(handles=legend_elements, loc='upper right')
plt.tight_layout()
plt.savefig(output_path, dpi=150)
plt.close()
print(f" Saved: {output_path}")
def write_analysis_json(result: ComparisonResult, resolution: float,
duration: int, output_path: Path) -> None:
"""Write analysis results to JSON."""
data = {
"test_info": {
"timestamp": datetime.now().isoformat(),
"duration_seconds": duration,
"resolution_m": resolution
},
"geometric_accuracy": {
"occupied_overlap_pct": round(result.occupied_overlap_pct, 2),
"free_overlap_pct": round(result.free_overlap_pct, 2),
"false_positive_pct": round(result.false_positive_pct, 2),
"false_negative_pct": round(result.false_negative_pct, 2)
},
"cell_counts": {
"gt_occupied": result.total_gt_occupied,
"gt_free": result.total_gt_free,
"slam_occupied": result.total_slam_occupied,
"slam_free": result.total_slam_free,
"slam_unknown": result.total_slam_unknown
},
"coverage": {
"explored_cells": result.explored_cells,
"coverage_ratio": round(result.coverage_ratio, 3)
},
"confusion_matrix": {
"true_positive": result.true_positive,
"true_negative": result.true_negative,
"false_positive": result.false_positive,
"false_negative": result.false_negative
}
}
with open(output_path, 'w') as f:
json.dump(data, f, indent=2)
print(f" Saved: {output_path}")
def write_summary(result: ComparisonResult, resolution: float,
duration: int, output_path: Path) -> None:
"""Write human-readable summary."""
# Determine pass/fail
pass_threshold = 70.0
status = "PASS" if result.occupied_overlap_pct >= pass_threshold else "FAIL"
area_m2 = result.explored_cells * (resolution ** 2)
lines = [
"SLAM Round-Trip Test Summary",
"=" * 40,
f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"Duration: {duration} seconds",
f"Result: {status} ({result.occupied_overlap_pct:.1f}% overlap)",
"",
"Accuracy Metrics:",
f" - Occupied overlap: {result.occupied_overlap_pct:.1f}%",
f" - Free space overlap: {result.free_overlap_pct:.1f}%",
f" - False positive rate: {result.false_positive_pct:.1f}%",
f" - False negative rate: {result.false_negative_pct:.1f}%",
"",
"Coverage:",
f" - Explored area: {area_m2:.2f} m²",
f" - Coverage ratio: {result.coverage_ratio * 100:.1f}%",
"",
"Cell Counts:",
f" - Ground truth occupied: {result.total_gt_occupied}",
f" - Ground truth free: {result.total_gt_free}",
f" - SLAM occupied: {result.total_slam_occupied}",
f" - SLAM free: {result.total_slam_free}",
f" - SLAM unknown: {result.total_slam_unknown}",
]
with open(output_path, 'w') as f:
f.write('\n'.join(lines))
print(f" Saved: {output_path}")
# Also print to console
print("")
for line in lines:
print(line)
def main():
parser = argparse.ArgumentParser(description="Analyze SLAM map vs ground truth")
parser.add_argument("--ground-truth", required=True, help="Ground truth PGM file")
parser.add_argument("--slam-output", required=True, help="SLAM output PGM file")
parser.add_argument("--start-pose", default="1.5,3.5,0",
help="Robot start pose: x,y,theta")
parser.add_argument("--resolution", type=float, default=0.05,
help="Map resolution (meters/pixel)")
parser.add_argument("--output-dir", required=True, help="Output directory")
parser.add_argument("--duration", type=int, default=120,
help="Test duration for reporting")
args = parser.parse_args()
gt_path = Path(args.ground_truth)
slam_path = Path(args.slam_output)
output_dir = Path(args.output_dir)
# Parse robot start pose
start_parts = [float(x) for x in args.start_pose.split(',')]
robot_start = (start_parts[0], start_parts[1], start_parts[2] if len(start_parts) > 2 else 0.0)
print("Loading maps...")
# Load ground truth
if not gt_path.exists():
print(f"ERROR: Ground truth not found: {gt_path}")
sys.exit(1)
gt_yaml = gt_path.with_suffix('.yaml')
gt_meta = load_yaml_metadata(gt_yaml) if gt_yaml.exists() else {}
gt_resolution = gt_meta.get('resolution', args.resolution)
gt_origin_x = gt_meta.get('origin_x', 0.0)
gt_origin_y = gt_meta.get('origin_y', 0.0)
ground_truth = load_pgm(gt_path, gt_resolution, gt_origin_x, gt_origin_y)
print(f" Ground truth: {ground_truth.width}x{ground_truth.height} @ {gt_resolution}m/px")
# Load SLAM output
if not slam_path.exists():
print(f"ERROR: SLAM output not found: {slam_path}")
sys.exit(1)
slam_yaml = slam_path.with_suffix('.yaml')
slam_meta = load_yaml_metadata(slam_yaml) if slam_yaml.exists() else {}
slam_resolution = slam_meta.get('resolution', args.resolution)
slam_origin_x = slam_meta.get('origin_x', robot_start[0])
slam_origin_y = slam_meta.get('origin_y', robot_start[1])
slam_map = load_pgm(slam_path, slam_resolution, slam_origin_x, slam_origin_y)
print(f" SLAM output: {slam_map.width}x{slam_map.height} @ {slam_resolution}m/px")
print("Aligning maps...")
gt_aligned, slam_aligned = align_maps(ground_truth, slam_map, robot_start)
print("Comparing maps...")
result = compare_maps(gt_aligned, slam_aligned)
print("Generating outputs...")
output_dir.mkdir(parents=True, exist_ok=True)
# Create diff image
create_diff_image(gt_aligned, slam_aligned, output_dir / "comparison.png")
# Write JSON report
write_analysis_json(result, args.resolution, args.duration,
output_dir / "analysis.json")
# Write summary
write_summary(result, args.resolution, args.duration,
output_dir / "summary.txt")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
# Proto package
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""
Send commands to DhruvaSLAM via TCP.
Usage:
python send_command.py start_mapping [--name NAME] [--port PORT]
python send_command.py stop_mapping [--save] [--port PORT]
python send_command.py emergency_stop [--port PORT]
"""
import argparse
import socket
import struct
import sys
import uuid
from pathlib import Path
# Add proto directory to path
SCRIPT_DIR = Path(__file__).parent
PROTO_DIR = SCRIPT_DIR / "proto"
sys.path.insert(0, str(PROTO_DIR))
try:
import dhruva_pb2
except ImportError:
print("ERROR: Proto files not compiled. Run:")
print(f" protoc --python_out={PROTO_DIR} -I ../dhruva-slam/proto dhruva.proto")
print("")
print("Or install protobuf: pip install protobuf")
sys.exit(1)
def send_message(sock: socket.socket, msg: bytes) -> None:
"""Send length-prefixed message."""
length = struct.pack(">I", len(msg))
sock.sendall(length + msg)
def recv_message(sock: socket.socket, timeout: float = 5.0) -> bytes:
"""Receive length-prefixed message."""
sock.settimeout(timeout)
# Read length (4 bytes)
length_data = b""
while len(length_data) < 4:
chunk = sock.recv(4 - len(length_data))
if not chunk:
raise ConnectionError("Connection closed")
length_data += chunk
length = struct.unpack(">I", length_data)[0]
# Read message
data = b""
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
raise ConnectionError("Connection closed")
data += chunk
return data
def start_mapping(host: str, port: int, name: str) -> bool:
"""Send StartMapping command."""
request_id = str(uuid.uuid4())[:8]
cmd = dhruva_pb2.DhruvaCommand()
cmd.request_id = request_id
cmd.start_mapping.map_name = name
print(f"Connecting to {host}:{port}...")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((host, port))
print(f"Sending StartMapping (name={name})...")
send_message(sock, cmd.SerializeToString())
# Wait for response
try:
response_data = recv_message(sock)
response = dhruva_pb2.DhruvaResponse()
response.ParseFromString(response_data)
if response.success:
print(f"SUCCESS: Mapping started (map_id={response.mapping_started.map_id})")
return True
else:
print(f"FAILED: {response.error_message}")
return False
except Exception as e:
print(f"No response received (may still have started): {e}")
return True # Assume success if no error
def stop_mapping(host: str, port: int, save: bool) -> bool:
"""Send StopMapping command."""
request_id = str(uuid.uuid4())[:8]
cmd = dhruva_pb2.DhruvaCommand()
cmd.request_id = request_id
cmd.stop_mapping.save = save
print(f"Connecting to {host}:{port}...")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((host, port))
print(f"Sending StopMapping (save={save})...")
send_message(sock, cmd.SerializeToString())
try:
response_data = recv_message(sock)
response = dhruva_pb2.DhruvaResponse()
response.ParseFromString(response_data)
if response.success:
if response.HasField("mapping_stopped"):
ms = response.mapping_stopped
print(f"SUCCESS: Mapping stopped (saved={ms.saved}, area={ms.area_m2:.2f}m²)")
else:
print("SUCCESS: Mapping stopped")
return True
else:
print(f"FAILED: {response.error_message}")
return False
except Exception as e:
print(f"No response received: {e}")
return True
def emergency_stop(host: str, port: int) -> bool:
"""Send EmergencyStop command."""
request_id = str(uuid.uuid4())[:8]
cmd = dhruva_pb2.DhruvaCommand()
cmd.request_id = request_id
cmd.emergency_stop.CopyFrom(dhruva_pb2.EmergencyStopCommand())
print(f"Connecting to {host}:{port}...")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((host, port))
print("Sending EmergencyStop...")
send_message(sock, cmd.SerializeToString())
try:
response_data = recv_message(sock)
response = dhruva_pb2.DhruvaResponse()
response.ParseFromString(response_data)
if response.success:
print("SUCCESS: Emergency stop executed")
return True
else:
print(f"FAILED: {response.error_message}")
return False
except Exception as e:
print(f"No response received: {e}")
return True
def main():
parser = argparse.ArgumentParser(description="Send commands to DhruvaSLAM")
parser.add_argument("--host", default="localhost", help="DhruvaSLAM host")
parser.add_argument("--port", type=int, default=5557, help="DhruvaSLAM port")
subparsers = parser.add_subparsers(dest="command", required=True)
# start_mapping
start_parser = subparsers.add_parser("start_mapping", help="Start mapping")
start_parser.add_argument("--name", default="test_map", help="Map name")
# stop_mapping
stop_parser = subparsers.add_parser("stop_mapping", help="Stop mapping")
stop_parser.add_argument("--save", action="store_true", help="Save map before stopping")
# emergency_stop
subparsers.add_parser("emergency_stop", help="Emergency stop")
args = parser.parse_args()
try:
if args.command == "start_mapping":
success = start_mapping(args.host, args.port, args.name)
elif args.command == "stop_mapping":
success = stop_mapping(args.host, args.port, args.save)
elif args.command == "emergency_stop":
success = emergency_stop(args.host, args.port)
else:
print(f"Unknown command: {args.command}")
sys.exit(1)
sys.exit(0 if success else 1)
except ConnectionRefusedError:
print(f"ERROR: Could not connect to {args.host}:{args.port}")
print("Is DhruvaSLAM running?")
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
# SLAM Round-Trip Test Configuration
[test]
name = "round_trip_slam"
# Test duration in seconds (configurable via CLI --duration)
duration_seconds = 120
# Coverage target (0.0-1.0) - stop when this % of area explored
coverage_target = 0.8
[paths]
# All paths relative to VacuumTiger root
sangamio_config = "sangam-io/mock.toml"
dhruva_config = "slam-test/dhruva-test.toml"
ground_truth_map = "sangam-io/maps/example.pgm"
ground_truth_yaml = "sangam-io/maps/example.yaml"
[ground_truth]
# Map resolution in meters per pixel (from example.yaml)
resolution = 0.05
# Robot start position in world coordinates (from mock.toml)
robot_start_x = 1.5
robot_start_y = 3.5
robot_start_theta = 0.0
[network]
sangamio_port = 5555
dhruva_port = 5557
[thresholds]
# Pass/fail thresholds for automated testing
occupied_overlap_pass = 0.70
occupied_overlap_warn = 0.60
free_overlap_pass = 0.85
free_overlap_warn = 0.75
wall_rms_error_pass = 0.05 # meters
wall_rms_error_warn = 0.10
coverage_pass = 0.75
coverage_warn = 0.60
max_drift_pass = 0.20 # meters
max_drift_warn = 0.30
+226
View File
@@ -0,0 +1,226 @@
#!/bin/bash
#
# SLAM Round-Trip Test Script
# Tests DhruvaSLAM mapping against mock simulation ground truth
#
# Usage:
# ./test_slam.sh [OPTIONS]
#
# Options:
# --output DIR Output directory (default: results/TIMESTAMP)
# --duration SEC Test duration in seconds (default: 120)
# --config FILE DhruvaSLAM config file (default: dhruva-test.toml)
# --help Show this help message
set -e
# Default values
DURATION=120
CONFIG="dhruva-test.toml"
OUTPUT_DIR=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--output|-o)
OUTPUT_DIR="$2"
shift 2
;;
--duration|-d)
DURATION="$2"
shift 2
;;
--config|-c)
CONFIG="$2"
shift 2
;;
--help|-h)
head -20 "$0" | tail -15
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Get script directory and project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Set output directory with timestamp if not specified (use absolute path)
if [[ -z "$OUTPUT_DIR" ]]; then
OUTPUT_DIR="$SCRIPT_DIR/results/$(date +%Y%m%d_%H%M%S)"
else
# Convert relative path to absolute
OUTPUT_DIR="$(cd "$(dirname "$OUTPUT_DIR")" 2>/dev/null && pwd)/$(basename "$OUTPUT_DIR")" || OUTPUT_DIR="$SCRIPT_DIR/$OUTPUT_DIR"
fi
# Create output directory
mkdir -p "$OUTPUT_DIR"
# Use drishti venv Python (has protobuf installed)
PYTHON="$PROJECT_ROOT/drishti/venv/bin/python3"
if [[ ! -x "$PYTHON" ]]; then
echo "ERROR: Python venv not found at $PYTHON"
echo "Create it with: cd ../drishti && python3 -m venv venv && source venv/bin/activate && pip install protobuf numpy pillow matplotlib"
exit 1
fi
echo "=============================================="
echo " SLAM Round-Trip Test"
echo "=============================================="
echo "Output: $OUTPUT_DIR"
echo "Duration: ${DURATION}s"
echo "Config: $CONFIG"
echo "=============================================="
# Cleanup function
cleanup() {
echo ""
echo "Cleaning up..."
if [[ -n "$SANGAM_PID" ]]; then
kill $SANGAM_PID 2>/dev/null || true
fi
if [[ -n "$DHRUVA_PID" ]]; then
kill $DHRUVA_PID 2>/dev/null || true
fi
wait 2>/dev/null || true
}
trap cleanup EXIT
# 1. Start SangamIO (mock mode)
echo ""
echo "[1/7] Starting SangamIO (mock mode)..."
(cd "$PROJECT_ROOT/sangam-io" && cargo run --release --features mock -- --config mock.toml) \
> "$OUTPUT_DIR/sangamio.log" 2>&1 &
SANGAM_PID=$!
echo " PID: $SANGAM_PID"
sleep 2
# Check if SangamIO started
if ! kill -0 $SANGAM_PID 2>/dev/null; then
echo "ERROR: SangamIO failed to start. Check $OUTPUT_DIR/sangamio.log"
exit 1
fi
# 2. Start DhruvaSLAM
echo ""
echo "[2/7] Starting DhruvaSLAM..."
(cd "$PROJECT_ROOT/dhruva-slam" && RUST_LOG=info cargo run --release -- --config "../slam-test/$CONFIG") \
> "$OUTPUT_DIR/dhruva.log" 2>&1 &
DHRUVA_PID=$!
echo " PID: $DHRUVA_PID"
sleep 3
# Check if DhruvaSLAM started
if ! kill -0 $DHRUVA_PID 2>/dev/null; then
echo "ERROR: DhruvaSLAM failed to start. Check $OUTPUT_DIR/dhruva.log"
exit 1
fi
# 3. Send start mapping command
echo ""
echo "[3/7] Sending StartMapping command..."
$PYTHON "$SCRIPT_DIR/scripts/send_command.py" --port 5557 start_mapping \
--name "test_$(date +%H%M%S)"
if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to send StartMapping command"
exit 1
fi
# 4. Wait for duration with progress
echo ""
echo "[4/7] Mapping in progress (${DURATION}s)..."
ELAPSED=0
while [[ $ELAPSED -lt $DURATION ]]; do
# Check processes are still running
if ! kill -0 $SANGAM_PID 2>/dev/null; then
echo "ERROR: SangamIO died unexpectedly"
exit 1
fi
if ! kill -0 $DHRUVA_PID 2>/dev/null; then
echo "ERROR: DhruvaSLAM died unexpectedly"
exit 1
fi
# Progress indicator
REMAINING=$((DURATION - ELAPSED))
printf "\r Time remaining: %3ds " $REMAINING
sleep 5
ELAPSED=$((ELAPSED + 5))
done
echo ""
# 5. Stop mapping
echo ""
echo "[5/7] Stopping mapping..."
$PYTHON "$SCRIPT_DIR/scripts/send_command.py" --port 5557 stop_mapping --save
sleep 2
# 6. Collect outputs
echo ""
echo "[6/7] Collecting outputs..."
# Copy ground truth (use the map from mock.toml - currently large_room)
cp "$PROJECT_ROOT/sangam-io/maps/large_room.pgm" "$OUTPUT_DIR/ground_truth.pgm"
cp "$PROJECT_ROOT/sangam-io/maps/large_room.yaml" "$OUTPUT_DIR/ground_truth.yaml"
echo " Copied ground truth map"
# Find and copy SLAM output
SLAM_MAP_DIR="/tmp/dhruva_test_maps"
if [[ -d "$SLAM_MAP_DIR" ]]; then
# Find most recent map file (macOS compatible)
LATEST_MAP=$(find "$SLAM_MAP_DIR" -name "*.pgm" -type f -exec stat -f '%m %N' {} \; 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
if [[ -n "$LATEST_MAP" ]]; then
cp "$LATEST_MAP" "$OUTPUT_DIR/slam_output.pgm"
# Copy associated yaml if exists
YAML_FILE="${LATEST_MAP%.pgm}.yaml"
if [[ -f "$YAML_FILE" ]]; then
cp "$YAML_FILE" "$OUTPUT_DIR/slam_output.yaml"
fi
echo " Copied SLAM output: $(basename "$LATEST_MAP")"
else
echo " WARNING: No SLAM map file found in $SLAM_MAP_DIR"
fi
else
echo " WARNING: SLAM map directory not found: $SLAM_MAP_DIR"
fi
# Kill processes before analysis
echo " Stopping processes..."
kill $SANGAM_PID $DHRUVA_PID 2>/dev/null || true
wait 2>/dev/null || true
unset SANGAM_PID DHRUVA_PID
# 7. Run analysis
echo ""
echo "[7/7] Analyzing results..."
if [[ -f "$OUTPUT_DIR/slam_output.pgm" ]]; then
$PYTHON "$SCRIPT_DIR/scripts/analyze_map.py" \
--ground-truth "$OUTPUT_DIR/ground_truth.pgm" \
--slam-output "$OUTPUT_DIR/slam_output.pgm" \
--start-pose 1.0,1.0,0 \
--resolution 0.05 \
--output-dir "$OUTPUT_DIR"
else
echo " SKIPPED: No SLAM output to analyze"
echo " Check $OUTPUT_DIR/dhruva.log for errors"
fi
# Print summary
echo ""
echo "=============================================="
echo " Test Complete"
echo "=============================================="
echo "Results: $OUTPUT_DIR"
echo ""
if [[ -f "$OUTPUT_DIR/summary.txt" ]]; then
cat "$OUTPUT_DIR/summary.txt"
else
echo "Logs available:"
echo " - $OUTPUT_DIR/sangamio.log"
echo " - $OUTPUT_DIR/dhruva.log"
fi