APF¶
src.python_motion_planning.controller.path_tracker.apf.APF
¶
Bases: PathTracker
Artificial Potential Field (APF) path-tracking controller.
Parameters:
Name | Type | Description | Default |
---|---|---|---|
*args
|
see the parent class. |
()
|
|
robot_model
|
BaseRobot
|
robot model for kinematic parameters |
required |
obstacle_grid
|
Grid
|
occupancy grid map for collision checking |
None
|
attr_weight
|
float
|
weight factor for attractive potential |
1.0
|
rep_weight
|
float
|
weight factor for repulsive potential |
1.0
|
rep_range
|
float
|
influence range for repulsive potential |
None
|
**kwargs
|
see the parent class. |
{}
|
Source code in src\python_motion_planning\controller\path_tracker\apf.py
Python
class APF(PathTracker):
"""
Artificial Potential Field (APF) path-tracking controller.
Args:
*args: see the parent class.
robot_model: robot model for kinematic parameters
obstacle_grid: occupancy grid map for collision checking
attr_weight: weight factor for attractive potential
rep_weight: weight factor for repulsive potential
rep_range: influence range for repulsive potential
**kwargs: see the parent class.
"""
def __init__(self,
*args,
robot_model: BaseRobot,
obstacle_grid: Grid = None,
attr_weight: float = 1.0,
rep_weight: float = 1.0,
rep_range: float = None,
**kwargs):
super().__init__(*args, **kwargs)
if robot_model.dim != self.dim:
raise ValueError("Dimension of robot model and controller must be the same")
self.robot_model = robot_model
if obstacle_grid and obstacle_grid.dim != self.dim:
raise ValueError("Dimension of obstacle grid and controller must be the same")
self.obstacle_grid = obstacle_grid
# APF parameters
self.attr_weight = attr_weight # Attractive potential weight
self.rep_weight = rep_weight # Repulsive potential weight
self.rep_range = rep_range if rep_range is not None else self.robot_model.radius * 2.0 # Repulsive influence range
def get_action(self, obs: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""
Get action from observation using Artificial Potential Field method.
Args:
obs: observation ([pos, orient, lin_vel, ang_vel])
Returns:
action: action in robot frame ([lin_acc, ang_acc])
target_pose: lookahead pose ([pos, orient])
"""
if self.goal is None:
return np.zeros(self.action_space.shape), self.goal
pose, vel, pos, orient, lin_vel, ang_vel = self.get_pose_velocity(obs)
# Find the lookahead pose as the target for attractive potential
target_pose = self._get_lookahead_pose(pos)
# Calculate potential field forces
attractive_force = self._calculate_attractive_force(pos, target_pose[:self.dim])
repulsive_force = self._calculate_repulsive_force(pos)
# Total force is the sum of attractive and repulsive forces
total_force = attractive_force + repulsive_force
# Calculate desired velocity from total force
desired_vel = self._force_to_velocity(total_force, orient)
desired_vel = self._stop_if_reached(desired_vel, pose)
# Convert current velocity to robot frame and calculate action
robot_vel = FrameTransformer.vel_world_to_robot(self.dim, vel, orient)
action = self._get_desired_action(desired_vel, robot_vel, orient)
return action, target_pose
def _calculate_attractive_force(self, current_pos: np.ndarray, target_pos: np.ndarray) -> np.ndarray:
"""
Calculate attractive force towards the target position.
Args:
current_pos: Current position of the robot in world frame
target_pos: Target position (lookahead point) in world frame
Returns:
attractive_force: Attractive force vector in world frame
"""
# Vector from current position to target position
direction = target_pos - current_pos
distance = np.linalg.norm(direction)
# If distance is very small, return zero force to avoid division issues
if distance < self.eps:
return np.zeros_like(direction)
# Normalize direction and scale by weight and distance
# For standard APF, attractive force is proportional to distance
attractive_force = self.attr_weight * direction
return attractive_force
def _calculate_repulsive_force(self, current_pos: np.ndarray) -> np.ndarray:
"""
Calculate repulsive force from obstacles using ESDF.
Args:
current_pos: Current position of the robot in world frame
Returns:
repulsive_force: Repulsive force vector in world frame
"""
if self.obstacle_grid is None:
return np.zeros(self.dim)
# Convert world position to grid coordinates
grid_pt = self.obstacle_grid.world_to_map(tuple(current_pos[:2]))
grid_x, grid_y = grid_pt
# Check if position is out of bounds or in an obstacle
if not self.obstacle_grid.within_bounds(grid_pt) or self.obstacle_grid.type_map[grid_pt] == TYPES.OBSTACLE:
# Large repulsive force if in collision
return np.full(self.dim, self.rep_weight * self.rep_range)
# Get distance to nearest obstacle from ESDF (in world units)
dist_to_obstacle = self.obstacle_grid.esdf[grid_pt] * self.obstacle_grid.resolution
# No repulsive force if outside influence range
if dist_to_obstacle >= self.rep_range:
return np.zeros(self.dim)
# Calculate gradient of repulsive potential using numpy's gradient function
# Extract a small window around current grid point to compute gradient
window_size = 3 # 3x3 window for gradient calculation
half_window = window_size // 2
# Initialize window with current distance value
window = np.zeros((window_size, window_size))
window[half_window, half_window] = self.obstacle_grid.esdf[grid_pt]
# Fill window with neighboring ESDF values, handling boundary conditions
for i in range(window_size):
for j in range(window_size):
# Calculate grid coordinates for this window position
grid_i = grid_x + (i - half_window)
grid_j = grid_y + (j - half_window)
# Check if neighboring grid point is within bounds
if self.obstacle_grid.within_bounds((grid_i, grid_j)):
window[i, j] = self.obstacle_grid.esdf[(grid_i, grid_j)]
else:
# For points outside bounds, use distance decreasing away from map
dx = abs(i - half_window)
dy = abs(j - half_window)
dist_from_center = math.sqrt(dx*dx + dy*dy)
window[i, j] = max(0, self.obstacle_grid.esdf[grid_pt] - dist_from_center)
window *= self.obstacle_grid.resolution
# Calculate gradient using numpy.gradient
# The gradient is scaled by grid resolution to get world coordinates
# TODO: Check if this is correct
# gy, gx = np.gradient(window)
gx, gy = np.gradient(window)
# The gradient at the center of the window gives the direction of maximum increase
# This is the direction away from obstacles
gradient_dir = np.array([gx[half_window, half_window], gy[half_window, half_window]])
# Normalize gradient direction
grad_mag = np.linalg.norm(gradient_dir)
if grad_mag < 1e-6:
return np.zeros(self.dim)
gradient_dir = gradient_dir / grad_mag
# Calculate repulsive force magnitude using standard APF formula
rep_force_magnitude = self.rep_weight * (1.0 / dist_to_obstacle - 1.0 / self.rep_range) / (dist_to_obstacle ** 2)
# Scale direction by repulsive force magnitude
repulsive_force = rep_force_magnitude * gradient_dir
return repulsive_force
def _force_to_velocity(self, force: np.ndarray, orient: np.ndarray) -> np.ndarray:
"""
Convert force vector to desired velocity in robot frame.
Args:
force: Total force vector in world frame
orient: Current orientation in world frame
Returns:
desired_vel: Desired velocity in robot frame
"""
force_magnitude = np.linalg.norm(force)
# If force is very small, return zero velocity
if force_magnitude < self.eps:
return np.zeros(self.action_space.shape[0])
# Desired linear velocity is proportional to force magnitude
desired_lin_speed = min(force_magnitude, self.max_lin_speed)
desired_lin_dir = force / force_magnitude # Direction of force
# Desired angular velocity is based on angle difference between force direction and robot orientation
force_angle = np.array([math.atan2(desired_lin_dir[1], desired_lin_dir[0])])
angle_diff = Geometry.regularize_orient(force_angle - orient)
desired_ang_speed = min(np.linalg.norm(angle_diff) / self.dt, self.max_ang_speed)
desired_ang_speed *= np.sign(angle_diff[0]) # Preserve direction
desired_ang_speed = np.array([desired_ang_speed])
# Combine linear and angular velocity
desired_vel_world = np.concatenate([desired_lin_dir * desired_lin_speed, desired_ang_speed])
# Convert desired velocity to robot frame
desired_vel_robot = FrameTransformer.vel_world_to_robot(self.dim, desired_vel_world, orient)
return self.clip_velocity(desired_vel_robot)
get_action(obs)
¶
Get action from observation using Artificial Potential Field method.
Parameters:
Name | Type | Description | Default |
---|---|---|---|
obs
|
ndarray
|
observation ([pos, orient, lin_vel, ang_vel]) |
required |
Returns:
Name | Type | Description |
---|---|---|
action |
ndarray
|
action in robot frame ([lin_acc, ang_acc]) |
target_pose |
ndarray
|
lookahead pose ([pos, orient]) |
Source code in src\python_motion_planning\controller\path_tracker\apf.py
Python
def get_action(self, obs: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""
Get action from observation using Artificial Potential Field method.
Args:
obs: observation ([pos, orient, lin_vel, ang_vel])
Returns:
action: action in robot frame ([lin_acc, ang_acc])
target_pose: lookahead pose ([pos, orient])
"""
if self.goal is None:
return np.zeros(self.action_space.shape), self.goal
pose, vel, pos, orient, lin_vel, ang_vel = self.get_pose_velocity(obs)
# Find the lookahead pose as the target for attractive potential
target_pose = self._get_lookahead_pose(pos)
# Calculate potential field forces
attractive_force = self._calculate_attractive_force(pos, target_pose[:self.dim])
repulsive_force = self._calculate_repulsive_force(pos)
# Total force is the sum of attractive and repulsive forces
total_force = attractive_force + repulsive_force
# Calculate desired velocity from total force
desired_vel = self._force_to_velocity(total_force, orient)
desired_vel = self._stop_if_reached(desired_vel, pose)
# Convert current velocity to robot frame and calculate action
robot_vel = FrameTransformer.vel_world_to_robot(self.dim, vel, orient)
action = self._get_desired_action(desired_vel, robot_vel, orient)
return action, target_pose