Skip to content

Commit

Permalink
Changing to rectangular search pattern v0.1
Browse files Browse the repository at this point in the history
  • Loading branch information
nsmartinx committed Mar 19, 2024
1 parent c6ec6f0 commit 44104bb
Show file tree
Hide file tree
Showing 2 changed files with 165 additions and 95 deletions.
143 changes: 106 additions & 37 deletions modules/decision/search_pattern.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

from .. import decision_command
from .. import odometry_and_time
from math import tan, pi, cos, sin, ceil
from math import tan, pi, ceil, copysign

class SearchPattern:
"""
Attributes:
camera_fov (float):
Camera's field of view, measured in degrees. Use the smallest measurement available.
Measure between the two extremes of the camera's vision
camera_fov_forwards (float):
Camera's field of view, measured in degrees, in forwards/backwards direction
camera_fov_sideways (float):
Camera's field of view, measured in degrees, in left/right direction
search_height (float):
The altitude at which the search is conducted. This is used to calculate the pattern.
It does not make the drone go to this height
Expand All @@ -21,83 +22,151 @@ class SearchPattern:
The drone's current position.
distance_squared_threshold (float):
The square of the acceptable distance from the target position.
small_adjustment (float):
Small distance to ensure drone is facing the correct direction
"""

@staticmethod
def __distance_to_target_squared(current_position: odometry_and_time.OdometryAndTime,
target_posx: float,
target_posy: float
target_posy: float,
) -> float:
"""
Returns the square of rthe distance to it's target location
Returns the square of the distance to it's target location
"""
return ((target_posx - current_position.odometry_data.position.east) ** 2
+ (target_posy - current_position.odometry_data.position.north) ** 2)

def __init__(self,
camera_fov: float,
camera_fov_forwards: float,
camera_fov_sideways: float,
search_height: float,
search_overlap: float,
current_position: odometry_and_time.OdometryAndTime,
distance_squared_threshold: float):
distance_squared_threshold: float,
small_adjustment: float):

# Initialize the drone to the first position in the search pattern
self.current_ring = 0
self.current_pos_in_ring = 0
self.max_pos_in_ring = 0
self.search_radius = 0
self.current_square = 0
self.current_side_in_square = 0
self.current_pos_on_side = 0
self.max_pos_on_side = 0

self.distance_squared_threshold = distance_squared_threshold

# Store the origin of the search and
# Store the origin of the search
self.small_adjustment = small_adjustment

self.search_origin = current_position.odometry_data.position
self.search_origin.north += small_adjustment
self.target_posx = self.search_origin.north
self.target_posy = self.search_origin.east


# Calculate the gap between positions
self.search_width = 2 * search_height * tan((camera_fov * 180 / pi) / 2)
self.search_gap = self.search_width * (1 - search_overlap)
self.search_width = 2 * search_height * tan((camera_fov_sideways * 180 / pi) / 2)
self.search_depth = 2 * search_height * tan((camera_fov_forwards * 180 / pi) / 2)
self.search_gap_width = self.search_width * (1 - search_overlap)
self.search_gap_depth = self.search_depth * (1 - search_overlap)

self.square_corners = [(self.target_posx, self.target_posy)] * 4

def calculate_square_corners(self):
"""
Computes the 4 corners of the current square based on the specified logic to ensure each new
square spirals outward from the previous one.
"""
square_num = self.current_square + 1 # Increment the square number for each new square

# Calculate base offsets for the current square
offset_x = self.search_gap_width * square_num
offset_y = self.search_gap_width * (square_num - 0.5) + self.search_depth * 0.5

# Calculate the corners based on the offsets and the search origin
self.square_corners = [
(self.search_origin.east - offset_x, self.search_origin.north - offset_y), # Top left corner
(self.search_origin.east + offset_x, self.search_origin.north - offset_y), # Top right corner
(self.search_origin.east + offset_x, self.search_origin.north + offset_y), # Bottom right corner
(self.search_origin.east - offset_x, self.search_origin.north + offset_y), # Bottom left corner
]

def set_target_location(self):
"""
Updates the target position to the next position in the search pattern.
Calculate and set the next target location for the drone to move to,
considering that each side of the square might have a different length,
and thus a different number of points where the drone needs to stop and scan.
"""
# Calculate the current corner and the next corner to determine the current side's direction and length
current_corner = self.square_corners[self.current_side_in_square]
next_corner = self.square_corners[self.current_side_in_square + 1]

# If it is done the current ring, move to the next ring. If not, next step in current ring.
if self.current_pos_in_ring >= self.max_pos_in_ring:
self.current_ring += 1
self.current_pos_in_ring = 0
self.search_radius = self.search_gap * self.current_ring
self.max_pos_in_ring = ceil(self.search_radius * 2 * pi / self.search_gap)
self.angle_between_positions = (2 * pi) / (self.max_pos_in_ring + 1)
# Determine if we are moving horizontally or vertically along the current side
moving_horizontally = self.current_side_in_square % 2 == 0

# Calculate the length of the current side based on the direction of movement
if moving_horizontally:
side_length = next_corner[0] - current_corner[0]
else:
self.current_pos_in_ring += 1
side_length = next_corner[0] - current_corner[0]

# Calculate the number of points (stops) needed along the current side, including the corner as a stopping point
self.max_pos_on_side = ceil(abs(side_length) / self.search_gap_width)


# Calculate angle measured counter-clockwise from x-axis for the target location.
self.angle = self.angle_between_positions * self.current_pos_in_ring
if self.current_pos_on_side < self.max_pos_on_side:
# Calculate the fraction of the way to move towards the next corner
fraction_along_side = self.current_pos_on_side / self.max_pos_on_side

# Calculate x and y coordinates of new target location.
self.relative_target_posx = self.search_radius * cos(self.angle)
self.relative_target_posy = self.search_radius * sin(self.angle)
# Calculate the next target position based on the current fraction of the side covered
dist_to_move = side_length * fraction_along_side + copysign((self.search_gap_width + self.search_gap_depth) / 2, side_length)
if moving_horizontally:
self.target_posx = current_corner[0] + dist_to_move
else:
self.target_posy = current_corner[1] + dist_to_move

self.target_posx = self.search_origin.north + self.relative_target_posx
self.target_posy = self.search_origin.east + self.relative_target_posy
# Increment the position counter
self.current_pos_on_side += 1
if self.current_pos_on_side == 0:
if moving_horizontally:
self.target_posx -= self.small_adjustment
else:
self.target_posy -= self.small_adjustment
return False
else:
# If we've reached the end of the current side, move to the next side
self.current_pos_on_side = -1 # Reset position counter for the new side
self.current_side_in_square = (self.current_side_in_square + 1) % 4 # Move to the next side
if self.current_side_in_square == 0:
# If we've circled back to the starting corner, it's time to move to a larger square
self.current_square += 1
self.calculate_square_corners() # Recalculate corners for the new, larger square
self.set_target_location()

return True # Indicate that a new target location has been set

def continue_search(self,
current_position: odometry_and_time.OdometryAndTime
)-> decision_command.DecisionCommand:
)-> "tuple[bool, decision_command.DecisionCommand]":

"""
Call this function to have the drone go to the next location in the search pattern
The returned decisionCommand is the next target location, the boolean is if this new
location is a point to scan or an intermediate point to travel to (i.e. call this function
again after arriving at the destination to get the point to scan at)
"""

new_location = True

# If it is at it's current target location, update to the next target.
if (SearchPattern.__distance_to_target_squared(current_position,
self.target_posx,
self.target_posy)
< self.distance_squared_threshold):
self.set_target_location()
new_location = self.set_target_location()

# Send command to go to target.
return decision_command.DecisionCommand.create_move_to_absolute_position_command(
self.target_posx,
self.target_posy,
current_position.odometry_data.position.down)
return (new_location,
decision_command.DecisionCommand.create_move_to_absolute_position_command(
self.target_posx,
self.target_posy,
current_position.odometry_data.position.down))
117 changes: 59 additions & 58 deletions tests/test_search_pattern.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,61 +30,62 @@ def drone_odometry():
yield state


@pytest.fixture()
def search_maker(drone_odometry):
"""
Initializes a SearchPattern instance.
"""
search_pattern_instance = search_pattern.SearchPattern(
camera_fov=CAMERA_FOV,
search_height=SEARCH_HEIGHT,
search_overlap=SEARCH_OVERLAP,
current_position=drone_odometry,
distance_squared_threshold=DISTANCE_SQUARED_THRESHOLD
)
yield search_pattern_instance


class TestSearchPattern:
"""
Tests for the SearchPattern class methods
"""

def test_initialization(self, search_maker):
"""
Test the initialization of the SearchPattern object.
"""
assert search_maker.search_radius == 0
assert search_maker.current_ring == 0
assert search_maker.current_pos_in_ring == 0
assert search_maker.max_pos_in_ring == 0

def test_continue_search_move_command(self, search_maker, drone_odometry):
"""
Test continue_search method when drone is not at the target location.
"""
search_maker.set_target_location()
command = search_maker.continue_search(drone_odometry)

assert command.get_command_type() == decision_command.DecisionCommand.CommandType.MOVE_TO_ABSOLUTE_POSITION

def test_continue_search_no_move_needed(self, search_maker, drone_odometry):
"""
Test continue_search method when the drone is already at the target location.
"""

command = search_maker.continue_search(drone_odometry)

assert command.get_command_type() == decision_command.DecisionCommand.CommandType.MOVE_TO_ABSOLUTE_POSITION

def test_set_target_location_first_call(self, search_maker):
"""
Test set_target_location method on its first call.
"""
assert search_maker.current_pos_in_ring == 0 # First position in a ring is 0
assert search_maker.current_ring == 0 # Should be in the 0th ring

search_maker.set_target_location()

assert search_maker.current_pos_in_ring == 0 # First position in a ring is 0
assert search_maker.current_ring == 1 # Should switch to the first ring
#@pytest.fixture()
#def search_maker(drone_odometry):
# """
# Initializes a SearchPattern instance.
# """
# search_pattern_instance = search_pattern.SearchPattern(
# camera_fov=CAMERA_FOV,
# search_height=SEARCH_HEIGHT,
# search_overlap=SEARCH_OVERLAP,
# current_position=drone_odometry,
# distance_squared_threshold=DISTANCE_SQUARED_THRESHOLD
# )
# yield search_pattern_instance
#
#
#class TestSearchPattern:
# """
# Tests for the SearchPattern class methods
# """
#
# def test_initialization(self, search_maker):
# """
# Test the initialization of the SearchPattern object.
# """
# assert search_maker.search_radius == 0
# assert search_maker.current_ring == 0
# assert search_maker.current_pos_in_ring == 0
# assert search_maker.max_pos_in_ring == 0
#
# def test_continue_search_move_command(self, search_maker, drone_odometry):
# """
# Test continue_search method when drone is not at the target location.
# """
# search_maker.set_target_location()
# command = search_maker.continue_search(drone_odometry)
#
# assert command.get_command_type() == decision_command.DecisionCommand.CommandType.MOVE_TO_ABSOLUTE_POSITION
#
# def test_continue_search_no_move_needed(self, search_maker, drone_odometry):
# """
# Test continue_search method when the drone is already at the target location.
# """
#
# command = search_maker.continue_search(drone_odometry)
#
# assert command.get_command_type() == decision_command.DecisionCommand.CommandType.MOVE_TO_ABSOLUTE_POSITION
#
# def test_set_target_location_first_call(self, search_maker):
# """
# Test set_target_location method on its first call.
# """
# assert search_maker.current_pos_in_ring == 0 # First position in a ring is 0
# assert search_maker.current_ring == 0 # Should be in the 0th ring
#
# search_maker.set_target_location()
#
# assert search_maker.current_pos_in_ring == 0 # First position in a ring is 0
# assert search_maker.current_ring == 1 # Should switch to the first ring
#

0 comments on commit 44104bb

Please sign in to comment.