feat: implement mission executor with breadcrumb system and failsafe integration
- Full mission state machine: IDLE/RUNNING/PAUSED/COMPLETE/ABORTED - Breadcrumb buffer: 1800 entries at 2s interval (1hr coverage) - Failsafe integration: HOVER=pause, RETURN_TO_SAFE=abort+breadcrumb, EMERGENCY=immediate abort - Waypoint loading from YAML mission file via LOAD command - Arrival detection with configurable radius - Image capture loiter with configurable delay - Simple proportional navigation toward waypoints (Phase 3+ replaces with full planner) - Entry point recorded at mission start for safe zone reference
This commit is contained in:
parent
9f7a775427
commit
4b1766255a
@ -1,118 +1,390 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Mission executor node.
|
Mission Executor Node — Argonaut 3
|
||||||
Manages inspection mission state machine.
|
====================================
|
||||||
Sequences waypoints, triggers image capture, handles replanning.
|
Manages inspection mission state machine per ROV_Failsafe_Design_v2.0.
|
||||||
|
|
||||||
States: IDLE → RUNNING → (PAUSED | COMPLETE | ABORTED)
|
Sequences inspection waypoints, triggers image capture, maintains the
|
||||||
|
breadcrumb position buffer used by the failsafe return-to-safe path,
|
||||||
|
and responds correctly to each failsafe action level.
|
||||||
|
|
||||||
|
States: IDLE -> RUNNING -> PAUSED | COMPLETE | ABORTED
|
||||||
|
|
||||||
|
Failsafe integration:
|
||||||
|
ACTION_HOVER -> pause mission, hold position
|
||||||
|
ACTION_RETURN_TO_SAFE -> abort mission, return via breadcrumb path
|
||||||
|
ACTION_EMERGENCY_SURFACE -> abort mission, emergency surface immediately
|
||||||
|
|
||||||
|
Breadcrumb system:
|
||||||
|
Position logged every 2s from mission start.
|
||||||
|
Circular buffer of 1800 entries (1 hour of operation).
|
||||||
|
Used by return-to-safe to reverse the entry path safely.
|
||||||
|
|
||||||
Topics subscribed:
|
Topics subscribed:
|
||||||
/rov/state (nav_msgs/Odometry) — current position
|
/rov/state (nav_msgs/Odometry) — current position
|
||||||
/rov/failsafe (rov_interfaces/FailsafeStatus) — safety override
|
/rov/failsafe (rov_interfaces/FailsafeStatus) — safety override
|
||||||
|
|
||||||
Topics published:
|
Topics published:
|
||||||
/rov/cmd_vel (geometry_msgs/Twist) — velocity setpoints to controller
|
/rov/cmd_vel (geometry_msgs/Twist) — velocity setpoints
|
||||||
/rov/mission/status (rov_interfaces/MissionStatus) — current mission state
|
/rov/mission/status (rov_interfaces/MissionStatus) — mission state
|
||||||
|
/rov/mission/waypoint (rov_interfaces/InspectionWaypoint) — current target
|
||||||
|
|
||||||
Services provided:
|
Services provided:
|
||||||
/rov/mission/command (rov_interfaces/MissionCommand) — start/pause/abort/resume
|
/rov/mission/command (rov_interfaces/MissionCommand) — start/pause/abort/resume
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import yaml
|
||||||
|
import collections
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from geometry_msgs.msg import Twist
|
from geometry_msgs.msg import Twist, Point
|
||||||
from nav_msgs.msg import Odometry
|
from nav_msgs.msg import Odometry
|
||||||
|
from std_msgs.msg import Header
|
||||||
from rov_interfaces.msg import MissionStatus, FailsafeStatus, InspectionWaypoint
|
from rov_interfaces.msg import MissionStatus, FailsafeStatus, InspectionWaypoint
|
||||||
from rov_interfaces.srv import MissionCommand
|
from rov_interfaces.srv import MissionCommand
|
||||||
|
|
||||||
|
|
||||||
class MissionExecutor(Node):
|
# ---------------------------------------------------------------------------
|
||||||
"""Executes inspection missions from a waypoint list."""
|
# Breadcrumb entry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Mission states
|
@dataclass
|
||||||
STATE_IDLE = 'IDLE'
|
class Breadcrumb:
|
||||||
STATE_RUNNING = 'RUNNING'
|
"""Single position record for the return-to-safe path."""
|
||||||
STATE_PAUSED = 'PAUSED'
|
timestamp_s: float # ROS time in seconds
|
||||||
|
x: float # metres
|
||||||
|
y: float # metres
|
||||||
|
z: float # metres (negative = depth)
|
||||||
|
heading_deg: float # degrees
|
||||||
|
confidence: float # 0.0–1.0 (placeholder until full state estimation)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Breadcrumb buffer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BreadcrumbBuffer:
|
||||||
|
"""
|
||||||
|
Circular buffer of position records logged during mission.
|
||||||
|
1800 entries at 2s interval = 1 hour of operation.
|
||||||
|
Older entries are discarded as new ones are added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAPACITY = 1800 # entries
|
||||||
|
LOG_INTERVAL_S = 2.0 # seconds between entries
|
||||||
|
MIN_CONFIDENCE = 0.3 # entries below this are flagged during reversal
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._buffer: collections.deque = collections.deque(maxlen=self.CAPACITY)
|
||||||
|
self._last_log_time: float = 0.0
|
||||||
|
|
||||||
|
def update(self, now_s: float, position: Point, heading_deg: float,
|
||||||
|
confidence: float = 1.0):
|
||||||
|
"""Log a breadcrumb if the log interval has elapsed."""
|
||||||
|
if now_s - self._last_log_time >= self.LOG_INTERVAL_S:
|
||||||
|
self._buffer.append(Breadcrumb(
|
||||||
|
timestamp_s=now_s,
|
||||||
|
x=position.x,
|
||||||
|
y=position.y,
|
||||||
|
z=position.z,
|
||||||
|
heading_deg=heading_deg,
|
||||||
|
confidence=confidence,
|
||||||
|
))
|
||||||
|
self._last_log_time = now_s
|
||||||
|
|
||||||
|
def get_return_path(self) -> List[Breadcrumb]:
|
||||||
|
"""
|
||||||
|
Return the breadcrumb buffer in reverse order for path reversal.
|
||||||
|
Entries below MIN_CONFIDENCE are filtered — skipped in favour of
|
||||||
|
the next reliable entry to reduce navigation uncertainty.
|
||||||
|
"""
|
||||||
|
reliable = [b for b in self._buffer if b.confidence >= self.MIN_CONFIDENCE]
|
||||||
|
return list(reversed(reliable))
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear all entries — called at mission start."""
|
||||||
|
self._buffer.clear()
|
||||||
|
self._last_log_time = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entry_count(self) -> int:
|
||||||
|
"""Number of entries currently in buffer."""
|
||||||
|
return len(self._buffer)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mission executor node
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MissionExecutor(Node):
|
||||||
|
"""
|
||||||
|
Executes inspection missions from a waypoint sequence.
|
||||||
|
Integrates with the failsafe monitor and maintains breadcrumb buffer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Internal mission states
|
||||||
|
STATE_IDLE = 'IDLE'
|
||||||
|
STATE_RUNNING = 'RUNNING'
|
||||||
|
STATE_PAUSED = 'PAUSED'
|
||||||
STATE_COMPLETE = 'COMPLETE'
|
STATE_COMPLETE = 'COMPLETE'
|
||||||
STATE_ABORTED = 'ABORTED'
|
STATE_ABORTED = 'ABORTED'
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('mission_executor')
|
super().__init__('mission_executor')
|
||||||
|
|
||||||
self.state = self.STATE_IDLE
|
# ------------------------------------------------------------------
|
||||||
self.waypoints = [] # List of InspectionWaypoint
|
# Parameters (from dev.yaml / field.yaml)
|
||||||
self.current_wp_index = 0
|
# ------------------------------------------------------------------
|
||||||
self.mission_id = ''
|
|
||||||
self.current_position = None
|
|
||||||
self.failsafe_active = False
|
|
||||||
|
|
||||||
# --- Subscribers ---
|
self.declare_parameter('waypoint_arrival_radius_m', 0.5)
|
||||||
self.state_sub = self.create_subscription(
|
self.declare_parameter('waypoint_timeout_s', 60.0)
|
||||||
Odometry, '/rov/state', self.state_callback, 10)
|
self.declare_parameter('image_capture_delay_s', 1.0)
|
||||||
self.failsafe_sub = self.create_subscription(
|
|
||||||
FailsafeStatus, '/rov/failsafe', self.failsafe_callback, 10)
|
|
||||||
|
|
||||||
# --- Publishers ---
|
self._arrival_radius = self.get_parameter('waypoint_arrival_radius_m').value
|
||||||
self.cmd_pub = self.create_publisher(Twist, '/rov/cmd_vel', 10)
|
self._wp_timeout_s = self.get_parameter('waypoint_timeout_s').value
|
||||||
self.status_pub = self.create_publisher(MissionStatus, '/rov/mission/status', 10)
|
self._capture_delay_s = self.get_parameter('image_capture_delay_s').value
|
||||||
|
|
||||||
# --- Services ---
|
# ------------------------------------------------------------------
|
||||||
self.cmd_srv = self.create_service(
|
# Mission state
|
||||||
MissionCommand, '/rov/mission/command', self.command_callback)
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
# --- Timer: mission loop at 10Hz ---
|
self.state = self.STATE_IDLE
|
||||||
self.timer = self.create_timer(0.1, self.mission_loop)
|
self.mission_id = ''
|
||||||
|
self.waypoints: List[InspectionWaypoint] = []
|
||||||
|
self.current_wp_index = 0
|
||||||
|
|
||||||
self.get_logger().info('MissionExecutor node started')
|
# Entry point — set when mission starts, used for safe zone reference
|
||||||
|
self.entry_point: Optional[Point] = None
|
||||||
|
|
||||||
def state_callback(self, msg: Odometry):
|
# Operation mode — affects return-to-safe path choice
|
||||||
"""Update current position."""
|
self.operation_mode = 'tethered' # 'tethered' or 'untethered'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Sensor state
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.current_position: Optional[Point] = None
|
||||||
|
self.current_heading_deg: float = 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Failsafe state
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.last_failsafe_action = FailsafeStatus.ACTION_NONE
|
||||||
|
self.last_assessment_state = FailsafeStatus.ASSESSMENT_GREEN
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Breadcrumb buffer
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.breadcrumbs = BreadcrumbBuffer()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Waypoint timing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.wp_start_time: Optional[float] = None # When current wp navigation began
|
||||||
|
self.capture_start_time: Optional[float] = None # When image capture loiter began
|
||||||
|
self.capturing_image: bool = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Subscribers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.create_subscription(
|
||||||
|
Odometry, '/rov/state',
|
||||||
|
self._state_callback, 10)
|
||||||
|
|
||||||
|
self.create_subscription(
|
||||||
|
FailsafeStatus, '/rov/failsafe',
|
||||||
|
self._failsafe_callback, 10)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Publishers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.cmd_pub = self.create_publisher(
|
||||||
|
Twist, '/rov/cmd_vel', 10)
|
||||||
|
|
||||||
|
self.status_pub = self.create_publisher(
|
||||||
|
MissionStatus, '/rov/mission/status', 10)
|
||||||
|
|
||||||
|
self.waypoint_pub = self.create_publisher(
|
||||||
|
InspectionWaypoint, '/rov/mission/waypoint', 10)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Service
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.create_service(
|
||||||
|
MissionCommand, '/rov/mission/command',
|
||||||
|
self._command_callback)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Timers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Mission loop at 10 Hz
|
||||||
|
self.create_timer(0.1, self._mission_loop)
|
||||||
|
|
||||||
|
# Status publisher at 2 Hz
|
||||||
|
self.create_timer(0.5, self._publish_status)
|
||||||
|
|
||||||
|
self.get_logger().info('MissionExecutor started')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Subscriber callbacks
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _state_callback(self, msg: Odometry):
|
||||||
|
"""Update current position and heading from state estimator."""
|
||||||
self.current_position = msg.pose.pose.position
|
self.current_position = msg.pose.pose.position
|
||||||
|
|
||||||
def failsafe_callback(self, msg: FailsafeStatus):
|
# Extract heading from quaternion (yaw only — simplified)
|
||||||
"""Handle failsafe — abort mission if active."""
|
q = msg.pose.pose.orientation
|
||||||
was_active = self.failsafe_active
|
siny_cosp = 2.0 * (q.w * q.z + q.x * q.y)
|
||||||
self.failsafe_active = (msg.action != FailsafeStatus.ACTION_NONE)
|
cosy_cosp = 1.0 - 2.0 * (q.y * q.y + q.z * q.z)
|
||||||
if self.failsafe_active and not was_active:
|
self.current_heading_deg = math.degrees(math.atan2(siny_cosp, cosy_cosp))
|
||||||
self.get_logger().warn('Failsafe triggered — aborting mission')
|
|
||||||
self.state = self.STATE_ABORTED
|
|
||||||
|
|
||||||
def command_callback(self, request, response):
|
# Log breadcrumb if mission is active
|
||||||
"""Handle mission command service calls."""
|
if self.state == self.STATE_RUNNING and self.current_position is not None:
|
||||||
|
now_s = self.get_clock().now().nanoseconds / 1e9
|
||||||
|
self.breadcrumbs.update(
|
||||||
|
now_s,
|
||||||
|
self.current_position,
|
||||||
|
self.current_heading_deg,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _failsafe_callback(self, msg: FailsafeStatus):
|
||||||
|
"""
|
||||||
|
Respond to failsafe actions per design spec priority.
|
||||||
|
Different actions require different mission responses.
|
||||||
|
"""
|
||||||
|
action = msg.action
|
||||||
|
assessment_state = msg.assessment_state
|
||||||
|
|
||||||
|
# Store for use in mission loop
|
||||||
|
self.last_failsafe_action = action
|
||||||
|
self.last_assessment_state = assessment_state
|
||||||
|
|
||||||
|
# --- Emergency surface: abort immediately, no controlled return ---
|
||||||
|
if action == FailsafeStatus.ACTION_EMERGENCY_SURFACE:
|
||||||
|
if self.state not in (self.STATE_ABORTED, self.STATE_COMPLETE):
|
||||||
|
self.get_logger().error(
|
||||||
|
'EMERGENCY SURFACE commanded — aborting mission immediately')
|
||||||
|
self._abort_mission('Emergency surface — failsafe')
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Return to safe: abort mission, initiate breadcrumb reversal ---
|
||||||
|
if action == FailsafeStatus.ACTION_RETURN_TO_SAFE:
|
||||||
|
if self.state not in (self.STATE_ABORTED, self.STATE_COMPLETE):
|
||||||
|
self.get_logger().warn(
|
||||||
|
f'RETURN TO SAFE commanded — aborting mission '
|
||||||
|
f'(breadcrumbs: {self.breadcrumbs.entry_count})')
|
||||||
|
self._abort_mission('Return to safe — failsafe')
|
||||||
|
# Breadcrumb path handed off to motion controller here
|
||||||
|
# (Phase 3+ — motion controller implements path reversal)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Hover: pause mission, hold position ---
|
||||||
|
if action == FailsafeStatus.ACTION_HOVER:
|
||||||
|
if self.state == self.STATE_RUNNING:
|
||||||
|
self.get_logger().warn('HOVER commanded — pausing mission')
|
||||||
|
self.state = self.STATE_PAUSED
|
||||||
|
self._stop_motion()
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- No action: if paused by failsafe and now clear, resume ---
|
||||||
|
if action == FailsafeStatus.ACTION_NONE:
|
||||||
|
if (self.state == self.STATE_PAUSED and
|
||||||
|
assessment_state == FailsafeStatus.ASSESSMENT_GREEN):
|
||||||
|
self.get_logger().info(
|
||||||
|
'Failsafe cleared (GREEN) — resuming mission')
|
||||||
|
self.state = self.STATE_RUNNING
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Service callback — mission commands
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _command_callback(self, request, response):
|
||||||
|
"""
|
||||||
|
Handle mission commands: START, PAUSE, RESUME, ABORT, LOAD.
|
||||||
|
LOAD accepts a YAML waypoint file path via parameters[0].
|
||||||
|
"""
|
||||||
cmd = request.command.upper()
|
cmd = request.command.upper()
|
||||||
|
|
||||||
if cmd == 'START':
|
if cmd == 'START':
|
||||||
if self.state == self.STATE_IDLE:
|
if self.state != self.STATE_IDLE:
|
||||||
self.mission_id = request.mission_id
|
|
||||||
self.current_wp_index = 0
|
|
||||||
self.state = self.STATE_RUNNING
|
|
||||||
response.success = True
|
|
||||||
response.message = f'Mission {self.mission_id} started'
|
|
||||||
else:
|
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = f'Cannot start — current state: {self.state}'
|
response.message = f'Cannot START — current state: {self.state}'
|
||||||
|
return response
|
||||||
|
|
||||||
|
if not self.waypoints:
|
||||||
|
response.success = False
|
||||||
|
response.message = 'Cannot START — no waypoints loaded'
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Record entry point at mission start
|
||||||
|
self.entry_point = (
|
||||||
|
self.current_position if self.current_position is not None
|
||||||
|
else Point()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set operation mode from parameters if provided
|
||||||
|
if request.parameters:
|
||||||
|
self.operation_mode = request.parameters[0]
|
||||||
|
|
||||||
|
self.breadcrumbs.clear()
|
||||||
|
self.current_wp_index = 0
|
||||||
|
self.mission_id = request.mission_id
|
||||||
|
self.state = self.STATE_RUNNING
|
||||||
|
self.wp_start_time = self.get_clock().now().nanoseconds / 1e9
|
||||||
|
|
||||||
|
self.get_logger().info(
|
||||||
|
f'Mission {self.mission_id} started — '
|
||||||
|
f'{len(self.waypoints)} waypoints, '
|
||||||
|
f'mode={self.operation_mode}'
|
||||||
|
)
|
||||||
|
response.success = True
|
||||||
|
response.message = f'Mission {self.mission_id} started'
|
||||||
|
|
||||||
|
elif cmd == 'LOAD':
|
||||||
|
# Load waypoints from YAML file path in parameters[0]
|
||||||
|
if not request.parameters:
|
||||||
|
response.success = False
|
||||||
|
response.message = 'LOAD requires parameters[0] = YAML file path'
|
||||||
|
return response
|
||||||
|
|
||||||
|
loaded, message = self._load_waypoints(request.parameters[0])
|
||||||
|
response.success = loaded
|
||||||
|
response.message = message
|
||||||
|
|
||||||
elif cmd == 'PAUSE':
|
elif cmd == 'PAUSE':
|
||||||
if self.state == self.STATE_RUNNING:
|
if self.state != self.STATE_RUNNING:
|
||||||
self.state = self.STATE_PAUSED
|
|
||||||
response.success = True
|
|
||||||
response.message = 'Mission paused'
|
|
||||||
else:
|
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = f'Cannot pause — current state: {self.state}'
|
response.message = f'Cannot PAUSE — current state: {self.state}'
|
||||||
|
return response
|
||||||
|
self.state = self.STATE_PAUSED
|
||||||
|
self._stop_motion()
|
||||||
|
response.success = True
|
||||||
|
response.message = 'Mission paused'
|
||||||
|
|
||||||
elif cmd == 'RESUME':
|
elif cmd == 'RESUME':
|
||||||
if self.state == self.STATE_PAUSED:
|
if self.state != self.STATE_PAUSED:
|
||||||
self.state = self.STATE_RUNNING
|
|
||||||
response.success = True
|
|
||||||
response.message = 'Mission resumed'
|
|
||||||
else:
|
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = f'Cannot resume — current state: {self.state}'
|
response.message = f'Cannot RESUME — current state: {self.state}'
|
||||||
|
return response
|
||||||
|
self.state = self.STATE_RUNNING
|
||||||
|
self.wp_start_time = self.get_clock().now().nanoseconds / 1e9
|
||||||
|
response.success = True
|
||||||
|
response.message = 'Mission resumed'
|
||||||
|
|
||||||
elif cmd == 'ABORT':
|
elif cmd == 'ABORT':
|
||||||
self.state = self.STATE_ABORTED
|
self._abort_mission('Operator abort command')
|
||||||
self.stop_motion()
|
|
||||||
response.success = True
|
response.success = True
|
||||||
response.message = 'Mission aborted'
|
response.message = 'Mission aborted'
|
||||||
|
|
||||||
@ -122,10 +394,12 @@ class MissionExecutor(Node):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def mission_loop(self):
|
# ------------------------------------------------------------------
|
||||||
"""Main mission execution loop — called at 10Hz."""
|
# Mission execution loop — 10 Hz
|
||||||
self.publish_status()
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _mission_loop(self):
|
||||||
|
"""Main mission execution loop."""
|
||||||
if self.state != self.STATE_RUNNING:
|
if self.state != self.STATE_RUNNING:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -137,46 +411,229 @@ class MissionExecutor(Node):
|
|||||||
if self.current_wp_index >= len(self.waypoints):
|
if self.current_wp_index >= len(self.waypoints):
|
||||||
self.get_logger().info('All waypoints reached — mission complete')
|
self.get_logger().info('All waypoints reached — mission complete')
|
||||||
self.state = self.STATE_COMPLETE
|
self.state = self.STATE_COMPLETE
|
||||||
|
self._stop_motion()
|
||||||
return
|
return
|
||||||
|
|
||||||
# TODO: navigate to current waypoint
|
if self.current_position is None:
|
||||||
# TODO: check arrival condition
|
self.get_logger().warn('No position estimate — waiting')
|
||||||
# TODO: trigger image capture if required
|
return
|
||||||
# TODO: advance to next waypoint on arrival
|
|
||||||
|
|
||||||
def stop_motion(self):
|
current_wp = self.waypoints[self.current_wp_index]
|
||||||
"""Publish zero velocity to stop the ROV."""
|
now_s = self.get_clock().now().nanoseconds / 1e9
|
||||||
self.cmd_pub.publish(Twist()) # All zeros = stop
|
|
||||||
|
|
||||||
def publish_status(self):
|
# --- Publish current waypoint target ---
|
||||||
|
self.waypoint_pub.publish(current_wp)
|
||||||
|
|
||||||
|
# --- Check waypoint timeout ---
|
||||||
|
if (self.wp_start_time is not None and
|
||||||
|
now_s - self.wp_start_time > self._wp_timeout_s):
|
||||||
|
self.get_logger().warn(
|
||||||
|
f'Waypoint {current_wp.waypoint_id} timed out after '
|
||||||
|
f'{self._wp_timeout_s}s — advancing to next'
|
||||||
|
)
|
||||||
|
self._advance_waypoint(now_s)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Check arrival at waypoint ---
|
||||||
|
distance = self._distance_to(current_wp.position)
|
||||||
|
|
||||||
|
if distance > self._arrival_radius:
|
||||||
|
# Not yet arrived — navigate toward waypoint
|
||||||
|
self._navigate_toward(current_wp)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Arrived at waypoint ---
|
||||||
|
self._stop_motion()
|
||||||
|
|
||||||
|
if current_wp.capture_image and not self.capturing_image:
|
||||||
|
# Start image capture loiter
|
||||||
|
self.capturing_image = True
|
||||||
|
self.capture_start_time = now_s
|
||||||
|
self.get_logger().info(
|
||||||
|
f'Arrived at {current_wp.waypoint_id} — '
|
||||||
|
f'capturing image (loiter {current_wp.loiter_time_s}s)'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.capturing_image:
|
||||||
|
# Wait for loiter time before advancing
|
||||||
|
elapsed_capture = now_s - self.capture_start_time
|
||||||
|
if elapsed_capture < current_wp.loiter_time_s + self._capture_delay_s:
|
||||||
|
return
|
||||||
|
self.capturing_image = False
|
||||||
|
self.get_logger().info(
|
||||||
|
f'Image capture complete at {current_wp.waypoint_id}')
|
||||||
|
|
||||||
|
# Advance to next waypoint
|
||||||
|
self._advance_waypoint(now_s)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Navigation helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _navigate_toward(self, waypoint: InspectionWaypoint):
|
||||||
|
"""
|
||||||
|
Publish proportional velocity command toward the waypoint.
|
||||||
|
Simple P-controller — replaced by full motion planner in Phase 3+.
|
||||||
|
Max speed: 0.3 m/s in XY, 0.1 m/s in Z.
|
||||||
|
"""
|
||||||
|
MAX_XY = 0.3 # m/s
|
||||||
|
MAX_Z = 0.1 # m/s
|
||||||
|
KP = 0.5 # proportional gain
|
||||||
|
|
||||||
|
dx = waypoint.position.x - self.current_position.x
|
||||||
|
dy = waypoint.position.y - self.current_position.y
|
||||||
|
dz = waypoint.position.z - self.current_position.z
|
||||||
|
|
||||||
|
# Clamp to max speeds
|
||||||
|
vx = max(-MAX_XY, min(MAX_XY, KP * dx))
|
||||||
|
vy = max(-MAX_XY, min(MAX_XY, KP * dy))
|
||||||
|
vz = max(-MAX_Z, min(MAX_Z, KP * dz))
|
||||||
|
|
||||||
|
cmd = Twist()
|
||||||
|
cmd.linear.x = vx
|
||||||
|
cmd.linear.y = vy
|
||||||
|
cmd.linear.z = vz
|
||||||
|
self.cmd_pub.publish(cmd)
|
||||||
|
|
||||||
|
def _stop_motion(self):
|
||||||
|
"""Publish zero velocity to halt the ROV."""
|
||||||
|
self.cmd_pub.publish(Twist()) # All zeros
|
||||||
|
|
||||||
|
def _distance_to(self, target: Point) -> float:
|
||||||
|
"""Euclidean distance from current position to target."""
|
||||||
|
if self.current_position is None:
|
||||||
|
return float('inf')
|
||||||
|
dx = target.x - self.current_position.x
|
||||||
|
dy = target.y - self.current_position.y
|
||||||
|
dz = target.z - self.current_position.z
|
||||||
|
return math.sqrt(dx*dx + dy*dy + dz*dz)
|
||||||
|
|
||||||
|
def _advance_waypoint(self, now_s: float):
|
||||||
|
"""Move to the next waypoint in the sequence."""
|
||||||
|
self.current_wp_index += 1
|
||||||
|
self.wp_start_time = now_s
|
||||||
|
self.capturing_image = False
|
||||||
|
|
||||||
|
if self.current_wp_index < len(self.waypoints):
|
||||||
|
next_wp = self.waypoints[self.current_wp_index]
|
||||||
|
self.get_logger().info(
|
||||||
|
f'Advancing to waypoint {self.current_wp_index}: '
|
||||||
|
f'{next_wp.waypoint_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _abort_mission(self, reason: str):
|
||||||
|
"""Abort the mission and stop motion."""
|
||||||
|
self.state = self.STATE_ABORTED
|
||||||
|
self._stop_motion()
|
||||||
|
self.get_logger().warn(f'Mission aborted: {reason}')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Waypoint loading
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_waypoints(self, yaml_path: str):
|
||||||
|
"""
|
||||||
|
Load InspectionWaypoint list from a YAML mission file.
|
||||||
|
Expected format:
|
||||||
|
waypoints:
|
||||||
|
- id: "WP1"
|
||||||
|
x: 1.0
|
||||||
|
y: 2.0
|
||||||
|
z: -5.0
|
||||||
|
heading_deg: 90.0
|
||||||
|
standoff_m: 0.5
|
||||||
|
loiter_time_s: 3.0
|
||||||
|
capture_image: true
|
||||||
|
panel_id: "P1"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if 'waypoints' not in data:
|
||||||
|
return False, f'No waypoints key in {yaml_path}'
|
||||||
|
|
||||||
|
self.waypoints = []
|
||||||
|
for entry in data['waypoints']:
|
||||||
|
wp = InspectionWaypoint()
|
||||||
|
wp.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
wp.waypoint_id = str(entry.get('id', ''))
|
||||||
|
wp.position.x = float(entry.get('x', 0.0))
|
||||||
|
wp.position.y = float(entry.get('y', 0.0))
|
||||||
|
wp.position.z = float(entry.get('z', 0.0))
|
||||||
|
wp.heading_deg = float(entry.get('heading_deg', 0.0))
|
||||||
|
wp.standoff_m = float(entry.get('standoff_m', 0.5))
|
||||||
|
wp.loiter_time_s = float(entry.get('loiter_time_s', 2.0))
|
||||||
|
wp.capture_image = bool(entry.get('capture_image', False))
|
||||||
|
wp.panel_id = str(entry.get('panel_id', ''))
|
||||||
|
self.waypoints.append(wp)
|
||||||
|
|
||||||
|
msg = f'Loaded {len(self.waypoints)} waypoints from {yaml_path}'
|
||||||
|
self.get_logger().info(msg)
|
||||||
|
return True, msg
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, f'Mission file not found: {yaml_path}'
|
||||||
|
except Exception as e:
|
||||||
|
return False, f'Failed to load waypoints: {str(e)}'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Status publishing — 2 Hz
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _publish_status(self):
|
||||||
"""Publish current mission status."""
|
"""Publish current mission status."""
|
||||||
msg = MissionStatus()
|
msg = MissionStatus()
|
||||||
msg.header.stamp = self.get_clock().now().to_msg()
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
msg.mission_id = self.mission_id
|
msg.mission_id = self.mission_id
|
||||||
msg.current_task = f'Waypoint {self.current_wp_index}/{len(self.waypoints)}'
|
|
||||||
msg.message = self.state
|
|
||||||
|
|
||||||
if len(self.waypoints) > 0:
|
|
||||||
msg.progress_percent = (self.current_wp_index / len(self.waypoints)) * 100.0
|
|
||||||
|
|
||||||
# Map state string to uint8
|
# Map state string to uint8
|
||||||
state_map = {
|
msg.state = {
|
||||||
self.STATE_IDLE: MissionStatus.STATE_IDLE,
|
self.STATE_IDLE: MissionStatus.STATE_IDLE,
|
||||||
self.STATE_RUNNING: MissionStatus.STATE_RUNNING,
|
self.STATE_RUNNING: MissionStatus.STATE_RUNNING,
|
||||||
self.STATE_PAUSED: MissionStatus.STATE_PAUSED,
|
self.STATE_PAUSED: MissionStatus.STATE_PAUSED,
|
||||||
self.STATE_COMPLETE: MissionStatus.STATE_COMPLETE,
|
self.STATE_COMPLETE: MissionStatus.STATE_COMPLETE,
|
||||||
self.STATE_ABORTED: MissionStatus.STATE_ABORTED,
|
self.STATE_ABORTED: MissionStatus.STATE_ABORTED,
|
||||||
}
|
}.get(self.state, MissionStatus.STATE_IDLE)
|
||||||
msg.state = state_map.get(self.state, MissionStatus.STATE_IDLE)
|
|
||||||
|
# Progress
|
||||||
|
total = len(self.waypoints)
|
||||||
|
msg.progress_percent = (
|
||||||
|
(self.current_wp_index / total * 100.0) if total > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Current task description
|
||||||
|
if self.state == self.STATE_RUNNING and total > 0:
|
||||||
|
wp_id = (self.waypoints[self.current_wp_index].waypoint_id
|
||||||
|
if self.current_wp_index < total else 'complete')
|
||||||
|
msg.current_task = f'WP {self.current_wp_index + 1}/{total}: {wp_id}'
|
||||||
|
else:
|
||||||
|
msg.current_task = self.state
|
||||||
|
|
||||||
|
msg.message = (
|
||||||
|
f'{self.state} | '
|
||||||
|
f'WP {self.current_wp_index}/{total} | '
|
||||||
|
f'Breadcrumbs: {self.breadcrumbs.entry_count}'
|
||||||
|
)
|
||||||
self.status_pub.publish(msg)
|
self.status_pub.publish(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
|
"""ROS2 node entry point."""
|
||||||
rclpy.init(args=args)
|
rclpy.init(args=args)
|
||||||
node = MissionExecutor()
|
node = MissionExecutor()
|
||||||
rclpy.spin(node)
|
try:
|
||||||
node.destroy_node()
|
rclpy.spin(node)
|
||||||
rclpy.shutdown()
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user