You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AirTrapMine/src/GUI/mission_executor.py

383 lines
14 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env python3
"""
任務執行模組
管理多架無人機的 GUIDED 模式飛行控制迴圈
設計:
- 每架無人機持有一個航點序列,逐點推進
- 各自到達就各自切換到下一個航點
- 事件驅動發送:航點切換時送一次,收到 send_result 才決定重送/前進
- 失敗策略 (b):單機重試 MAX_RETRY 次仍失敗 → 該機 fallback LOITER其他機繼續
- Rendezvous barrier在指定 wp index 等所有活著的機到齊才一起推進,有 timeout 保護
- 用 QTimer 驅動到達判定,在 Qt 主線程執行
- 暫停 = 停止到達判定 + 停止新指令
"""
import asyncio
import math
import time
from enum import Enum
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
def _log(level, message):
print(f"[{level}] {message}")
class MissionState(Enum):
"""整體任務狀態"""
IDLE = "idle"
RUNNING = "running"
PAUSED = "paused"
class TaskStatus(Enum):
"""單機任務狀態"""
NORMAL = "normal"
RETRYING = "retrying"
WAITING_AT_BARRIER = "waiting_at_barrier"
FALLBACK_LOITER = "fallback_loiter"
class DroneTask:
"""單架無人機的任務資料"""
__slots__ = (
'drone_id', 'sysid', 'waypoints', 'wp_index', 'done',
'sent_current_wp', 'fail_count', 'status', 'waiting_since',
)
def __init__(self, drone_id, sysid, waypoints):
self.drone_id = drone_id
self.sysid = sysid
self.waypoints = waypoints
self.wp_index = 0
self.done = len(waypoints) == 0
self.sent_current_wp = False
self.fail_count = 0
self.status = TaskStatus.NORMAL
self.waiting_since = 0.0 # monotonic time 進入 WAITING_AT_BARRIER 的瞬間
@property
def current_target(self):
if self.done or self.wp_index >= len(self.waypoints):
return None
return self.waypoints[self.wp_index]
@property
def total_waypoints(self):
return len(self.waypoints)
class MissionExecutor(QObject):
"""
任務執行器 (事件驅動版 + rendezvous barrier)
planned_waypoints 格式:
{
'drone_ids': ['s0_1', 's0_2', ...],
'waypoints': [
[(lat,lon,alt), ...], # drone 0
[(lat,lon,alt), ...], # drone 1
],
'rendezvous_indices': [3, 7, ...], # 選填;缺省 = 全程不同步(各自跑)
}
"""
MAX_RETRY = 3
drone_waypoint_reached = pyqtSignal(str, int, int) # (drone_id, wp_index, total)
task_status_changed = pyqtSignal(str, str, str) # (drone_id, status, message)
mission_completed = pyqtSignal()
def __init__(self, sender, drone_gps, monitor=None,
arrival_radius=2.0, tick_rate_hz=2.0,
barrier_timeout_sec=20.0):
super().__init__()
self.sender = sender
self.drone_gps = drone_gps
self.monitor = monitor # 用於失敗 fallback 到 LOITER
self.arrival_radius = arrival_radius
self.barrier_timeout_sec = barrier_timeout_sec
self.state = MissionState.IDLE
self.tasks = {}
self.rendezvous_indices = set()
self._interval_ms = int(1000 / tick_rate_hz)
self._timer = QTimer()
self._timer.timeout.connect(self._tick)
# 只有 Ros2CommandSender 有 send_result signalMavlinkSender 沒有
if hasattr(sender, 'send_result'):
sender.send_result.connect(self._on_send_result)
# ------------------------------------------------------------------ 公開方法
def start(self, planned_waypoints):
if self.state == MissionState.RUNNING:
_log("WARN", "任務已在執行中")
return
self.tasks.clear()
drone_ids = planned_waypoints['drone_ids']
waypoints_list = planned_waypoints['waypoints']
self.rendezvous_indices = set(
planned_waypoints.get('rendezvous_indices', []) or []
)
for i, drone_id in enumerate(drone_ids):
sysid = int(drone_id.split('_')[1])
self.tasks[drone_id] = DroneTask(drone_id, sysid, waypoints_list[i])
self.state = MissionState.RUNNING
self._timer.start(self._interval_ms)
total_wps = sum(t.total_waypoints for t in self.tasks.values())
rv_info = (
f"rendezvous WP={sorted(self.rendezvous_indices)}"
if self.rendezvous_indices else "無 rendezvous (各自跑)"
)
_log(
"INFO",
f"任務已啟動: {len(self.tasks)} 架無人機, "
f"{total_wps} 個航點, "
f"到達半徑={self.arrival_radius}m, "
f"tick 週期={self._interval_ms}ms, "
f"barrier timeout={self.barrier_timeout_sec}s, "
f"{rv_info}",
)
def pause(self):
if self.state == MissionState.RUNNING:
self._timer.stop()
self.state = MissionState.PAUSED
_log("INFO", "任務已暫停")
def resume(self):
if self.state == MissionState.PAUSED:
self._timer.start(self._interval_ms)
self.state = MissionState.RUNNING
_log("INFO", "任務已恢復")
def stop(self):
self._timer.stop()
self.tasks.clear()
self.rendezvous_indices = set()
self.state = MissionState.IDLE
_log("INFO", "任務已停止")
# ------------------------------------------------------------------ 控制迴圈
def _tick(self):
"""
控制 tick
Phase 1: 每台機做到達判定;非 barrier 點直接推進barrier 點改成 WAITING
Phase 2: 檢查 barrier 能不能釋放(全員到齊或 timeout
Phase 3: 每台未送過當前航點的機送一次
Phase 4: 全部完成檢查
"""
now = time.monotonic()
# ---- Phase 1: 到達判定 ----
for task in self.tasks.values():
if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue
# 已在 barrier 等待的不再跑到達判定
if task.status == TaskStatus.WAITING_AT_BARRIER:
continue
target = task.current_target
if target is None:
continue
gps = self.drone_gps.get(task.drone_id)
if gps is None:
continue
tgt_lat, tgt_lon, _ = target
distance = _haversine(gps['lat'], gps['lon'], tgt_lat, tgt_lon)
if distance >= self.arrival_radius:
continue
# 到達當前 wp_index
if (task.wp_index in self.rendezvous_indices
and task.wp_index < task.total_waypoints - 1):
# rendezvous 點 → 不推進,進入 barrier 等待
task.status = TaskStatus.WAITING_AT_BARRIER
task.waiting_since = now
_log("INFO", f"{task.drone_id} 抵達 barrier WP {task.wp_index},等待同伴")
self.task_status_changed.emit(
task.drone_id, task.status.value,
f"waiting at WP {task.wp_index}"
)
continue
# 一般點 → 直接推進
self._advance_waypoint(task, distance)
# ---- Phase 2: barrier 釋放檢查 ----
self._check_barriers(now)
# ---- Phase 3: 發送未送過的目標 ----
for task in self.tasks.values():
if task.done or task.status in (
TaskStatus.FALLBACK_LOITER, TaskStatus.WAITING_AT_BARRIER
):
continue
if task.sent_current_wp:
continue
target = task.current_target
if target is None:
continue
tgt_lat, tgt_lon, tgt_alt = target
task.sent_current_wp = True
self.sender.send_position_global(
task.drone_id, task.sysid, tgt_lat, tgt_lon, tgt_alt
)
# ---- Phase 4: 完成檢查 ----
all_done = all(
t.done or t.status == TaskStatus.FALLBACK_LOITER
for t in self.tasks.values()
)
if all_done and self.tasks:
self._timer.stop()
self.state = MissionState.IDLE
self.mission_completed.emit()
_log("INFO", "任務全部完成")
def _advance_waypoint(self, task, arrived_distance):
"""把 task 推進一個航點,重置發送旗標。不處理 barrier 邏輯。"""
task.wp_index += 1
task.sent_current_wp = False
task.fail_count = 0
if task.wp_index >= task.total_waypoints:
task.done = True
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
_log(
"INFO",
f"{task.drone_id} 已完成所有航點 "
f"({task.total_waypoints}/{task.total_waypoints})",
)
return
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
_log(
"INFO",
f"{task.drone_id} 前往 WP {task.wp_index}/{task.total_waypoints} "
f"(到達距離: {arrived_distance:.1f}m)",
)
def _check_barriers(self, now):
"""檢查每個有人在等的 barrier 能不能釋放。"""
# 蒐集目前有哪些 barrier 有人卡著
waiting_groups = {} # barrier_idx -> [task, ...]
for task in self.tasks.values():
if task.status == TaskStatus.WAITING_AT_BARRIER:
waiting_groups.setdefault(task.wp_index, []).append(task)
if not waiting_groups:
return
for barrier_idx, waiting_tasks in waiting_groups.items():
# 判斷是否「全員到齊」:所有仍活著的機都要麼在此 barrier 等,要麼早已越過此 idx
everyone_arrived = True
for task in self.tasks.values():
if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue # 失敗機/完成機不擋
if (task.status == TaskStatus.WAITING_AT_BARRIER
and task.wp_index == barrier_idx):
continue # 在這裡等
if task.wp_index > barrier_idx:
continue # 早已越過barrier 先前已對他釋放)
# 還在路上
everyone_arrived = False
break
force_reason = None
if not everyone_arrived:
# 檢查 timeout以該 barrier 上最早進入等待的時間為準
oldest_wait = min(t.waiting_since for t in waiting_tasks)
if now - oldest_wait >= self.barrier_timeout_sec:
force_reason = f"timeout {self.barrier_timeout_sec:.0f}s"
_log("WARN", f"barrier WP {barrier_idx} {force_reason},強制放行")
else:
continue # 還沒到齊、也還沒 timeout → 繼續等
# 釋放:把所有在此 barrier 等待的機一起推進
tag = "全員到齊" if force_reason is None else force_reason
_log("INFO", f"barrier WP {barrier_idx} 已釋放 ({tag}),推進 {len(waiting_tasks)}")
for task in waiting_tasks:
task.status = TaskStatus.NORMAL
msg = f"barrier WP {barrier_idx} released ({tag})"
self.task_status_changed.emit(
task.drone_id, task.status.value, msg
)
self._advance_waypoint(task, self.arrival_radius)
# ------------------------------------------------------------------ 結果回呼
def _on_send_result(self, drone_id, sysid, success, message):
"""Ros2CommandSender.send_result 的 slot"""
task = self.tasks.get(drone_id)
if task is None or task.done:
return
# 若已在 barrier 等待,舊指令的遲到回應不要再觸發重試/fallback
if task.status == TaskStatus.WAITING_AT_BARRIER:
return
if success:
if task.fail_count > 0 or task.status == TaskStatus.RETRYING:
task.status = TaskStatus.NORMAL
self.task_status_changed.emit(drone_id, task.status.value, "recovered")
task.fail_count = 0
return
task.fail_count += 1
_log("WARN", f"{drone_id} 發送失敗 {task.fail_count}/{self.MAX_RETRY}: {message}")
if task.fail_count < self.MAX_RETRY:
task.status = TaskStatus.RETRYING
task.sent_current_wp = False # 下個 tick 會重送
self.task_status_changed.emit(
drone_id, task.status.value,
f"retry {task.fail_count}/{self.MAX_RETRY}: {message}"
)
else:
task.status = TaskStatus.FALLBACK_LOITER
self.task_status_changed.emit(
drone_id, task.status.value,
f"fallback LOITER after {self.MAX_RETRY} fails: {message}"
)
_log("ERROR", f"{drone_id} 連續失敗 {self.MAX_RETRY} 次,切換至 LOITER")
self._fallback_to_loiter(drone_id)
def _fallback_to_loiter(self, drone_id):
"""用 monitor.set_mode 切 LOITER。set_mode 是 coroutine透過 event loop 派送。"""
if self.monitor is None:
_log("WARN", f"無 monitor無法將 {drone_id} 切換至 LOITER")
return
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.create_task(self.monitor.set_mode(drone_id, "LOITER"))
# ------------------------------------------------------------------ 工具函式
def _haversine(lat1, lon1, lat2, lon2):
"""計算兩個 GPS 座標間的水平距離 (公尺)"""
R = 6371000.0
lat1_r = math.radians(lat1)
lat2_r = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(lat1_r) * math.cos(lat2_r) * math.sin(dlon / 2) ** 2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))