sl-controls bfd291cbdd feat(controls): Geofence boundary enforcer (Issue #298)
Implements ROS2 geofence enforcer for SaltyBot with:
- Loads polygon geofence from params (list of x/y vertices)
- Subscribes to /odom for real-time robot position
- Point-in-polygon ray casting algorithm for boundary checking
- Publishes Bool on /saltybot/geofence_breach on boundary violation
- Optional enforcement flag for cmd_vel zeroing
- Configurable safety margin
- Includes 20+ unit tests for geometry and breach detection

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 21:06:58 -05:00

171 lines
5.4 KiB
Python

"""Tests for geofence boundary enforcer."""
import pytest
from nav_msgs.msg import Odometry
from geometry_msgs.msg import Point, Quaternion, Pose, PoseWithCovariance, TwistWithCovariance
import rclpy
from rclpy.time import Time
from saltybot_geofence.geofence_node import GeofenceNode
@pytest.fixture
def rclpy_fixture():
rclpy.init()
yield
rclpy.shutdown()
@pytest.fixture
def node(rclpy_fixture):
node = GeofenceNode()
yield node
node.destroy_node()
class TestInit:
def test_node_initialization(self, node):
assert node.enforce_boundary is False
assert node.margin == 0.0
assert node.inside_geofence is True
class TestVertexParsing:
def test_parse_vertices_valid(self, node):
vertices = node._parse_vertices([0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0])
assert len(vertices) == 4
assert vertices[0] == (0.0, 0.0)
assert vertices[1] == (1.0, 0.0)
def test_parse_vertices_insufficient(self, node):
vertices = node._parse_vertices([0.0, 0.0, 1.0])
assert len(vertices) == 0
class TestPointInPolygon:
def test_point_inside_square(self, node):
vertices = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]
assert node._point_in_polygon((1.0, 1.0), vertices) is True
def test_point_outside_square(self, node):
vertices = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]
assert node._point_in_polygon((3.0, 1.0), vertices) is False
def test_point_on_vertex(self, node):
vertices = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]
# Point on vertex behavior may vary (typically outside)
result = node._point_in_polygon((0.0, 0.0), vertices)
assert isinstance(result, bool)
def test_point_on_edge(self, node):
vertices = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]
# Point on edge behavior (typically outside)
result = node._point_in_polygon((1.0, 0.0), vertices)
assert isinstance(result, bool)
def test_triangle_inside(self, node):
vertices = [(0.0, 0.0), (4.0, 0.0), (2.0, 3.0)]
assert node._point_in_polygon((2.0, 1.0), vertices) is True
def test_triangle_outside(self, node):
vertices = [(0.0, 0.0), (4.0, 0.0), (2.0, 3.0)]
assert node._point_in_polygon((5.0, 5.0), vertices) is False
def test_concave_polygon(self, node):
# L-shaped polygon
vertices = [(0.0, 0.0), (3.0, 0.0), (3.0, 1.0), (1.0, 1.0), (1.0, 3.0), (0.0, 3.0)]
assert node._point_in_polygon((0.5, 0.5), vertices) is True
assert node._point_in_polygon((2.0, 2.0), vertices) is False
def test_circle_approximation(self, node):
# Octagon approximating circle
import math
vertices = []
for i in range(8):
angle = 2 * math.pi * i / 8
vertices.append((math.cos(angle), math.sin(angle)))
# Center should be inside
assert node._point_in_polygon((0.0, 0.0), vertices) is True
# Far outside should be outside
assert node._point_in_polygon((10.0, 10.0), vertices) is False
class TestOdometryProcessing:
def test_odometry_update_position(self, node):
node.geofence_vertices = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
msg = Odometry()
msg.pose.pose.position.x = 5.0
msg.pose.pose.position.y = 5.0
node._on_odometry(msg)
assert node.robot_x == 5.0
assert node.robot_y == 5.0
def test_breach_detection_inside(self, node):
node.geofence_vertices = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
msg = Odometry()
msg.pose.pose.position.x = 5.0
msg.pose.pose.position.y = 5.0
node._on_odometry(msg)
assert node.inside_geofence is True
def test_breach_detection_outside(self, node):
node.geofence_vertices = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
msg = Odometry()
msg.pose.pose.position.x = 15.0
msg.pose.pose.position.y = 5.0
node._on_odometry(msg)
assert node.inside_geofence is False
def test_breach_flag_transition(self, node):
node.geofence_vertices = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
assert node.breach_published is False
# Move outside
msg = Odometry()
msg.pose.pose.position.x = 15.0
msg.pose.pose.position.y = 5.0
node._on_odometry(msg)
assert node.breach_published is True
# Move back inside
msg.pose.pose.position.x = 5.0
msg.pose.pose.position.y = 5.0
node._on_odometry(msg)
assert node.breach_published is False
class TestEdgeCases:
def test_no_geofence_defined(self, node):
node.geofence_vertices = []
msg = Odometry()
msg.pose.pose.position.x = 0.0
msg.pose.pose.position.y = 0.0
node._on_odometry(msg)
# Should default to safe (inside)
assert node.inside_geofence is True
def test_very_small_polygon(self, node):
vertices = [(0.0, 0.0), (0.01, 0.0), (0.01, 0.01), (0.0, 0.01)]
assert node._point_in_polygon((0.005, 0.005), vertices) is True
assert node._point_in_polygon((0.1, 0.1), vertices) is False
def test_large_coordinates(self, node):
vertices = [(0.0, 0.0), (1000.0, 0.0), (1000.0, 1000.0), (0.0, 1000.0)]
assert node._point_in_polygon((500.0, 500.0), vertices) is True
assert node._point_in_polygon((1500.0, 500.0), vertices) is False