Compare commits
5 Commits
31ddc2c7eb
...
559f60dfd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
559f60dfd8 | ||
|
|
6aea0c24a6 | ||
|
|
c3a8b4bc77 | ||
|
|
4b1766255a | ||
|
|
9f7a775427 |
74
src/rov_bringup/launch/foxglove_mcap.launch.py
Normal file
74
src/rov_bringup/launch/foxglove_mcap.launch.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
foxglove_mcap.launch.py
|
||||||
|
Launches foxglove_bridge (WebSocket :8765) and MCAP bag recording.
|
||||||
|
Included automatically by rov_full.launch.py.
|
||||||
|
|
||||||
|
Standalone:
|
||||||
|
ros2 launch rov_bringup foxglove_mcap.launch.py
|
||||||
|
ros2 launch rov_bringup foxglove_mcap.launch.py record:=false
|
||||||
|
|
||||||
|
Foxglove connection from Edge PC:
|
||||||
|
Foxglove Studio -> Open Connection -> Foxglove WebSocket -> ws://rov-brain.local:8765
|
||||||
|
|
||||||
|
Storage:
|
||||||
|
Bags written to /data/bags/ on NVMe. Create before first launch:
|
||||||
|
sudo mkdir -p /data/bags && sudo chown ubuntu:ubuntu /data/bags
|
||||||
|
"""
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument, ExecuteProcess, LogInfo
|
||||||
|
from launch.conditions import IfCondition
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
|
||||||
|
bag_output_arg = DeclareLaunchArgument(
|
||||||
|
'bag_output_dir',
|
||||||
|
default_value='/data/bags/dive',
|
||||||
|
description='Base path for MCAP bag. Timestamp appended automatically.',
|
||||||
|
)
|
||||||
|
|
||||||
|
record_arg = DeclareLaunchArgument(
|
||||||
|
'record',
|
||||||
|
default_value='true',
|
||||||
|
description='Set false to run foxglove_bridge without recording.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exposes all ROS2 topics over WebSocket
|
||||||
|
# Edge PC connects via Foxglove Studio -> ws://rov-brain.local:8765
|
||||||
|
foxglove_bridge_node = Node(
|
||||||
|
package='foxglove_bridge',
|
||||||
|
executable='foxglove_bridge',
|
||||||
|
name='foxglove_bridge',
|
||||||
|
parameters=[{
|
||||||
|
'port': 8765,
|
||||||
|
'address': '0.0.0.0', # all interfaces — required for tether access
|
||||||
|
'tls': False,
|
||||||
|
'send_buffer_limit': 10000000, # 10MB — headroom for image topics
|
||||||
|
'max_update_ms': 100, # 10Hz cap — prevents WebSocket flooding
|
||||||
|
}],
|
||||||
|
output='screen',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Records all topics to NVMe in MCAP format (Foxglove native)
|
||||||
|
# Output dir is auto-timestamped, e.g.: /data/bags/dive_2026_05_07-14_22_05/
|
||||||
|
mcap_recorder = ExecuteProcess(
|
||||||
|
cmd=[
|
||||||
|
'ros2', 'bag', 'record',
|
||||||
|
'--all',
|
||||||
|
'--storage', 'mcap',
|
||||||
|
'--output', LaunchConfiguration('bag_output_dir'),
|
||||||
|
'--max-bag-size', '0', # no size limit — one bag per dive
|
||||||
|
],
|
||||||
|
output='screen',
|
||||||
|
condition=IfCondition(LaunchConfiguration('record')),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
bag_output_arg,
|
||||||
|
record_arg,
|
||||||
|
LogInfo(msg='foxglove_bridge: connect Foxglove Studio to ws://<RPi5-IP>:8765'),
|
||||||
|
foxglove_bridge_node,
|
||||||
|
mcap_recorder,
|
||||||
|
])
|
||||||
@ -79,4 +79,11 @@ def generate_launch_description():
|
|||||||
os.path.join(mission_pkg, 'launch', 'mission.launch.py')
|
os.path.join(mission_pkg, 'launch', 'mission.launch.py')
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Foxglove bridge + MCAP recording (Prong 2 + 3)
|
||||||
|
IncludeLaunchDescription(
|
||||||
|
PythonLaunchDescriptionSource(
|
||||||
|
os.path.join(bringup_pkg, 'launch', 'foxglove_mcap.launch.py')
|
||||||
|
),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -14,4 +14,5 @@
|
|||||||
<export>
|
<export>
|
||||||
<build_type>ament_cmake</build_type>
|
<build_type>ament_cmake</build_type>
|
||||||
</export>
|
</export>
|
||||||
|
<exec_depend>foxglove_bridge</exec_depend>
|
||||||
</package>
|
</package>
|
||||||
|
|||||||
@ -1,8 +1,40 @@
|
|||||||
# Failsafe configuration
|
# Failsafe Monitor Configuration — Argonaut 3
|
||||||
# Tune thresholds before any pool or field testing
|
# See ROV_Failsafe_Design_v2.0 for rationale behind all values.
|
||||||
|
# Tune marked values after pool testing before field deployment.
|
||||||
|
|
||||||
failsafe_monitor:
|
failsafe_monitor:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
low_battery_threshold: 0.20 # Surface when battery below 20%
|
|
||||||
max_depth_m: 50.0 # Emergency surface if depth exceeded
|
# --- Comms ---
|
||||||
comms_timeout_s: 5.0 # Surface after 5s comms loss
|
# Time without FCU heartbeat before comms loss triggers.
|
||||||
|
# Default 2s per design spec. May need increasing in high-latency tether.
|
||||||
|
# TUNE: verify in pool — 2s may generate false positives.
|
||||||
|
comms_timeout_s: 2.0
|
||||||
|
|
||||||
|
# Hold timeouts before escalating to RETURN_TO_SAFE on comms loss.
|
||||||
|
comms_hold_green_s: 60.0 # Hold 60s if GREEN before returning
|
||||||
|
comms_hold_amber_s: 30.0 # Hold 30s if AMBER before returning
|
||||||
|
|
||||||
|
# Hold timeout on manual abort before auto-return (GREEN state only).
|
||||||
|
abort_hold_green_s: 120.0
|
||||||
|
|
||||||
|
# --- Battery thresholds (fractions 0.0-1.0) ---
|
||||||
|
# All values configurable via web UI in production (battery.yaml).
|
||||||
|
# TUNE: measure empirically per battery type. Do not assume.
|
||||||
|
battery_warning_pct: 0.25 # Alert, no new panels started
|
||||||
|
battery_return_pct: 0.20 # Return to safe after current panel
|
||||||
|
battery_critical_pct: 0.12 # Abandon current task, return immediately
|
||||||
|
battery_emergency_pct: 0.08 # Emergency surface, no other action
|
||||||
|
|
||||||
|
# --- Depth limits (metres, positive = depth below surface) ---
|
||||||
|
# NOTE: Standard BlueROV2 Heavy rated to 100m.
|
||||||
|
# Values below target hardware-upgraded configuration.
|
||||||
|
# Validate against actual build specification before field use.
|
||||||
|
depth_design_m: 200.0 # Log + alert, continue mission
|
||||||
|
depth_warning_m: 250.0 # Halt descent, hold, alert operator
|
||||||
|
depth_safety_m: 300.0 # RETURN_TO_SAFE immediately
|
||||||
|
|
||||||
|
# --- Thruster anomaly ---
|
||||||
|
# Single thruster drawing > this ratio vs peer average triggers AMBER.
|
||||||
|
# TUNE: establish normal baseline in calm water before setting threshold.
|
||||||
|
thruster_peer_ratio: 1.5 # 150% of peer average
|
||||||
|
|||||||
@ -1,134 +1,550 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Failsafe monitor node.
|
Failsafe Monitor Node — Argonaut 3
|
||||||
Monitors all safety-critical conditions and publishes failsafe state.
|
===================================
|
||||||
This node must be running at all times during autonomous operation.
|
Implements continuous self-assessment per ROV_Failsafe_Design_v2.0.
|
||||||
|
|
||||||
Conditions monitored:
|
Assessment states (GREEN/AMBER/RED) are evaluated continuously.
|
||||||
- Comms loss (heartbeat timeout)
|
Assessment rate adapts dynamically:
|
||||||
- Low battery (from MAVROS battery state)
|
- GREEN (all nominal): 5 Hz
|
||||||
- Maximum depth exceeded
|
- AMBER (degraded): 20 Hz
|
||||||
- Obstacle detection (future — proximity sensors)
|
- RED (critical): 50 Hz
|
||||||
- Manual abort command
|
|
||||||
|
State machine states:
|
||||||
|
NORMAL -> ASSESSING -> HOLD_AND_RECOVER -> RETURN_TO_SAFE -> EMERGENCY_SURFACE
|
||||||
|
|
||||||
|
Priority order (highest wins when multiple conditions active):
|
||||||
|
1. EMERGENCY_SURFACE (manual trigger)
|
||||||
|
2. Critical/emergency battery
|
||||||
|
3. Safety depth limit (300m)
|
||||||
|
4. Obstacle detected in RED state
|
||||||
|
5. Comms loss (assessed response)
|
||||||
|
6. Manual abort (assessed response)
|
||||||
|
7. Thruster anomaly (AMBER downgrade)
|
||||||
|
8. Low battery warning (informational)
|
||||||
|
|
||||||
Topics subscribed:
|
Topics subscribed:
|
||||||
/mavros/battery (sensor_msgs/BatteryState) — battery level
|
/mavros/battery (sensor_msgs/BatteryState)
|
||||||
/mavros/state (mavros_msgs/State) — FCU connection state
|
/mavros/state (mavros_msgs/State)
|
||||||
/rov/mission/abort (std_msgs/Bool) — manual abort trigger
|
/mavros/global_position/rel_alt (std_msgs/Float32) — altitude/depth proxy
|
||||||
|
/rov/mission/abort (std_msgs/Bool)
|
||||||
|
|
||||||
Topics published:
|
Topics published:
|
||||||
/rov/failsafe (rov_interfaces/FailsafeStatus) — current failsafe state
|
/rov/failsafe (rov_interfaces/FailsafeStatus)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from rclpy.duration import Duration
|
from rclpy.duration import Duration
|
||||||
from std_msgs.msg import Bool
|
from std_msgs.msg import Bool, Float32
|
||||||
from sensor_msgs.msg import BatteryState
|
from sensor_msgs.msg import BatteryState
|
||||||
from mavros_msgs.msg import State
|
from mavros_msgs.msg import State
|
||||||
from rov_interfaces.msg import FailsafeStatus
|
from rov_interfaces.msg import FailsafeStatus
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal state enumerations (not exposed in ROS messages directly)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AssessmentState:
|
||||||
|
"""Continuous self-assessment result states."""
|
||||||
|
GREEN = FailsafeStatus.ASSESSMENT_GREEN # All systems nominal
|
||||||
|
AMBER = FailsafeStatus.ASSESSMENT_AMBER # Degraded but functional
|
||||||
|
RED = FailsafeStatus.ASSESSMENT_RED # Incapable of safe autonomous action
|
||||||
|
|
||||||
|
|
||||||
|
class FSMState:
|
||||||
|
"""Failsafe state machine states."""
|
||||||
|
NORMAL = FailsafeStatus.STATE_NORMAL
|
||||||
|
ASSESSING = FailsafeStatus.STATE_ASSESSING
|
||||||
|
HOLD_AND_RECOVER = FailsafeStatus.STATE_HOLD_AND_RECOVER
|
||||||
|
RETURN_TO_SAFE = FailsafeStatus.STATE_RETURN_TO_SAFE
|
||||||
|
EMERGENCY_SURFACE = FailsafeStatus.STATE_EMERGENCY_SURFACE
|
||||||
|
MISSION_COMPLETE = FailsafeStatus.STATE_MISSION_COMPLETE
|
||||||
|
ABORTED = FailsafeStatus.STATE_ABORTED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Assessment rates (seconds between evaluations per design spec)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RATE_GREEN = 1.0 / 5.0 # 5 Hz — normal operation
|
||||||
|
RATE_AMBER = 1.0 / 20.0 # 20 Hz — degraded
|
||||||
|
RATE_RED = 1.0 / 50.0 # 50 Hz — failsafe active
|
||||||
|
|
||||||
|
|
||||||
class FailsafeMonitor(Node):
|
class FailsafeMonitor(Node):
|
||||||
"""Monitors safety conditions and publishes failsafe status."""
|
"""
|
||||||
|
Monitors all safety-critical conditions and publishes failsafe state.
|
||||||
|
Implements the Argonaut 3 failsafe design (v2.0) in full.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('failsafe_monitor')
|
super().__init__('failsafe_monitor')
|
||||||
|
|
||||||
# --- Parameters ---
|
# ------------------------------------------------------------------
|
||||||
self.declare_parameter('low_battery_threshold', 0.20) # 20% remaining
|
# Parameters (loaded from failsafe.yaml via ros__parameters)
|
||||||
self.declare_parameter('max_depth_m', 50.0) # metres
|
# ------------------------------------------------------------------
|
||||||
self.declare_parameter('comms_timeout_s', 5.0) # seconds
|
|
||||||
|
|
||||||
self.low_batt_thresh = self.get_parameter('low_battery_threshold').value
|
# Comms
|
||||||
self.max_depth = self.get_parameter('max_depth_m').value
|
self.declare_parameter('comms_timeout_s', 2.0)
|
||||||
self.comms_timeout = self.get_parameter('comms_timeout_s').value
|
|
||||||
|
|
||||||
|
# Battery thresholds (fractions 0.0–1.0)
|
||||||
|
self.declare_parameter('battery_warning_pct', 0.25) # Alert, no new panels
|
||||||
|
self.declare_parameter('battery_return_pct', 0.20) # Return to safe
|
||||||
|
self.declare_parameter('battery_critical_pct', 0.12) # Immediate return
|
||||||
|
self.declare_parameter('battery_emergency_pct', 0.08) # Emergency surface
|
||||||
|
|
||||||
|
# Depth limits (metres — positive = depth below surface)
|
||||||
|
self.declare_parameter('depth_design_m', 200.0) # Log + alert, continue
|
||||||
|
self.declare_parameter('depth_warning_m', 250.0) # Halt descent, hold
|
||||||
|
self.declare_parameter('depth_safety_m', 300.0) # Return to safe
|
||||||
|
|
||||||
|
# Hold timeouts before escalating to RETURN_TO_SAFE
|
||||||
|
self.declare_parameter('comms_hold_green_s', 60.0) # Hold 60s if GREEN
|
||||||
|
self.declare_parameter('comms_hold_amber_s', 30.0) # Hold 30s if AMBER
|
||||||
|
self.declare_parameter('abort_hold_green_s', 120.0) # Hold 120s on abort
|
||||||
|
|
||||||
|
# Thruster anomaly detection
|
||||||
|
self.declare_parameter('thruster_peer_ratio', 1.5) # 150% of peer average
|
||||||
|
|
||||||
|
self._load_parameters()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
# Internal state
|
# Internal state
|
||||||
self.last_heartbeat = self.get_clock().now()
|
# ------------------------------------------------------------------
|
||||||
self.battery_percent = 1.0
|
|
||||||
self.fcu_connected = False
|
|
||||||
|
|
||||||
# --- Subscribers ---
|
self.assessment_state = AssessmentState.GREEN
|
||||||
self.battery_sub = self.create_subscription(
|
self.fsm_state = FSMState.NORMAL
|
||||||
BatteryState, '/mavros/battery', self.battery_callback, 10)
|
|
||||||
self.state_sub = self.create_subscription(
|
|
||||||
State, '/mavros/state', self.state_callback, 10)
|
|
||||||
self.abort_sub = self.create_subscription(
|
|
||||||
Bool, '/rov/mission/abort', self.abort_callback, 10)
|
|
||||||
|
|
||||||
# --- Publishers ---
|
# Sensor readings
|
||||||
self.failsafe_pub = self.create_publisher(FailsafeStatus, '/rov/failsafe', 10)
|
self.battery_percent = 1.0 # 0.0–1.0
|
||||||
|
self.fcu_connected = False
|
||||||
|
self.current_depth_m = 0.0
|
||||||
|
|
||||||
# --- Timer: evaluate failsafe conditions at 10Hz ---
|
# Timing
|
||||||
self.timer = self.create_timer(0.1, self.evaluate_failsafe)
|
self.last_heartbeat = self.get_clock().now()
|
||||||
|
self.comms_loss_start = None # When comms loss began
|
||||||
|
self.hold_recover_start = None # When HOLD_AND_RECOVER began
|
||||||
|
self.abort_hold_start = None # When abort hold began
|
||||||
|
|
||||||
self.get_logger().info('FailsafeMonitor node started')
|
# Active condition flags
|
||||||
|
self.flag_comms_loss = False
|
||||||
|
self.flag_low_battery = False
|
||||||
|
self.flag_critical_battery = False
|
||||||
|
self.flag_emergency_battery = False
|
||||||
|
self.flag_depth_exceeded = False
|
||||||
|
self.flag_obstacle = False
|
||||||
|
self.flag_manual_abort = False
|
||||||
|
self.flag_thruster_anomaly = False
|
||||||
|
|
||||||
def battery_callback(self, msg: BatteryState):
|
# ------------------------------------------------------------------
|
||||||
"""Update battery state."""
|
# Subscribers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.create_subscription(
|
||||||
|
BatteryState, '/mavros/battery',
|
||||||
|
self._battery_callback, 10)
|
||||||
|
|
||||||
|
self.create_subscription(
|
||||||
|
State, '/mavros/state',
|
||||||
|
self._state_callback, 10)
|
||||||
|
|
||||||
|
self.create_subscription(
|
||||||
|
Float32, '/mavros/global_position/rel_alt',
|
||||||
|
self._altitude_callback, 10)
|
||||||
|
|
||||||
|
self.create_subscription(
|
||||||
|
Bool, '/rov/mission/abort',
|
||||||
|
self._abort_callback, 10)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Publishers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.failsafe_pub = self.create_publisher(
|
||||||
|
FailsafeStatus, '/rov/failsafe', 10)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Initial timer — starts at GREEN rate (5 Hz)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self._current_rate = RATE_GREEN
|
||||||
|
self._eval_timer = self.create_timer(RATE_GREEN, self._evaluate)
|
||||||
|
|
||||||
|
self.get_logger().info(
|
||||||
|
'FailsafeMonitor started — '
|
||||||
|
f'comms_timeout={self._comms_timeout_s}s '
|
||||||
|
f'battery_return={self._battery_return_pct*100:.0f}% '
|
||||||
|
f'depth_safety={self._depth_safety_m}m'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Parameter loading
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_parameters(self):
|
||||||
|
"""Load all parameters from the ROS parameter server."""
|
||||||
|
self._comms_timeout_s = self.get_parameter('comms_timeout_s').value
|
||||||
|
self._battery_warning_pct = self.get_parameter('battery_warning_pct').value
|
||||||
|
self._battery_return_pct = self.get_parameter('battery_return_pct').value
|
||||||
|
self._battery_critical_pct = self.get_parameter('battery_critical_pct').value
|
||||||
|
self._battery_emerg_pct = self.get_parameter('battery_emergency_pct').value
|
||||||
|
self._depth_design_m = self.get_parameter('depth_design_m').value
|
||||||
|
self._depth_warning_m = self.get_parameter('depth_warning_m').value
|
||||||
|
self._depth_safety_m = self.get_parameter('depth_safety_m').value
|
||||||
|
self._comms_hold_green_s = self.get_parameter('comms_hold_green_s').value
|
||||||
|
self._comms_hold_amber_s = self.get_parameter('comms_hold_amber_s').value
|
||||||
|
self._abort_hold_green_s = self.get_parameter('abort_hold_green_s').value
|
||||||
|
self._thruster_peer_ratio = self.get_parameter('thruster_peer_ratio').value
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Subscriber callbacks
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _battery_callback(self, msg: BatteryState):
|
||||||
|
"""Update battery percentage from MAVROS."""
|
||||||
|
# MAVROS BatteryState.percentage is 0.0–1.0
|
||||||
self.battery_percent = msg.percentage
|
self.battery_percent = msg.percentage
|
||||||
|
|
||||||
def state_callback(self, msg: State):
|
def _state_callback(self, msg: State):
|
||||||
"""Update FCU connection state and heartbeat time."""
|
"""Update FCU connection state and record last heartbeat time."""
|
||||||
self.fcu_connected = msg.connected
|
self.fcu_connected = msg.connected
|
||||||
if msg.connected:
|
if msg.connected:
|
||||||
self.last_heartbeat = self.get_clock().now()
|
# Reset comms loss tracking on each received heartbeat
|
||||||
|
self.last_heartbeat = self.get_clock().now()
|
||||||
|
self.comms_loss_start = None
|
||||||
|
|
||||||
def abort_callback(self, msg: Bool):
|
def _altitude_callback(self, msg: Float32):
|
||||||
"""Handle manual abort command."""
|
"""
|
||||||
|
Update current depth from relative altitude.
|
||||||
|
rel_alt is positive above home — negate for depth below surface.
|
||||||
|
"""
|
||||||
|
self.current_depth_m = -msg.data if msg.data < 0.0 else 0.0
|
||||||
|
|
||||||
|
def _abort_callback(self, msg: Bool):
|
||||||
|
"""Handle manual abort command from operator."""
|
||||||
if msg.data:
|
if msg.data:
|
||||||
self.get_logger().warn('Manual abort received')
|
self.flag_manual_abort = True
|
||||||
self.publish_failsafe(
|
self.get_logger().warn('Manual abort received from operator')
|
||||||
manual_abort=True,
|
|
||||||
action=FailsafeStatus.ACTION_SURFACE,
|
|
||||||
message='Manual abort commanded'
|
|
||||||
)
|
|
||||||
|
|
||||||
def evaluate_failsafe(self):
|
# ------------------------------------------------------------------
|
||||||
"""Evaluate all failsafe conditions and publish status."""
|
# Main evaluation loop — rate adapts to assessment state
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _evaluate(self):
|
||||||
|
"""
|
||||||
|
Main evaluation cycle. Called by timer.
|
||||||
|
1. Assess all sensor inputs -> GREEN/AMBER/RED
|
||||||
|
2. Adapt assessment rate to match state
|
||||||
|
3. Apply priority-ordered failsafe logic
|
||||||
|
4. Publish status
|
||||||
|
"""
|
||||||
|
self._assess_conditions()
|
||||||
|
self._adapt_rate()
|
||||||
|
self._apply_failsafe_priority()
|
||||||
|
self._publish_status()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 1: Continuous self-assessment -> GREEN / AMBER / RED
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _assess_conditions(self):
|
||||||
|
"""
|
||||||
|
Evaluate all monitored parameters and derive assessment state.
|
||||||
|
GREEN: all nominal, 5+ parameters assessed.
|
||||||
|
AMBER: core navigation intact, <= 1 parameter marginal.
|
||||||
|
RED: navigation sensor failure, >= 2 marginal, or any safety-critical lost.
|
||||||
|
"""
|
||||||
now = self.get_clock().now()
|
now = self.get_clock().now()
|
||||||
elapsed = (now - self.last_heartbeat).nanoseconds / 1e9
|
|
||||||
|
|
||||||
comms_loss = elapsed > self.comms_timeout
|
# --- Comms ---
|
||||||
low_battery = self.battery_percent < self.low_batt_thresh
|
elapsed_s = (now - self.last_heartbeat).nanoseconds / 1e9
|
||||||
|
self.flag_comms_loss = elapsed_s > self._comms_timeout_s
|
||||||
|
if self.flag_comms_loss and self.comms_loss_start is None:
|
||||||
|
self.comms_loss_start = now
|
||||||
|
|
||||||
if comms_loss:
|
# --- Battery ---
|
||||||
self.publish_failsafe(
|
pct = self.battery_percent
|
||||||
comms_loss=True,
|
self.flag_emergency_battery = pct < self._battery_emerg_pct
|
||||||
action=FailsafeStatus.ACTION_SURFACE,
|
self.flag_critical_battery = pct < self._battery_critical_pct
|
||||||
message=f'Comms lost for {elapsed:.1f}s'
|
self.flag_low_battery = pct < self._battery_return_pct
|
||||||
)
|
|
||||||
elif low_battery:
|
# --- Depth ---
|
||||||
self.publish_failsafe(
|
d = self.current_depth_m
|
||||||
low_battery=True,
|
self.flag_depth_exceeded = d > self._depth_safety_m
|
||||||
action=FailsafeStatus.ACTION_SURFACE,
|
|
||||||
message=f'Low battery: {self.battery_percent*100:.0f}%'
|
# --- Obstacle (placeholder — Phase 3+ when sonar is fitted) ---
|
||||||
|
# self.flag_obstacle = <sonar_node data>
|
||||||
|
|
||||||
|
# --- Derive assessment state ---
|
||||||
|
marginal_count = sum([
|
||||||
|
self.flag_comms_loss,
|
||||||
|
self.flag_low_battery,
|
||||||
|
self.flag_depth_exceeded,
|
||||||
|
self.flag_obstacle,
|
||||||
|
self.flag_thruster_anomaly,
|
||||||
|
])
|
||||||
|
|
||||||
|
if self.flag_emergency_battery or self.flag_depth_exceeded or marginal_count >= 2:
|
||||||
|
self.assessment_state = AssessmentState.RED
|
||||||
|
elif marginal_count == 1:
|
||||||
|
self.assessment_state = AssessmentState.AMBER
|
||||||
|
else:
|
||||||
|
self.assessment_state = AssessmentState.GREEN
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 2: Adapt timer rate to assessment state
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _adapt_rate(self):
|
||||||
|
"""
|
||||||
|
Change evaluation rate based on current assessment state.
|
||||||
|
Timer is recreated only when rate actually changes.
|
||||||
|
"""
|
||||||
|
target_rate = {
|
||||||
|
AssessmentState.GREEN: RATE_GREEN,
|
||||||
|
AssessmentState.AMBER: RATE_AMBER,
|
||||||
|
AssessmentState.RED: RATE_RED,
|
||||||
|
}[self.assessment_state]
|
||||||
|
|
||||||
|
if abs(target_rate - self._current_rate) > 1e-6:
|
||||||
|
self._eval_timer.cancel()
|
||||||
|
self._eval_timer = self.create_timer(target_rate, self._evaluate)
|
||||||
|
self._current_rate = target_rate
|
||||||
|
self.get_logger().info(
|
||||||
|
f'Assessment rate changed to {1.0/target_rate:.0f} Hz '
|
||||||
|
f'(state={self._assessment_state_name()})'
|
||||||
)
|
)
|
||||||
|
|
||||||
def publish_failsafe(self, comms_loss=False, low_battery=False,
|
# ------------------------------------------------------------------
|
||||||
depth_exceeded=False, obstacle_detected=False,
|
# Step 3: Priority-ordered failsafe logic
|
||||||
manual_abort=False,
|
# ------------------------------------------------------------------
|
||||||
action=FailsafeStatus.ACTION_NONE, message=''):
|
|
||||||
"""Publish a failsafe status message."""
|
def _apply_failsafe_priority(self):
|
||||||
|
"""
|
||||||
|
Apply failsafe conditions in strict priority order per design spec.
|
||||||
|
Higher priority conditions override lower priority ones.
|
||||||
|
"""
|
||||||
|
now = self.get_clock().now()
|
||||||
|
|
||||||
|
# Priority 1: Manual emergency surface (hardcoded, no assessment)
|
||||||
|
# (Handled separately — no current ROS trigger for P1 emergency.
|
||||||
|
# Manual abort at P6 covers operator intent. Reserved for future.)
|
||||||
|
|
||||||
|
# Priority 2: Critical / emergency battery
|
||||||
|
if self.flag_emergency_battery:
|
||||||
|
self._transition(FSMState.EMERGENCY_SURFACE,
|
||||||
|
FailsafeStatus.ACTION_EMERGENCY_SURFACE,
|
||||||
|
f'Emergency battery: {self.battery_percent*100:.0f}%')
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.flag_critical_battery:
|
||||||
|
self._transition(FSMState.RETURN_TO_SAFE,
|
||||||
|
FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
f'Critical battery: {self.battery_percent*100:.0f}%')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 3: Safety depth limit (300m)
|
||||||
|
if self.flag_depth_exceeded:
|
||||||
|
self._transition(FSMState.RETURN_TO_SAFE,
|
||||||
|
FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
f'Safety depth exceeded: {self.current_depth_m:.1f}m')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 4: Obstacle in RED state
|
||||||
|
if self.flag_obstacle and self.assessment_state == AssessmentState.RED:
|
||||||
|
self._transition(FSMState.RETURN_TO_SAFE,
|
||||||
|
FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
'Obstacle detected in RED state')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 5: Comms loss — conditional on assessment state
|
||||||
|
if self.flag_comms_loss:
|
||||||
|
self._handle_comms_loss(now)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 6: Manual abort — conditional on assessment state
|
||||||
|
if self.flag_manual_abort:
|
||||||
|
self._handle_manual_abort(now)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 7: Thruster anomaly — AMBER downgrade, informational
|
||||||
|
if self.flag_thruster_anomaly:
|
||||||
|
# Does not change FSM state, already handled in assessment
|
||||||
|
self.get_logger().warn('Thruster anomaly detected — AMBER state')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 8: Low battery warning — informational only
|
||||||
|
if self.battery_percent < self._battery_warning_pct:
|
||||||
|
self.get_logger().warn(
|
||||||
|
f'Battery warning: {self.battery_percent*100:.0f}% — '
|
||||||
|
'complete current task, no new panels'
|
||||||
|
)
|
||||||
|
|
||||||
|
# All clear — return to NORMAL if previously in HOLD_AND_RECOVER
|
||||||
|
if self.fsm_state == FSMState.HOLD_AND_RECOVER:
|
||||||
|
self.get_logger().info('Conditions cleared — returning to NORMAL')
|
||||||
|
self.fsm_state = FSMState.NORMAL
|
||||||
|
self.hold_recover_start = None
|
||||||
|
|
||||||
|
def _handle_comms_loss(self, now):
|
||||||
|
"""
|
||||||
|
Comms loss response conditional on assessment state per design spec.
|
||||||
|
GREEN: hold 60s then RETURN_TO_SAFE.
|
||||||
|
AMBER: hold 30s then RETURN_TO_SAFE.
|
||||||
|
RED: RETURN_TO_SAFE immediately.
|
||||||
|
"""
|
||||||
|
if self.assessment_state == AssessmentState.RED:
|
||||||
|
self._transition(FSMState.RETURN_TO_SAFE,
|
||||||
|
FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
'Comms loss in RED state — immediate return')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Enter HOLD_AND_RECOVER if not already there
|
||||||
|
if self.fsm_state != FSMState.HOLD_AND_RECOVER:
|
||||||
|
self._transition(FSMState.HOLD_AND_RECOVER,
|
||||||
|
FailsafeStatus.ACTION_HOVER,
|
||||||
|
'Comms loss — holding, attempting reconnection')
|
||||||
|
self.hold_recover_start = now
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check hold timeout
|
||||||
|
hold_limit = (
|
||||||
|
self._comms_hold_green_s
|
||||||
|
if self.assessment_state == AssessmentState.GREEN
|
||||||
|
else self._comms_hold_amber_s
|
||||||
|
)
|
||||||
|
hold_elapsed = (now - self.hold_recover_start).nanoseconds / 1e9
|
||||||
|
|
||||||
|
if hold_elapsed > hold_limit:
|
||||||
|
self._transition(FSMState.RETURN_TO_SAFE,
|
||||||
|
FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
f'Comms lost for {hold_elapsed:.0f}s — returning to safe')
|
||||||
|
|
||||||
|
def _handle_manual_abort(self, now):
|
||||||
|
"""
|
||||||
|
Manual abort response conditional on assessment state per design spec.
|
||||||
|
GREEN: hold position 120s awaiting operator instruction, then RETURN_TO_SAFE.
|
||||||
|
AMBER/RED: RETURN_TO_SAFE immediately.
|
||||||
|
"""
|
||||||
|
if self.assessment_state != AssessmentState.GREEN:
|
||||||
|
self._transition(FSMState.RETURN_TO_SAFE,
|
||||||
|
FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
f'Manual abort in {self._assessment_state_name()} state')
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.fsm_state != FSMState.HOLD_AND_RECOVER:
|
||||||
|
self._transition(FSMState.HOLD_AND_RECOVER,
|
||||||
|
FailsafeStatus.ACTION_HOVER,
|
||||||
|
'Manual abort — holding, awaiting operator instruction')
|
||||||
|
self.abort_hold_start = now
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.abort_hold_start is not None:
|
||||||
|
elapsed = (now - self.abort_hold_start).nanoseconds / 1e9
|
||||||
|
if elapsed > self._abort_hold_green_s:
|
||||||
|
self._transition(FSMState.RETURN_TO_SAFE,
|
||||||
|
FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
f'No operator instruction after {elapsed:.0f}s — returning to safe')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# State machine transition
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _transition(self, new_state: int, action: int, message: str):
|
||||||
|
"""
|
||||||
|
Transition the FSM to a new state. Logs the transition.
|
||||||
|
Only logs if state actually changes to avoid log spam.
|
||||||
|
"""
|
||||||
|
if self.fsm_state != new_state:
|
||||||
|
self.get_logger().warn(
|
||||||
|
f'FSM: {self._fsm_state_name(self.fsm_state)} -> '
|
||||||
|
f'{self._fsm_state_name(new_state)} | {message}'
|
||||||
|
)
|
||||||
|
self.fsm_state = new_state
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Step 4: Publish current status
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _publish_status(self):
|
||||||
|
"""Publish the current failsafe status to /rov/failsafe."""
|
||||||
msg = FailsafeStatus()
|
msg = FailsafeStatus()
|
||||||
msg.header.stamp = self.get_clock().now().to_msg()
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
msg.comms_loss = comms_loss
|
msg.assessment_state = self.assessment_state
|
||||||
msg.low_battery = low_battery
|
msg.failsafe_state = self.fsm_state
|
||||||
msg.depth_exceeded = depth_exceeded
|
|
||||||
msg.obstacle_detected = obstacle_detected
|
# Trigger flags
|
||||||
msg.manual_abort = manual_abort
|
msg.comms_loss = self.flag_comms_loss
|
||||||
msg.action = action
|
msg.low_battery = self.flag_low_battery
|
||||||
msg.message = message
|
msg.depth_exceeded = self.flag_depth_exceeded
|
||||||
|
msg.obstacle_detected = self.flag_obstacle
|
||||||
|
msg.manual_abort = self.flag_manual_abort
|
||||||
|
msg.thruster_anomaly = self.flag_thruster_anomaly
|
||||||
|
|
||||||
|
# Telemetry
|
||||||
|
msg.battery_percent = float(self.battery_percent)
|
||||||
|
|
||||||
|
# Derive action from FSM state
|
||||||
|
msg.action = {
|
||||||
|
FSMState.NORMAL: FailsafeStatus.ACTION_NONE,
|
||||||
|
FSMState.ASSESSING: FailsafeStatus.ACTION_NONE,
|
||||||
|
FSMState.HOLD_AND_RECOVER: FailsafeStatus.ACTION_HOVER,
|
||||||
|
FSMState.RETURN_TO_SAFE: FailsafeStatus.ACTION_RETURN_TO_SAFE,
|
||||||
|
FSMState.EMERGENCY_SURFACE: FailsafeStatus.ACTION_EMERGENCY_SURFACE,
|
||||||
|
FSMState.MISSION_COMPLETE: FailsafeStatus.ACTION_NONE,
|
||||||
|
FSMState.ABORTED: FailsafeStatus.ACTION_NONE,
|
||||||
|
}.get(self.fsm_state, FailsafeStatus.ACTION_NONE)
|
||||||
|
|
||||||
|
msg.message = (
|
||||||
|
f'Assessment: {self._assessment_state_name()} | '
|
||||||
|
f'State: {self._fsm_state_name(self.fsm_state)} | '
|
||||||
|
f'Battery: {self.battery_percent*100:.0f}%'
|
||||||
|
)
|
||||||
|
|
||||||
self.failsafe_pub.publish(msg)
|
self.failsafe_pub.publish(msg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _assessment_state_name(self) -> str:
|
||||||
|
"""Return human-readable assessment state name."""
|
||||||
|
return {
|
||||||
|
AssessmentState.GREEN: 'GREEN',
|
||||||
|
AssessmentState.AMBER: 'AMBER',
|
||||||
|
AssessmentState.RED: 'RED',
|
||||||
|
}.get(self.assessment_state, 'UNKNOWN')
|
||||||
|
|
||||||
|
def _fsm_state_name(self, state: int) -> str:
|
||||||
|
"""Return human-readable FSM state name."""
|
||||||
|
return {
|
||||||
|
FSMState.NORMAL: 'NORMAL',
|
||||||
|
FSMState.ASSESSING: 'ASSESSING',
|
||||||
|
FSMState.HOLD_AND_RECOVER: 'HOLD_AND_RECOVER',
|
||||||
|
FSMState.RETURN_TO_SAFE: 'RETURN_TO_SAFE',
|
||||||
|
FSMState.EMERGENCY_SURFACE: 'EMERGENCY_SURFACE',
|
||||||
|
FSMState.MISSION_COMPLETE: 'MISSION_COMPLETE',
|
||||||
|
FSMState.ABORTED: 'ABORTED',
|
||||||
|
}.get(state, 'UNKNOWN')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
|
"""ROS2 node entry point."""
|
||||||
rclpy.init(args=args)
|
rclpy.init(args=args)
|
||||||
node = FailsafeMonitor()
|
node = FailsafeMonitor()
|
||||||
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__':
|
||||||
|
|||||||
@ -1,12 +1,39 @@
|
|||||||
std_msgs/Header header
|
std_msgs/Header header
|
||||||
|
|
||||||
|
# Assessment state (continuous self-assessment result)
|
||||||
|
uint8 assessment_state
|
||||||
|
uint8 ASSESSMENT_GREEN=0
|
||||||
|
uint8 ASSESSMENT_AMBER=1
|
||||||
|
uint8 ASSESSMENT_RED=2
|
||||||
|
|
||||||
|
# Failsafe state machine state
|
||||||
|
uint8 failsafe_state
|
||||||
|
uint8 STATE_NORMAL=0
|
||||||
|
uint8 STATE_ASSESSING=1
|
||||||
|
uint8 STATE_HOLD_AND_RECOVER=2
|
||||||
|
uint8 STATE_RETURN_TO_SAFE=3
|
||||||
|
uint8 STATE_EMERGENCY_SURFACE=4
|
||||||
|
uint8 STATE_MISSION_COMPLETE=5
|
||||||
|
uint8 STATE_ABORTED=6
|
||||||
|
|
||||||
|
# Active trigger flags
|
||||||
bool comms_loss
|
bool comms_loss
|
||||||
bool low_battery
|
bool low_battery
|
||||||
bool depth_exceeded
|
bool depth_exceeded
|
||||||
bool obstacle_detected
|
bool obstacle_detected
|
||||||
bool manual_abort
|
bool manual_abort
|
||||||
|
bool thruster_anomaly
|
||||||
|
|
||||||
|
# Commanded action
|
||||||
uint8 action
|
uint8 action
|
||||||
uint8 ACTION_NONE=0
|
uint8 ACTION_NONE=0
|
||||||
uint8 ACTION_HOVER=1
|
uint8 ACTION_HOVER=1
|
||||||
uint8 ACTION_SURFACE=2
|
uint8 ACTION_RETURN_TO_SAFE=2
|
||||||
uint8 ACTION_EMERGENCY_SURFACE=3
|
uint8 ACTION_SURFACE=3
|
||||||
|
uint8 ACTION_EMERGENCY_SURFACE=4
|
||||||
|
|
||||||
|
# Battery detail
|
||||||
|
float32 battery_percent
|
||||||
|
|
||||||
|
# Human-readable status
|
||||||
string message
|
string message
|
||||||
|
|||||||
@ -1,30 +1,52 @@
|
|||||||
"""Mission executor launch file."""
|
"""
|
||||||
|
Mission executor launch file.
|
||||||
|
Selects config based on env argument at launch time.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ros2 launch rov_mission mission.launch.py # dev config (default)
|
||||||
|
ros2 launch rov_mission mission.launch.py env:=field # field config
|
||||||
|
"""
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument
|
from launch.actions import DeclareLaunchArgument, OpaqueFunction
|
||||||
from launch.substitutions import LaunchConfiguration
|
from launch.substitutions import LaunchConfiguration
|
||||||
from launch_ros.actions import Node
|
from launch_ros.actions import Node
|
||||||
from ament_index_python.packages import get_package_share_directory
|
from ament_index_python.packages import get_package_share_directory
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description():
|
def _launch_with_config(context, *args, **kwargs):
|
||||||
|
"""Resolve env argument and return node with correct config file."""
|
||||||
pkg_dir = get_package_share_directory('rov_mission')
|
pkg_dir = get_package_share_directory('rov_mission')
|
||||||
|
|
||||||
env_arg = DeclareLaunchArgument(
|
# Evaluate env argument at launch time
|
||||||
'env', default_value='dev',
|
env = LaunchConfiguration('env').perform(context)
|
||||||
description='Deployment environment: dev, field'
|
|
||||||
)
|
|
||||||
|
|
||||||
env = LaunchConfiguration('env')
|
config_file = os.path.join(pkg_dir, 'config', f'{env}.yaml')
|
||||||
|
|
||||||
return LaunchDescription([
|
if not os.path.exists(config_file):
|
||||||
env_arg,
|
raise FileNotFoundError(
|
||||||
|
f'Config file not found: {config_file}. '
|
||||||
|
f'Valid environments: dev, field'
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
Node(
|
Node(
|
||||||
package='rov_mission',
|
package='rov_mission',
|
||||||
executable='mission_executor',
|
executable='mission_executor',
|
||||||
name='mission_executor',
|
name='mission_executor',
|
||||||
output='screen',
|
output='screen',
|
||||||
# Config selected by env argument at launch time
|
parameters=[config_file],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
"""Launch mission executor with environment-specific config."""
|
||||||
|
return LaunchDescription([
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
'env',
|
||||||
|
default_value='dev',
|
||||||
|
description='Deployment environment: dev, field'
|
||||||
),
|
),
|
||||||
|
OpaqueFunction(function=_launch_with_config),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -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__':
|
||||||
|
|||||||
70
src/rov_simulation/launch/dev_stack.launch.py
Normal file
70
src/rov_simulation/launch/dev_stack.launch.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Dev stack launch file — Argonaut 3 Simulation.
|
||||||
|
DEV ONLY — NOT FOR DEPLOYMENT TO VEHICLE HARDWARE.
|
||||||
|
|
||||||
|
Starts mock_publisher + failsafe_monitor + mission_executor together
|
||||||
|
for integrated testing without physical hardware.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ros2 launch rov_simulation dev_stack.launch.py
|
||||||
|
ros2 launch rov_simulation dev_stack.launch.py scenario:=low_battery
|
||||||
|
ros2 launch rov_simulation dev_stack.launch.py scenario:=comms_loss
|
||||||
|
|
||||||
|
Available scenarios: nominal, low_battery, comms_loss, depth_warning, all_clear
|
||||||
|
"""
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
"""Launch full dev stack with selected simulation scenario."""
|
||||||
|
|
||||||
|
control_pkg = get_package_share_directory('rov_control')
|
||||||
|
mission_pkg = get_package_share_directory('rov_mission')
|
||||||
|
|
||||||
|
failsafe_config = os.path.join(control_pkg, 'config', 'failsafe.yaml')
|
||||||
|
mission_config = os.path.join(mission_pkg, 'config', 'dev.yaml')
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
|
||||||
|
# Scenario argument — passed to mock_publisher
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
'scenario',
|
||||||
|
default_value='nominal',
|
||||||
|
description=(
|
||||||
|
'Simulation scenario: '
|
||||||
|
'nominal | low_battery | comms_loss | depth_warning | all_clear'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
# Mock publisher — synthetic sensor data (dev only)
|
||||||
|
Node(
|
||||||
|
package='rov_simulation',
|
||||||
|
executable='mock_publisher',
|
||||||
|
name='mock_publisher',
|
||||||
|
output='screen',
|
||||||
|
parameters=[{'scenario': LaunchConfiguration('scenario')}],
|
||||||
|
),
|
||||||
|
|
||||||
|
# Failsafe monitor — reads mock sensor data, publishes /rov/failsafe
|
||||||
|
Node(
|
||||||
|
package='rov_control',
|
||||||
|
executable='failsafe_monitor',
|
||||||
|
name='failsafe_monitor',
|
||||||
|
output='screen',
|
||||||
|
parameters=[failsafe_config],
|
||||||
|
),
|
||||||
|
|
||||||
|
# Mission executor — reads failsafe state, publishes mission status
|
||||||
|
Node(
|
||||||
|
package='rov_mission',
|
||||||
|
executable='mission_executor',
|
||||||
|
name='mission_executor',
|
||||||
|
output='screen',
|
||||||
|
parameters=[mission_config],
|
||||||
|
),
|
||||||
|
])
|
||||||
19
src/rov_simulation/package.xml
Normal file
19
src/rov_simulation/package.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>rov_simulation</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Development-only simulation utilities — NOT for deployment to vehicle hardware.</description>
|
||||||
|
<maintainer email="grant@symbytech.com">grant</maintainer>
|
||||||
|
<license>Proprietary</license>
|
||||||
|
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>sensor_msgs</depend>
|
||||||
|
<depend>mavros_msgs</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>nav_msgs</depend>
|
||||||
|
<depend>rov_interfaces</depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
0
src/rov_simulation/resource/rov_simulation
Normal file
0
src/rov_simulation/resource/rov_simulation
Normal file
0
src/rov_simulation/rov_simulation/__init__.py
Normal file
0
src/rov_simulation/rov_simulation/__init__.py
Normal file
322
src/rov_simulation/rov_simulation/mock_publisher.py
Normal file
322
src/rov_simulation/rov_simulation/mock_publisher.py
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Mock Publisher Node — Argonaut 3 Simulation
|
||||||
|
=============================================
|
||||||
|
DEV ONLY — NOT FOR DEPLOYMENT TO VEHICLE HARDWARE.
|
||||||
|
|
||||||
|
Publishes synthetic sensor data to simulate a connected ArduSub vehicle.
|
||||||
|
Allows failsafe_monitor and mission_executor to be tested without
|
||||||
|
physical hardware or a live MAVROS connection.
|
||||||
|
|
||||||
|
Simulated topics (mimics MAVROS output):
|
||||||
|
/mavros/state (mavros_msgs/State) — FCU connection + heartbeat
|
||||||
|
/mavros/battery (sensor_msgs/BatteryState) — battery level
|
||||||
|
/mavros/global_position/rel_alt (std_msgs/Float32) — depth (negative = below surface)
|
||||||
|
/rov/state (nav_msgs/Odometry) — vehicle position + orientation
|
||||||
|
|
||||||
|
Scenarios (set via ROS parameter 'scenario'):
|
||||||
|
nominal — healthy vehicle, normal battery, shallow depth
|
||||||
|
low_battery — battery draining toward return threshold
|
||||||
|
comms_loss — heartbeat stops after 10s to trigger comms failsafe
|
||||||
|
depth_warning — vehicle descends toward warning depth
|
||||||
|
all_clear — all GREEN, used to verify normal operation
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ros2 run rov_simulation mock_publisher --ros-args -p scenario:=low_battery
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from std_msgs.msg import Float32, Bool, Header
|
||||||
|
from sensor_msgs.msg import BatteryState
|
||||||
|
from nav_msgs.msg import Odometry
|
||||||
|
from geometry_msgs.msg import Quaternion
|
||||||
|
from mavros_msgs.msg import State
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Available simulation scenarios
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCENARIOS = {
|
||||||
|
'nominal': {
|
||||||
|
'description': 'Healthy vehicle — all systems nominal',
|
||||||
|
'battery_start': 0.85, # 85% — well above all thresholds
|
||||||
|
'battery_drain': 0.0, # No drain
|
||||||
|
'depth_start_m': 5.0, # Shallow
|
||||||
|
'depth_rate_mps': 0.0, # Stationary
|
||||||
|
'comms_loss_at_s': None, # No comms loss
|
||||||
|
},
|
||||||
|
'low_battery': {
|
||||||
|
'description': 'Battery draining — triggers warning then return threshold',
|
||||||
|
'battery_start': 0.30, # Start above warning (25%)
|
||||||
|
'battery_drain': 0.001, # Drain 0.1% per second
|
||||||
|
'depth_start_m': 10.0,
|
||||||
|
'depth_rate_mps': 0.0,
|
||||||
|
'comms_loss_at_s': None,
|
||||||
|
},
|
||||||
|
'comms_loss': {
|
||||||
|
'description': 'Heartbeat stops after 10s — triggers comms failsafe',
|
||||||
|
'battery_start': 0.80,
|
||||||
|
'battery_drain': 0.0,
|
||||||
|
'depth_start_m': 8.0,
|
||||||
|
'depth_rate_mps': 0.0,
|
||||||
|
'comms_loss_at_s': 10.0, # Stop heartbeat at 10s
|
||||||
|
},
|
||||||
|
'depth_warning': {
|
||||||
|
'description': 'Vehicle descending toward warning depth limit',
|
||||||
|
'battery_start': 0.75,
|
||||||
|
'battery_drain': 0.0,
|
||||||
|
'depth_start_m': 180.0, # Start near design depth (200m)
|
||||||
|
'depth_rate_mps': 0.5, # Descend 0.5 m/s toward warning (250m)
|
||||||
|
'comms_loss_at_s': None,
|
||||||
|
},
|
||||||
|
'all_clear': {
|
||||||
|
'description': 'All GREEN — verify normal operation produces no failsafe',
|
||||||
|
'battery_start': 0.95,
|
||||||
|
'battery_drain': 0.0,
|
||||||
|
'depth_start_m': 3.0,
|
||||||
|
'depth_rate_mps': 0.0,
|
||||||
|
'comms_loss_at_s': None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MockPublisher(Node):
|
||||||
|
"""
|
||||||
|
Publishes synthetic MAVROS-compatible sensor data for dev testing.
|
||||||
|
Simulates a connected ArduSub vehicle without physical hardware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PUBLISH_RATE_HZ = 10 # All topics published at 10 Hz
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('mock_publisher')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Parameters
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self.declare_parameter('scenario', 'nominal')
|
||||||
|
scenario_name = self.get_parameter('scenario').value
|
||||||
|
|
||||||
|
if scenario_name not in SCENARIOS:
|
||||||
|
self.get_logger().error(
|
||||||
|
f'Unknown scenario: {scenario_name}. '
|
||||||
|
f'Valid: {list(SCENARIOS.keys())}'
|
||||||
|
)
|
||||||
|
scenario_name = 'nominal'
|
||||||
|
|
||||||
|
self._scenario = SCENARIOS[scenario_name]
|
||||||
|
self.get_logger().info(
|
||||||
|
f'MockPublisher starting — scenario: {scenario_name} '
|
||||||
|
f'({self._scenario["description"]})'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Simulation state
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self._elapsed_s = 0.0
|
||||||
|
self._battery_pct = self._scenario['battery_start']
|
||||||
|
self._depth_m = self._scenario['depth_start_m']
|
||||||
|
self._comms_active = True
|
||||||
|
|
||||||
|
# Simple position simulation — vehicle moves in XY circle for realism
|
||||||
|
self._pos_x = 0.0
|
||||||
|
self._pos_y = 0.0
|
||||||
|
self._orbit_radius_m = 2.0 # metres
|
||||||
|
self._orbit_rate_rps = 0.05 # radians per second
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Publishers (all matching MAVROS topic names and message types)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self._state_pub = self.create_publisher(
|
||||||
|
State, '/mavros/state', 10)
|
||||||
|
|
||||||
|
self._battery_pub = self.create_publisher(
|
||||||
|
BatteryState, '/mavros/battery', 10)
|
||||||
|
|
||||||
|
self._alt_pub = self.create_publisher(
|
||||||
|
Float32, '/mavros/global_position/rel_alt', 10)
|
||||||
|
|
||||||
|
self._odom_pub = self.create_publisher(
|
||||||
|
Odometry, '/rov/state', 10)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Timer
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
self._dt = 1.0 / self.PUBLISH_RATE_HZ
|
||||||
|
self.create_timer(self._dt, self._publish_all)
|
||||||
|
|
||||||
|
self.get_logger().warn(
|
||||||
|
'*** DEV ONLY — mock_publisher is active. '
|
||||||
|
'Do not deploy to vehicle hardware. ***'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Main publish cycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _publish_all(self):
|
||||||
|
"""Advance simulation state and publish all topics."""
|
||||||
|
self._advance_simulation()
|
||||||
|
self._publish_state()
|
||||||
|
self._publish_battery()
|
||||||
|
self._publish_altitude()
|
||||||
|
self._publish_odometry()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Simulation state advancement
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _advance_simulation(self):
|
||||||
|
"""Advance all simulated quantities by one timestep."""
|
||||||
|
self._elapsed_s += self._dt
|
||||||
|
|
||||||
|
# Battery drain
|
||||||
|
drain = self._scenario['battery_drain'] * self._dt
|
||||||
|
self._battery_pct = max(0.0, self._battery_pct - drain)
|
||||||
|
|
||||||
|
# Depth change
|
||||||
|
self._depth_m += self._scenario['depth_rate_mps'] * self._dt
|
||||||
|
|
||||||
|
# Position orbit (simple circle for realistic /rov/state output)
|
||||||
|
angle = self._orbit_rate_rps * self._elapsed_s
|
||||||
|
self._pos_x = self._orbit_radius_m * math.cos(angle)
|
||||||
|
self._pos_y = self._orbit_radius_m * math.sin(angle)
|
||||||
|
|
||||||
|
# Comms loss scenario — stop publishing heartbeat after threshold
|
||||||
|
loss_at = self._scenario['comms_loss_at_s']
|
||||||
|
if loss_at is not None and self._elapsed_s >= loss_at:
|
||||||
|
if self._comms_active:
|
||||||
|
self.get_logger().warn(
|
||||||
|
f'[{self._elapsed_s:.1f}s] Simulating comms loss — '
|
||||||
|
'stopping FCU heartbeat'
|
||||||
|
)
|
||||||
|
self._comms_active = False
|
||||||
|
|
||||||
|
# Log key threshold crossings once
|
||||||
|
self._log_threshold_crossings()
|
||||||
|
|
||||||
|
def _log_threshold_crossings(self):
|
||||||
|
"""Log once when battery crosses each threshold."""
|
||||||
|
pct = self._battery_pct
|
||||||
|
thresholds = [
|
||||||
|
(0.25, 'WARNING (25%)'),
|
||||||
|
(0.20, 'RETURN threshold (20%)'),
|
||||||
|
(0.12, 'CRITICAL (12%)'),
|
||||||
|
(0.08, 'EMERGENCY (8%)'),
|
||||||
|
]
|
||||||
|
for threshold, label in thresholds:
|
||||||
|
attr = f'_logged_battery_{int(threshold*100)}'
|
||||||
|
if pct < threshold and not getattr(self, attr, False):
|
||||||
|
self.get_logger().warn(
|
||||||
|
f'[{self._elapsed_s:.1f}s] Battery crossed {label}: '
|
||||||
|
f'{pct*100:.1f}%'
|
||||||
|
)
|
||||||
|
setattr(self, attr, True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Topic publishers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _publish_state(self):
|
||||||
|
"""
|
||||||
|
Publish FCU connection state (mavros_msgs/State).
|
||||||
|
Mimics the MAVROS /mavros/state topic.
|
||||||
|
Stops publishing when comms_loss scenario activates.
|
||||||
|
"""
|
||||||
|
if not self._comms_active:
|
||||||
|
# Do not publish — simulates lost heartbeat
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = State()
|
||||||
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
msg.connected = True
|
||||||
|
msg.armed = False # Unarmed for dev testing
|
||||||
|
msg.guided = True
|
||||||
|
msg.mode = 'ALT_HOLD'
|
||||||
|
msg.system_status = 3 # MAV_STATE_STANDBY
|
||||||
|
self._state_pub.publish(msg)
|
||||||
|
|
||||||
|
def _publish_battery(self):
|
||||||
|
"""
|
||||||
|
Publish battery state (sensor_msgs/BatteryState).
|
||||||
|
Mimics the MAVROS /mavros/battery topic.
|
||||||
|
percentage field: 0.0–1.0.
|
||||||
|
"""
|
||||||
|
msg = BatteryState()
|
||||||
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
msg.percentage = float(self._battery_pct)
|
||||||
|
msg.voltage = 14.8 * self._battery_pct + 12.0 # Approximate LiPo curve
|
||||||
|
msg.current = 5.0 # Amps — nominal draw
|
||||||
|
msg.charge = float('nan')
|
||||||
|
msg.capacity = float('nan')
|
||||||
|
msg.design_capacity = float('nan')
|
||||||
|
msg.power_supply_status = BatteryState.POWER_SUPPLY_STATUS_DISCHARGING
|
||||||
|
self._battery_pub.publish(msg)
|
||||||
|
|
||||||
|
def _publish_altitude(self):
|
||||||
|
"""
|
||||||
|
Publish relative altitude (std_msgs/Float32).
|
||||||
|
Negative value = depth below surface, matching MAVROS convention.
|
||||||
|
"""
|
||||||
|
msg = Float32()
|
||||||
|
msg.data = -abs(self._depth_m) # Always negative (depth)
|
||||||
|
self._alt_pub.publish(msg)
|
||||||
|
|
||||||
|
def _publish_odometry(self):
|
||||||
|
"""
|
||||||
|
Publish vehicle state (nav_msgs/Odometry).
|
||||||
|
Provides position for mission_executor arrival detection and
|
||||||
|
breadcrumb logging.
|
||||||
|
"""
|
||||||
|
msg = Odometry()
|
||||||
|
msg.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
msg.header.frame_id = 'map'
|
||||||
|
msg.child_frame_id = 'base_link'
|
||||||
|
|
||||||
|
# Position — orbit + depth
|
||||||
|
msg.pose.pose.position.x = self._pos_x
|
||||||
|
msg.pose.pose.position.y = self._pos_y
|
||||||
|
msg.pose.pose.position.z = -abs(self._depth_m)
|
||||||
|
|
||||||
|
# Orientation — identity quaternion (level vehicle)
|
||||||
|
msg.pose.pose.orientation.w = 1.0
|
||||||
|
msg.pose.pose.orientation.x = 0.0
|
||||||
|
msg.pose.pose.orientation.y = 0.0
|
||||||
|
msg.pose.pose.orientation.z = 0.0
|
||||||
|
|
||||||
|
# Velocity — tangential to orbit
|
||||||
|
angle = self._orbit_rate_rps * self._elapsed_s
|
||||||
|
speed = self._orbit_radius_m * self._orbit_rate_rps
|
||||||
|
msg.twist.twist.linear.x = -speed * math.sin(angle)
|
||||||
|
msg.twist.twist.linear.y = speed * math.cos(angle)
|
||||||
|
msg.twist.twist.linear.z = 0.0
|
||||||
|
|
||||||
|
self._odom_pub.publish(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
"""ROS2 node entry point."""
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = MockPublisher()
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
29
src/rov_simulation/setup.py
Normal file
29
src/rov_simulation/setup.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
package_name = 'rov_simulation'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.1.0',
|
||||||
|
packages=[package_name],
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages',
|
||||||
|
['resource/' + package_name]),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
(os.path.join('share', package_name, 'launch'),
|
||||||
|
glob('launch/*.py')),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='grant',
|
||||||
|
maintainer_email='grant@symbytech.com',
|
||||||
|
description='Development-only simulation utilities — NOT for deployment',
|
||||||
|
license='Proprietary',
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'mock_publisher = rov_simulation.mock_publisher:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user