feat(bringup): scan height filter with IMU pitch compensation (Issue #211)
Two files added to saltybot_bringup: - _scan_height_filter.py: pure-Python helpers (no rclpy) — filter_scan_by_height() projects each LIDAR ray to world-frame height using pitch/roll from the IMU and filters ground/ceiling returns; pitch_roll_from_accel() uses convention-agnostic atan2 formula; AttitudeEstimator low-pass filters the accelerometer attitude. - scan_height_filter_node.py: subscribes /scan + /camera/imu, publishes /scan_filtered (LaserScan) for Nav2 at source rate (up to 20 Hz). setup.py: adds scan_height_filter entry point. 18/18 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d1a4008451
commit
b722279739
@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
_scan_height_filter.py — Pure-Python height filter for 2D LIDAR scans (no ROS2 deps).
|
||||||
|
|
||||||
|
Given pitch/roll angles (from IMU accelerometer) and the LIDAR's mount height
|
||||||
|
above the floor, marks scan rays whose computed world-frame height falls outside
|
||||||
|
[z_min, z_max] as invalid (range = inf).
|
||||||
|
|
||||||
|
Coordinate convention (ROS REP-103):
|
||||||
|
x = forward
|
||||||
|
y = left
|
||||||
|
z = up
|
||||||
|
pitch > 0 = nose down (front lower than rear)
|
||||||
|
roll > 0 = left side up (robot leaning right)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def filter_scan_by_height(
|
||||||
|
ranges: List[float],
|
||||||
|
angle_min: float,
|
||||||
|
angle_increment: float,
|
||||||
|
lidar_height_m: float,
|
||||||
|
pitch_rad: float,
|
||||||
|
roll_rad: float,
|
||||||
|
z_min_m: float = 0.05,
|
||||||
|
z_max_m: float = 2.0,
|
||||||
|
range_min: float = 0.0,
|
||||||
|
range_max: float = float('inf'),
|
||||||
|
) -> List[float]:
|
||||||
|
"""
|
||||||
|
Filter a 2D LaserScan by estimated point height in the world frame.
|
||||||
|
|
||||||
|
For each ray at bearing angle θ and range r, the world-frame height is:
|
||||||
|
|
||||||
|
z = lidar_height + r·cos(θ)·sin(pitch) − r·sin(θ)·sin(roll)
|
||||||
|
|
||||||
|
Rays whose estimated z falls outside [z_min_m, z_max_m] are set to inf
|
||||||
|
(Nav2 treats inf as "no obstacle" / max range).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ranges : raw scan ranges; inf / nan = no-return
|
||||||
|
angle_min : bearing of first ray (rad)
|
||||||
|
angle_increment : per-step bearing increment (rad)
|
||||||
|
lidar_height_m : height of the LIDAR above the floor (m)
|
||||||
|
pitch_rad : robot pitch angle (rad)
|
||||||
|
roll_rad : robot roll angle (rad)
|
||||||
|
z_min_m : minimum valid height (m); below → ground plane → filtered
|
||||||
|
z_max_m : maximum valid height (m); above → ceiling / out-of-range → filtered
|
||||||
|
range_min : minimum valid range from LaserScan message
|
||||||
|
range_max : maximum valid range from LaserScan message
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
List[float] filtered ranges — invalid points set to inf
|
||||||
|
"""
|
||||||
|
out: List[float] = []
|
||||||
|
sin_pitch = math.sin(pitch_rad)
|
||||||
|
sin_roll = math.sin(roll_rad)
|
||||||
|
|
||||||
|
for i, r in enumerate(ranges):
|
||||||
|
# Pass through existing invalid readings unchanged
|
||||||
|
if math.isinf(r) or math.isnan(r) or r <= range_min or r >= range_max:
|
||||||
|
out.append(r)
|
||||||
|
continue
|
||||||
|
|
||||||
|
angle = angle_min + i * angle_increment
|
||||||
|
x_laser = r * math.cos(angle) # forward component in laser frame
|
||||||
|
y_laser = r * math.sin(angle) # lateral component in laser frame
|
||||||
|
|
||||||
|
# Estimated world-frame height of this scan point
|
||||||
|
z_world = lidar_height_m + x_laser * sin_pitch - y_laser * sin_roll
|
||||||
|
|
||||||
|
out.append(float('inf') if z_world < z_min_m or z_world > z_max_m else r)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_roll_from_accel(ax: float, ay: float, az: float) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Estimate pitch and roll from raw accelerometer readings (static / low-dynamics).
|
||||||
|
|
||||||
|
Uses the gravity vector projected onto each tilt axis. The denominator
|
||||||
|
sqrt(ay²+az²) / sqrt(ax²+az²) is always non-negative, so the result is
|
||||||
|
independent of the sign convention for the Z axis (works for both
|
||||||
|
z-up and z-down IMU frames).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ax, ay, az : accelerometer components (m/s²) in body / IMU frame.
|
||||||
|
Convention-agnostic: works for both az ≈ +g and az ≈ −g at rest.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
(pitch_rad, roll_rad)
|
||||||
|
pitch : rotation around Y axis; positive when nose is lower than tail
|
||||||
|
roll : rotation around X axis; positive when left side is higher
|
||||||
|
"""
|
||||||
|
pitch = math.atan2(-ax, math.sqrt(ay * ay + az * az))
|
||||||
|
roll = math.atan2( ay, math.sqrt(ax * ax + az * az))
|
||||||
|
return pitch, roll
|
||||||
|
|
||||||
|
|
||||||
|
class AttitudeEstimator:
|
||||||
|
"""
|
||||||
|
Single-pole low-pass filter applied to pitch and roll derived from
|
||||||
|
accelerometer readings. Smooths out vibration noise while remaining
|
||||||
|
responsive to genuine tilt changes.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
alpha : float (0 < alpha < 1)
|
||||||
|
Smoothing factor. Higher = faster response, less filtering.
|
||||||
|
alpha = 1 − exp(−dt / τ) for desired time constant τ.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, alpha: float = 0.1):
|
||||||
|
self._alpha = alpha
|
||||||
|
self._pitch = 0.0
|
||||||
|
self._roll = 0.0
|
||||||
|
self._initialised = False
|
||||||
|
|
||||||
|
def update(self, ax: float, ay: float, az: float) -> Tuple[float, float]:
|
||||||
|
"""Update filter with new accelerometer sample; return (pitch, roll)."""
|
||||||
|
pitch_raw, roll_raw = pitch_roll_from_accel(ax, ay, az)
|
||||||
|
if not self._initialised:
|
||||||
|
self._pitch = pitch_raw
|
||||||
|
self._roll = roll_raw
|
||||||
|
self._initialised = True
|
||||||
|
else:
|
||||||
|
self._pitch += self._alpha * (pitch_raw - self._pitch)
|
||||||
|
self._roll += self._alpha * (roll_raw - self._roll)
|
||||||
|
return self._pitch, self._roll
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pitch(self) -> float:
|
||||||
|
return self._pitch
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roll(self) -> float:
|
||||||
|
return self._roll
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
scan_height_filter_node.py — Ground-plane LIDAR filter with IMU pitch compensation.
|
||||||
|
|
||||||
|
Subscribes:
|
||||||
|
/scan sensor_msgs/LaserScan raw RPLIDAR A1M8 scan (~5.5 Hz)
|
||||||
|
/camera/imu sensor_msgs/Imu raw D435i IMU (~200 Hz)
|
||||||
|
|
||||||
|
Publishes:
|
||||||
|
/scan_filtered sensor_msgs/LaserScan ground-filtered scan for Nav2
|
||||||
|
|
||||||
|
Algorithm
|
||||||
|
---------
|
||||||
|
For each incoming LaserScan, the current robot pitch and roll are estimated
|
||||||
|
from the D435i accelerometer (low-pass filtered to suppress vibration).
|
||||||
|
|
||||||
|
Each ray at bearing θ and range r is assigned an estimated world-frame height:
|
||||||
|
|
||||||
|
z = lidar_height + r·cos(θ)·sin(pitch) − r·sin(θ)·sin(roll)
|
||||||
|
|
||||||
|
Rays with z < z_min (ground plane) or z > z_max (ceiling/spurious) are set to
|
||||||
|
inf and are therefore treated by Nav2 as free space.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
lidar_height_m float 0.10 LIDAR mount height above floor (m)
|
||||||
|
z_min_m float 0.05 Minimum valid obstacle height (m)
|
||||||
|
z_max_m float 2.00 Maximum valid obstacle height (m)
|
||||||
|
imu_alpha float 0.10 Accelerometer low-pass factor (0–1)
|
||||||
|
imu_topic str /camera/imu
|
||||||
|
scan_topic str /scan
|
||||||
|
filtered_topic str /scan_filtered
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
|
||||||
|
|
||||||
|
from sensor_msgs.msg import Imu, LaserScan
|
||||||
|
|
||||||
|
from ._scan_height_filter import filter_scan_by_height, AttitudeEstimator
|
||||||
|
|
||||||
|
|
||||||
|
_SENSOR_QOS = QoSProfile(
|
||||||
|
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||||
|
history=HistoryPolicy.KEEP_LAST,
|
||||||
|
depth=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScanHeightFilterNode(Node):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('scan_height_filter')
|
||||||
|
|
||||||
|
self.declare_parameter('lidar_height_m', 0.10)
|
||||||
|
self.declare_parameter('z_min_m', 0.05)
|
||||||
|
self.declare_parameter('z_max_m', 2.00)
|
||||||
|
self.declare_parameter('imu_alpha', 0.10)
|
||||||
|
self.declare_parameter('imu_topic', '/camera/imu')
|
||||||
|
self.declare_parameter('scan_topic', '/scan')
|
||||||
|
self.declare_parameter('filtered_topic', '/scan_filtered')
|
||||||
|
|
||||||
|
self._lidar_h = self.get_parameter('lidar_height_m').value
|
||||||
|
self._z_min = self.get_parameter('z_min_m').value
|
||||||
|
self._z_max = self.get_parameter('z_max_m').value
|
||||||
|
alpha = self.get_parameter('imu_alpha').value
|
||||||
|
imu_topic = self.get_parameter('imu_topic').value
|
||||||
|
scan_topic = self.get_parameter('scan_topic').value
|
||||||
|
filtered_topic = self.get_parameter('filtered_topic').value
|
||||||
|
|
||||||
|
self._attitude = AttitudeEstimator(alpha=alpha)
|
||||||
|
|
||||||
|
self.create_subscription(Imu, imu_topic, self._on_imu, _SENSOR_QOS)
|
||||||
|
self.create_subscription(LaserScan, scan_topic, self._on_scan, _SENSOR_QOS)
|
||||||
|
|
||||||
|
self._pub = self.create_publisher(LaserScan, filtered_topic, 10)
|
||||||
|
|
||||||
|
self.get_logger().info(
|
||||||
|
f'scan_height_filter ready — '
|
||||||
|
f'lidar_h={self._lidar_h}m z=[{self._z_min},{self._z_max}]m '
|
||||||
|
f'imu_alpha={alpha}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Callbacks ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_imu(self, msg: Imu) -> None:
|
||||||
|
a = msg.linear_acceleration
|
||||||
|
self._attitude.update(a.x, a.y, a.z)
|
||||||
|
|
||||||
|
def _on_scan(self, msg: LaserScan) -> None:
|
||||||
|
filtered_ranges = filter_scan_by_height(
|
||||||
|
ranges = list(msg.ranges),
|
||||||
|
angle_min = msg.angle_min,
|
||||||
|
angle_increment = msg.angle_increment,
|
||||||
|
lidar_height_m = self._lidar_h,
|
||||||
|
pitch_rad = self._attitude.pitch,
|
||||||
|
roll_rad = self._attitude.roll,
|
||||||
|
z_min_m = self._z_min,
|
||||||
|
z_max_m = self._z_max,
|
||||||
|
range_min = msg.range_min,
|
||||||
|
range_max = msg.range_max,
|
||||||
|
)
|
||||||
|
|
||||||
|
out = LaserScan()
|
||||||
|
out.header = msg.header
|
||||||
|
out.angle_min = msg.angle_min
|
||||||
|
out.angle_max = msg.angle_max
|
||||||
|
out.angle_increment = msg.angle_increment
|
||||||
|
out.time_increment = msg.time_increment
|
||||||
|
out.scan_time = msg.scan_time
|
||||||
|
out.range_min = msg.range_min
|
||||||
|
out.range_max = msg.range_max
|
||||||
|
out.ranges = filtered_ranges
|
||||||
|
out.intensities = list(msg.intensities) if msg.intensities else []
|
||||||
|
self._pub.publish(out)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = ScanHeightFilterNode()
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -30,6 +30,7 @@ setup(
|
|||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'depth_confidence_filter = saltybot_bringup.depth_confidence_filter_node:main',
|
'depth_confidence_filter = saltybot_bringup.depth_confidence_filter_node:main',
|
||||||
'camera_health_monitor = saltybot_bringup.camera_health_node:main',
|
'camera_health_monitor = saltybot_bringup.camera_health_node:main',
|
||||||
|
'scan_height_filter = saltybot_bringup.scan_height_filter_node:main',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
test_scan_height_filter.py — Unit tests for scan height filter helpers (no ROS2 required).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- pitch_roll_from_accel: accelerometer → pitch/roll angles
|
||||||
|
- filter_scan_by_height: height-based ray filtering
|
||||||
|
- AttitudeEstimator: low-pass pitch/roll smoother
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
from saltybot_bringup._scan_height_filter import (
|
||||||
|
pitch_roll_from_accel,
|
||||||
|
filter_scan_by_height,
|
||||||
|
AttitudeEstimator,
|
||||||
|
)
|
||||||
|
|
||||||
|
_G = 9.81 # m/s²
|
||||||
|
|
||||||
|
|
||||||
|
# ── pitch_roll_from_accel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestPitchRollFromAccel:
|
||||||
|
|
||||||
|
def test_level_robot_zero_pitch_roll(self):
|
||||||
|
# Both z-up (az=-g) and z-down (az=+g) conventions should give 0,0
|
||||||
|
for az in (-_G, +_G):
|
||||||
|
pitch, roll = pitch_roll_from_accel(0.0, 0.0, az)
|
||||||
|
assert pitch == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert roll == pytest.approx(0.0, abs=1e-6)
|
||||||
|
|
||||||
|
def test_nose_down_positive_pitch(self):
|
||||||
|
# Nose down 10°: ax component of gravity = -g·sin(10°)
|
||||||
|
angle = math.radians(10.0)
|
||||||
|
ax = -_G * math.sin(angle)
|
||||||
|
az = _G * math.cos(angle) # z-down convention
|
||||||
|
pitch, _ = pitch_roll_from_accel(ax, 0.0, az)
|
||||||
|
assert pitch == pytest.approx(angle, abs=1e-4)
|
||||||
|
|
||||||
|
def test_roll_right_positive_roll(self):
|
||||||
|
# 15° roll (left side up): ay component of gravity = g·sin(15°)
|
||||||
|
angle = math.radians(15.0)
|
||||||
|
ay = _G * math.sin(angle)
|
||||||
|
az = _G * math.cos(angle) # z-down convention
|
||||||
|
_, roll = pitch_roll_from_accel(0.0, ay, az)
|
||||||
|
assert roll == pytest.approx(angle, abs=1e-4)
|
||||||
|
|
||||||
|
def test_roll_sign_convention(self):
|
||||||
|
# Negative ay (right side up) → negative roll
|
||||||
|
_, roll = pitch_roll_from_accel(0.0, -1.0, _G)
|
||||||
|
assert roll < 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── filter_scan_by_height ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestFilterScanByHeight:
|
||||||
|
|
||||||
|
def _flat_scan(self, n: int = 360, r: float = 1.0):
|
||||||
|
"""Uniform range scan, 360°."""
|
||||||
|
angle_min = -math.pi
|
||||||
|
angle_inc = 2 * math.pi / n
|
||||||
|
return [r] * n, angle_min, angle_inc
|
||||||
|
|
||||||
|
def test_level_robot_no_filtering(self):
|
||||||
|
"""Flat ground, no pitch/roll: all points at lidar_height → kept."""
|
||||||
|
ranges, amin, ainc = self._flat_scan(r=2.0)
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, amin, ainc,
|
||||||
|
lidar_height_m=0.10, pitch_rad=0.0, roll_rad=0.0,
|
||||||
|
z_min_m=0.05, z_max_m=3.0,
|
||||||
|
)
|
||||||
|
assert all(o == 2.0 for o in out)
|
||||||
|
|
||||||
|
def test_forward_ray_filtered_when_pitched_down(self):
|
||||||
|
"""
|
||||||
|
Robot pitched nose-down 45°: forward ray (θ=0) at r=0.10m hits ground.
|
||||||
|
z = 0.10 + 0.10*cos(0)*sin(45°) = 0.10 + 0.0707 = 0.1707 -- NOT filtered
|
||||||
|
Make it shorter so it DOES hit ground:
|
||||||
|
r=0.05: z = 0.10 + 0.05*sin(45°) = 0.10 + 0.0354 = 0.135 -- still not
|
||||||
|
Actually: ground filter z < 0.05. Let's use pitch = -45° (nose up) so
|
||||||
|
forward ray dips below floor:
|
||||||
|
pitch = -45°: z = 0.10 + r*cos(0)*sin(-45°) = 0.10 - r*0.707
|
||||||
|
For r=0.15: z = 0.10 - 0.106 = -0.006 < 0.05 → filtered
|
||||||
|
"""
|
||||||
|
pitch = -math.radians(45.0) # nose UP → forward ray height decreases
|
||||||
|
ranges = [float('inf')] * 360
|
||||||
|
# θ=0 ray: angle_min = -π, index 180 ≈ θ=0
|
||||||
|
ranges[180] = 0.15
|
||||||
|
angle_inc = 2 * math.pi / 360
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, -math.pi, angle_inc,
|
||||||
|
lidar_height_m=0.10, pitch_rad=pitch, roll_rad=0.0,
|
||||||
|
z_min_m=0.05, z_max_m=3.0,
|
||||||
|
)
|
||||||
|
assert math.isinf(out[180])
|
||||||
|
|
||||||
|
def test_rear_ray_kept_when_pitched_down(self):
|
||||||
|
"""
|
||||||
|
With pitch=-45° (nose up), rear ray (θ=π) has z = lidar_h + r*cos(π)*sin(pitch)
|
||||||
|
= 0.10 + 0.15*(-1)*sin(-45°) = 0.10 + 0.106 = 0.206 → kept
|
||||||
|
"""
|
||||||
|
pitch = -math.radians(45.0)
|
||||||
|
ranges = [float('inf')] * 360
|
||||||
|
ranges[0] = 0.15 # θ ≈ -π (rear)
|
||||||
|
angle_inc = 2 * math.pi / 360
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, -math.pi, angle_inc,
|
||||||
|
lidar_height_m=0.10, pitch_rad=pitch, roll_rad=0.0,
|
||||||
|
z_min_m=0.05, z_max_m=3.0,
|
||||||
|
)
|
||||||
|
assert not math.isinf(out[0])
|
||||||
|
|
||||||
|
def test_inf_ranges_passed_through(self):
|
||||||
|
ranges = [float('inf')] * 10
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, 0.0, 0.1,
|
||||||
|
lidar_height_m=0.10, pitch_rad=0.0, roll_rad=0.0,
|
||||||
|
)
|
||||||
|
assert all(math.isinf(o) for o in out)
|
||||||
|
|
||||||
|
def test_nan_ranges_passed_through(self):
|
||||||
|
ranges = [float('nan')]
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, 0.0, 0.1,
|
||||||
|
lidar_height_m=0.10, pitch_rad=0.0, roll_rad=0.0,
|
||||||
|
)
|
||||||
|
assert math.isnan(out[0])
|
||||||
|
|
||||||
|
def test_out_of_range_bounds_passed_through(self):
|
||||||
|
"""Points outside [range_min, range_max] are passed through unchanged."""
|
||||||
|
ranges = [0.01, 15.0] # 0.01 < range_min=0.15; 15.0 > range_max=12.0
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, 0.0, 0.1,
|
||||||
|
lidar_height_m=0.10, pitch_rad=0.0, roll_rad=0.0,
|
||||||
|
range_min=0.15, range_max=12.0,
|
||||||
|
)
|
||||||
|
assert out[0] == pytest.approx(0.01)
|
||||||
|
assert out[1] == pytest.approx(15.0)
|
||||||
|
|
||||||
|
def test_z_max_filters_high_points(self):
|
||||||
|
"""A long-range forward ray on a steep upward pitch exceeds z_max → filtered."""
|
||||||
|
pitch = math.radians(80.0) # strongly nose-down (forward goes up)
|
||||||
|
ranges = [float('inf')] * 360
|
||||||
|
ranges[180] = 5.0 # θ=0, long range
|
||||||
|
angle_inc = 2 * math.pi / 360
|
||||||
|
# z = 0.10 + 5.0 * sin(80°) = 0.10 + 4.92 = 5.02 > z_max=2.0 → filtered
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, -math.pi, angle_inc,
|
||||||
|
lidar_height_m=0.10, pitch_rad=pitch, roll_rad=0.0,
|
||||||
|
z_min_m=0.05, z_max_m=2.0,
|
||||||
|
)
|
||||||
|
assert math.isinf(out[180])
|
||||||
|
|
||||||
|
def test_output_length_equals_input(self):
|
||||||
|
ranges = [1.0] * 100
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, -math.pi, 2 * math.pi / 100,
|
||||||
|
lidar_height_m=0.10, pitch_rad=0.0, roll_rad=0.0,
|
||||||
|
)
|
||||||
|
assert len(out) == 100
|
||||||
|
|
||||||
|
def test_zero_pitch_roll_preserves_valid_points(self):
|
||||||
|
"""With no tilt, all finite ranges at lidar_height (0.10m) are kept."""
|
||||||
|
ranges = [1.0, 2.0, 3.0]
|
||||||
|
out = filter_scan_by_height(
|
||||||
|
ranges, 0.0, 0.5,
|
||||||
|
lidar_height_m=0.10, pitch_rad=0.0, roll_rad=0.0,
|
||||||
|
z_min_m=0.05, z_max_m=3.0,
|
||||||
|
range_min=0.0, range_max=12.0,
|
||||||
|
)
|
||||||
|
assert out == ranges
|
||||||
|
|
||||||
|
|
||||||
|
# ── AttitudeEstimator ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestAttitudeEstimator:
|
||||||
|
|
||||||
|
def test_first_update_initialises_exactly(self):
|
||||||
|
est = AttitudeEstimator(alpha=0.5)
|
||||||
|
pitch, roll = est.update(0.0, 0.0, _G)
|
||||||
|
assert pitch == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert roll == pytest.approx(0.0, abs=1e-6)
|
||||||
|
|
||||||
|
def test_converges_to_steady_tilt(self):
|
||||||
|
est = AttitudeEstimator(alpha=0.5)
|
||||||
|
angle = math.radians(10.0)
|
||||||
|
ax = -_G * math.sin(angle)
|
||||||
|
az = _G * math.cos(angle)
|
||||||
|
# Run many updates to converge
|
||||||
|
for _ in range(50):
|
||||||
|
est.update(ax, 0.0, az)
|
||||||
|
assert est.pitch == pytest.approx(angle, abs=1e-3)
|
||||||
|
|
||||||
|
def test_properties_accessible_after_update(self):
|
||||||
|
est = AttitudeEstimator()
|
||||||
|
est.update(0.0, 0.0, -_G)
|
||||||
|
assert isinstance(est.pitch, float)
|
||||||
|
assert isinstance(est.roll, float)
|
||||||
|
|
||||||
|
def test_initial_pitch_roll_zero_before_update(self):
|
||||||
|
est = AttitudeEstimator()
|
||||||
|
assert est.pitch == pytest.approx(0.0)
|
||||||
|
assert est.roll == pytest.approx(0.0)
|
||||||
|
|
||||||
|
def test_alpha_one_tracks_instantly(self):
|
||||||
|
est = AttitudeEstimator(alpha=1.0)
|
||||||
|
angle = math.radians(20.0)
|
||||||
|
ax = -_G * math.sin(angle)
|
||||||
|
az = _G * math.cos(angle)
|
||||||
|
est.update(0.0, 0.0, _G) # initialise level
|
||||||
|
est.update(ax, 0.0, az) # step to tilted angle
|
||||||
|
assert est.pitch == pytest.approx(angle, abs=1e-3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pytest.main([__file__, '-v'])
|
||||||
Loading…
x
Reference in New Issue
Block a user