#!/usr/bin/env python3 """ MissionOrchestrator —— 多機偵測後的動態包圍任務編排器。 設計重點 ======== 1. 偵察階段仍可使用既有 A/B 群組:A 搜尋、B 待機。 2. 偵測並由操作員確認後,A/B 角色立即解除,所有友機視為同一個集合。 3. 不再使用 leader-follow / PURSUE 隊形;確認後直接針對敵機未來位置的包圍 slot 收斂。 A/B 高度以敵機即時相對高度為基準,不再固定以 30m 為中心。 4. 每台友機使用 C2 平滑且持續前進的 quintic Cartesian「微幅發散 → 收斂」虛擬軌跡,避免繞大圈。 5. 對所有 slot assignment(4 機時最多 4! = 24 種)評分: - 路徑長度 - 路線交叉 - 預測最小間距 - 頻繁交換 slot 的成本 6. 同層路徑交叉維持硬限制;間距不足時在持續前進中疊加分離修正。 A/B 跨層若水平距離過近且尚未建立垂直間隔,先原地垂直分層,再水平移動。 7. 包圍完成條件改為「每台友機均到達自己的 slot」,不是形心距敵機。 8. 敵機定位遺失超時時進 TARGET_LOST,將友機 hold 在當下位置,避免繼續追過期目標。 相容性 ====== - 保留既有 MissionExecutor 介面:start(plan) / update_plan(plan) / stop() - 保留 EngagementPanel 的主要 signal/API。 - __init__ 保留舊版 r_engage / r_engage_exit / lon_spacing / lat_offset 參數, 讓既有 GUI 呼叫端不必立刻全部修改;新策略不再依賴 leader-follow 的 spacing。 """ import math import time from enum import Enum from itertools import permutations, product from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QFrame, QPushButton, QDoubleSpinBox ) from PyQt6.QtCore import Qt, QObject, pyqtSignal # --------------------------------------------------------------------------- # 基礎工具 EARTH_R = 6371000.0 def _log(level, message): print(f"[{level}] {message}", flush=True) def _haversine(lat1, lon1, lat2, lon2): """兩經緯度間水平地面距離 (m)。""" p1 = math.radians(lat1) p2 = math.radians(lat2) dphi = math.radians(lat2 - lat1) dlam = math.radians(lon2 - lon1) a = (math.sin(dphi / 2.0) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlam / 2.0) ** 2) return 2.0 * EARTH_R * math.asin(min(1.0, math.sqrt(a))) def _ll_to_m(lat, lon, ref_lat, ref_lon): """經緯度 -> 以 ref 為原點的局部平面公尺座標 (x=東, y=北)。""" x = math.radians(lon - ref_lon) * EARTH_R * math.cos(math.radians(ref_lat)) y = math.radians(lat - ref_lat) * EARTH_R return x, y def _m_to_ll(x, y, ref_lat, ref_lon): """局部平面公尺座標 -> 經緯度。""" lat = ref_lat + math.degrees(y / EARTH_R) cos_lat = max(1e-9, math.cos(math.radians(ref_lat))) lon = ref_lon + math.degrees(x / (EARTH_R * cos_lat)) return lat, lon def _norm2(x, y): return math.hypot(x, y) def _normalize(x, y, fallback=(1.0, 0.0)): n = math.hypot(x, y) if n < 1e-9: return fallback return x / n, y / n def _bezier_point(p0, p1, p2, p3, t): """2D cubic Bezier。pX=(x,y)。""" u = 1.0 - t b0 = u ** 3 b1 = 3.0 * u * u * t b2 = 3.0 * u * t * t b3 = t ** 3 return ( b0 * p0[0] + b1 * p1[0] + b2 * p2[0] + b3 * p3[0], b0 * p0[1] + b1 * p1[1] + b2 * p2[1] + b3 * p3[1], ) def _smoothstep5(t): """Quintic smoothstep: 0/1 端點的一、二階導數皆為 0。""" t = min(1.0, max(0.0, float(t))) return t * t * t * (t * (t * 6.0 - 15.0) + 10.0) def _wrap_pi(a): """角度包到 [-pi, pi)。""" return (a + math.pi) % (2.0 * math.pi) - math.pi def _polyline_length(points): total = 0.0 for a, b in zip(points, points[1:]): total += math.hypot(b[0] - a[0], b[1] - a[1]) return total def _orientation(a, b, c): return ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) def _segments_intersect(a, b, c, d, eps=1e-9): """一般位置線段相交測試;對任務規劃足夠。""" o1 = _orientation(a, b, c) o2 = _orientation(a, b, d) o3 = _orientation(c, d, a) o4 = _orientation(c, d, b) # 只把真正穿越當成 crossing;共線/端點接觸交給 minimum separation 處理。 return ((o1 > eps and o2 < -eps) or (o1 < -eps and o2 > eps)) and \ ((o3 > eps and o4 < -eps) or (o3 < -eps and o4 > eps)) # --------------------------------------------------------------------------- # 預設參數 DEFAULT_R_DETECT = 40.0 DEFAULT_DETECT_DWELL = 1.0 # 以下兩個保留作為舊呼叫端相容參數;新策略不用「距離到某半徑才切追擊/包圍」。 DEFAULT_R_ENGAGE = 25.0 DEFAULT_R_ENGAGE_EXIT = 35.0 # 新策略建議值 DEFAULT_CIRCLE_RADIUS = 10.0 # 最終包圍半徑 (m);近場會以敵機當下位置為圓心 DEFAULT_FORMATION_ALT = 30.0 # 敵機高度缺失時的安全備援值 (m,相對 home) DEFAULT_ALT_LAYER_GAP = 10.0 # A/B 兩層的垂直間隔 (m) DEFAULT_MIN_ALT_FLOOR = 10.0 # 最低安全相對高度 (m) DEFAULT_CROSS_LAYER_HOLD_DISTANCE = 12.0 # A/B 水平距離小於此值時先確認垂直分層 (m) DEFAULT_CROSS_LAYER_MIN_VERTICAL_GAP = 7.0 # 跨層近距離時要求的最小實際垂直間隔 (m) DEFAULT_CROSS_LAYER_ALT_TOLERANCE = 1.0 # 垂直分層完成容許誤差 (m) DEFAULT_SPREAD_DISTANCE = 3.0 # 小幅外擴上限 (m):避免繞大圈,只保留必要的發散 DEFAULT_APPROACH_MARGIN = 4.0 # 保留相容參數;新 Cartesian 平滑路徑不再強制繞到圈外 staging DEFAULT_LOOKAHEAD_T = 0.35 # 遠距離時的 Bezier 前視比例 DEFAULT_FINAL_DIRECT_DISTANCE = 4.0 # 只在最後 4m 直接送 slot;避免中途提早進入減速 DEFAULT_LEAD_FADE_START = 30.0 # 平均距敵 <= 此距離開始逐步減少敵機前置預測 DEFAULT_LEAD_FADE_END = 18.0 # 平均距敵 <= 此距離完全取消前置,圓心貼住敵機當下位置 DEFAULT_CENTER_SMOOTH_ALPHA = 0.25 # 規劃圓心低通係數,降低 GPS/敵速造成的路徑抖動 DEFAULT_LOOKAHEAD_DISTANCE = 14.0 # 保持較遠 carrot point,避免反覆追短 waypoint 而減速 DEFAULT_PATH_SAMPLES = 41 # 路徑/交叉預測取樣數;提高以降低漏判 DEFAULT_SAFE_SEPARATION = 8.0 # 硬安全間距 (m) DEFAULT_FORMATION_SPACING = 12.0 # 移動中希望逐步拉到的隊形間距;比硬安全距離更早開始展開 DEFAULT_INITIAL_SEP_TOLERANCE = 0.5 # 起始若小於 8m,允許最多只再縮短這麼多 (m) DEFAULT_EMERGENCY_SEPARATION = 3.0 # 無論如何不可低於的水平緊急下限 (m) DEFAULT_RECOVERY_SPREAD_STEP = 2.0 # 舊版保底:無 assignment 時的小幅外擴 (m) DEFAULT_SEPARATION_CORRECTION_GAIN = 1.25 # 更積極的移動中隊形修正 DEFAULT_SEPARATION_CORRECTION_MAX = 5.0 # 最大側向修正;仍保證主要前進分量 DEFAULT_DIRECTION_SMOOTH_ALPHA = 0.35 # 每 tick 接受新方向的比例;越小越平滑 DEFAULT_MAX_LATERAL_RATIO = 0.45 # 側向/前進最大比例,避免修隊形時原地橫移 DEFAULT_LAYER_LANE_SPACING = 4.0 # 同層同心圓車道的半徑間隔 (m) DEFAULT_LAYER_AVOID_DISTANCE = 14.0 # 同層距離小於此值開始啟用同心圓避讓 DEFAULT_LAYER_LANE_FADE_DISTANCE = 12.0 # 距最終 slot 小於此值時逐步收回共同包圍半徑 DEFAULT_HEADING_SMOOTH_ALPHA = 0.25 # 敵機 heading 低通,避免 X 陣形因航向抖動快速旋轉 DEFAULT_ENCIRCLE_TOLERANCE = 3.0 # 每台距自己 slot <= 此值才算完成包圍 DEFAULT_REASSIGN_IMPROVEMENT = 0.20 # 安全時至少改善 20% 才允許換 slot DEFAULT_REASSIGN_COOLDOWN = 2.0 # slot 交換最短間隔 (s);安全時原則上不換 DEFAULT_REPLAN_MOVE_M = 1.0 # 敵機移動 >= 1m 觸發更新 DEFAULT_REPLAN_CAP_SEC = 0.4 # 最慢 0.4 秒更新一次瞬時目標 DEFAULT_LEAD_TIME_CAP = 4.0 DEFAULT_FRIENDLY_SPEED = 3.0 DEFAULT_ENEMY_VEL_WINDOW = 1.0 DEFAULT_TARGET_LOST_TIMEOUT = 5.0 # assignment cost DEFAULT_CROSSING_PENALTY = 20000.0 DEFAULT_SEPARATION_PENALTY = 30000.0 DEFAULT_SLOT_SWITCH_PENALTY = 8.0 # 舊版相容參數:保留但不再當 leader-follow spacing 使用 DEFAULT_LON_SPACING = 8.0 DEFAULT_LAT_OFFSET = 5.0 _NONE_LABEL = "(未選擇)" class MissionPhase(Enum): IDLE = "idle" WATCH = "watch" DETECTED = "detected" CONVERGE = "converge" # 動態分配 slot + 發散/收斂曲線接近 ENCIRCLE = "encircle" # 所有友機已進入自己的 slot TARGET_LOST = "target_lost" # 目標定位逾時,友機 hold # --------------------------------------------------------------------------- # Mission Orchestrator class MissionOrchestrator(QObject): """ 任務狀態機。 callback: make_executor() -> MissionExecutor gps_provider() -> {drone_id: {'lat','lon','alt'}} stop_groups_fn() -> 操作員確認重編隊時,停止既有 A/B 群組任務 新策略: WATCH -> DETECTED -> CONVERGE -> ENCIRCLE 進入 CONVERGE 後保留 A/B 分層:A/B 各自固定在敵機高度上下層; 同層必要時使用同心圓半徑車道避讓。跨層近距離時先垂直、後水平。 """ phase_changed = pyqtSignal(str) detection_prompt = pyqtSignal(str) status_message = pyqtSignal(str) def __init__( self, make_executor, gps_provider, stop_groups_fn=None, r_detect=DEFAULT_R_DETECT, detect_dwell_sec=DEFAULT_DETECT_DWELL, r_engage=DEFAULT_R_ENGAGE, r_engage_exit=DEFAULT_R_ENGAGE_EXIT, replan_move_m=DEFAULT_REPLAN_MOVE_M, replan_cap_sec=DEFAULT_REPLAN_CAP_SEC, circle_radius=DEFAULT_CIRCLE_RADIUS, lon_spacing=DEFAULT_LON_SPACING, lat_offset=DEFAULT_LAT_OFFSET, formation_alt=DEFAULT_FORMATION_ALT, alt_layer_gap=DEFAULT_ALT_LAYER_GAP, min_alt_floor=DEFAULT_MIN_ALT_FLOOR, lead_time_cap_sec=DEFAULT_LEAD_TIME_CAP, friendly_speed=DEFAULT_FRIENDLY_SPEED, enemy_vel_window_sec=DEFAULT_ENEMY_VEL_WINDOW, spread_distance=DEFAULT_SPREAD_DISTANCE, approach_margin=DEFAULT_APPROACH_MARGIN, lookahead_t=DEFAULT_LOOKAHEAD_T, final_direct_distance=DEFAULT_FINAL_DIRECT_DISTANCE, lead_fade_start=DEFAULT_LEAD_FADE_START, lead_fade_end=DEFAULT_LEAD_FADE_END, center_smooth_alpha=DEFAULT_CENTER_SMOOTH_ALPHA, lookahead_distance=DEFAULT_LOOKAHEAD_DISTANCE, path_samples=DEFAULT_PATH_SAMPLES, safe_separation=DEFAULT_SAFE_SEPARATION, formation_spacing=DEFAULT_FORMATION_SPACING, initial_sep_tolerance=DEFAULT_INITIAL_SEP_TOLERANCE, emergency_separation=DEFAULT_EMERGENCY_SEPARATION, recovery_spread_step=DEFAULT_RECOVERY_SPREAD_STEP, separation_correction_gain=DEFAULT_SEPARATION_CORRECTION_GAIN, separation_correction_max=DEFAULT_SEPARATION_CORRECTION_MAX, direction_smooth_alpha=DEFAULT_DIRECTION_SMOOTH_ALPHA, max_lateral_ratio=DEFAULT_MAX_LATERAL_RATIO, layer_lane_spacing=DEFAULT_LAYER_LANE_SPACING, layer_avoid_distance=DEFAULT_LAYER_AVOID_DISTANCE, layer_lane_fade_distance=DEFAULT_LAYER_LANE_FADE_DISTANCE, heading_smooth_alpha=DEFAULT_HEADING_SMOOTH_ALPHA, encircle_tolerance=DEFAULT_ENCIRCLE_TOLERANCE, reassign_improvement=DEFAULT_REASSIGN_IMPROVEMENT, reassign_cooldown_sec=DEFAULT_REASSIGN_COOLDOWN, target_lost_timeout_sec=DEFAULT_TARGET_LOST_TIMEOUT, crossing_penalty=DEFAULT_CROSSING_PENALTY, separation_penalty=DEFAULT_SEPARATION_PENALTY, slot_switch_penalty=DEFAULT_SLOT_SWITCH_PENALTY, parent=None, cross_layer_hold_distance=DEFAULT_CROSS_LAYER_HOLD_DISTANCE, cross_layer_min_vertical_gap=DEFAULT_CROSS_LAYER_MIN_VERTICAL_GAP, cross_layer_alt_tolerance=DEFAULT_CROSS_LAYER_ALT_TOLERANCE, ): super().__init__(parent) self._make_executor = make_executor self._gps_provider = gps_provider self._stop_groups_fn = stop_groups_fn self.r_detect = float(r_detect) self.detect_dwell_sec = float(detect_dwell_sec) # legacy/compatibility self.r_engage = float(r_engage) self.r_engage_exit = float(r_engage_exit) self.lon_spacing = float(lon_spacing) self.lat_offset = float(lat_offset) self.replan_move_m = float(replan_move_m) self.replan_cap_sec = float(replan_cap_sec) self.circle_radius = float(circle_radius) self.formation_alt = float(formation_alt) self.alt_layer_gap = float(alt_layer_gap) self.min_alt_floor = float(min_alt_floor) self.cross_layer_hold_distance = max( 0.0, float(cross_layer_hold_distance) ) self.cross_layer_min_vertical_gap = max( 0.0, float(cross_layer_min_vertical_gap) ) self.cross_layer_alt_tolerance = max( 0.0, float(cross_layer_alt_tolerance) ) self.lead_time_cap_sec = float(lead_time_cap_sec) self.friendly_speed = float(friendly_speed) self.enemy_vel_window_sec = float(enemy_vel_window_sec) self.spread_distance = float(spread_distance) self.approach_margin = float(approach_margin) self.lookahead_t = min(0.95, max(0.05, float(lookahead_t))) self.final_direct_distance = max(0.0, float(final_direct_distance)) self.lead_fade_start = max(0.0, float(lead_fade_start)) self.lead_fade_end = max(0.0, float(lead_fade_end)) self.center_smooth_alpha = min(1.0, max(0.02, float(center_smooth_alpha))) self.lookahead_distance = max(1.0, float(lookahead_distance)) if self.lead_fade_end > self.lead_fade_start: self.lead_fade_end, self.lead_fade_start = self.lead_fade_start, self.lead_fade_end self.path_samples = max(7, int(path_samples)) self.safe_separation = float(safe_separation) self.formation_spacing = max(self.safe_separation, float(formation_spacing)) self.initial_sep_tolerance = max(0.0, float(initial_sep_tolerance)) self.emergency_separation = max(0.5, float(emergency_separation)) self.recovery_spread_step = max(0.5, float(recovery_spread_step)) self.separation_correction_gain = max(0.0, float(separation_correction_gain)) self.separation_correction_max = max(0.0, float(separation_correction_max)) self.direction_smooth_alpha = min(1.0, max(0.05, float(direction_smooth_alpha))) self.max_lateral_ratio = min(1.0, max(0.05, float(max_lateral_ratio))) self.layer_lane_spacing = max(0.5, float(layer_lane_spacing)) self.layer_avoid_distance = max(self.safe_separation, float(layer_avoid_distance)) self.layer_lane_fade_distance = max(1.0, float(layer_lane_fade_distance)) self.heading_smooth_alpha = min(1.0, max(0.02, float(heading_smooth_alpha))) self.encircle_tolerance = float(encircle_tolerance) self.reassign_improvement = min(0.9, max(0.0, float(reassign_improvement))) self.reassign_cooldown_sec = float(reassign_cooldown_sec) self.target_lost_timeout_sec = float(target_lost_timeout_sec) self.crossing_penalty = float(crossing_penalty) self.separation_penalty = float(separation_penalty) self.slot_switch_penalty = float(slot_switch_penalty) self.phase = MissionPhase.IDLE self.a_drone_ids = [] self.b_drone_ids = [] self.enemy_id = None self._detect_since = 0.0 # 確認後的角色無關友機集合 self._friendly_ids = [] self._merged_ex = None # assignment: {drone_id: slot_index} self._assignment = {} self._last_assignment_change_at = 0.0 self._last_assignment_had_conflict = False # 敵機運動估計 self._enemy_hist = [] self._enemy_vel = (0.0, 0.0) # east, north m/s self._enemy_alt = None # 敵機即時相對高度;缺失時才用 formation_alt self._enemy_heading_deg = None # 0°=North, clockwise;X 陣形以此為基準 self._last_enemy_seen_at = 0.0 # replan / command state self._last_replan_at = 0.0 self._last_plan_enemy = None self._last_predicted_center = None self._last_slots = None self._smoothed_center = None # 每架持續前進方向;避免每次 replan 重新生成 carrot 時航向跳動/煞停 self._motion_dirs = {} self._slot_groups = {} # {slot_index: "A"/"B"} self._vertical_hold_ids = set() # ------------------------------------------------------------------ 設定 def configure(self, a_drone_ids, b_drone_ids, enemy_id): """ WATCH/DETECTED 階段可更新 A/B 與敵機。 一旦進入 CONVERGE/ENCIRCLE,角色集合鎖定,避免 GUI 下拉變動破壞任務。 """ if self.phase in ( MissionPhase.CONVERGE, MissionPhase.ENCIRCLE, MissionPhase.TARGET_LOST, ): return False self.a_drone_ids = list(a_drone_ids) self.b_drone_ids = list(b_drone_ids) new_enemy = enemy_id or None if new_enemy != self.enemy_id: self._detect_since = 0.0 self._enemy_hist = [] self._enemy_vel = (0.0, 0.0) self._enemy_alt = None self._enemy_heading_deg = None self.enemy_id = new_enemy return True def _configured(self): friendly = set(self.a_drone_ids) | set(self.b_drone_ids) return ( bool(self.enemy_id) and bool(self.a_drone_ids) and self.enemy_id not in friendly and not (set(self.a_drone_ids) & set(self.b_drone_ids)) ) # ------------------------------------------------------------------ 操作員確認 / 停止 def confirm_detection(self): """ DETECTED -> CONVERGE。 1. 停止既有 A/B 群組任務。 2. A/B 身分解除,合併為單一 friendly 集合。 3. 計算預測敵機中心與圓形 slots。 4. 做全域 slot assignment。 5. 產生 Bezier lookahead point,啟動合併 executor。 """ if self.phase != MissionPhase.DETECTED: self.status_message.emit("目前不在待確認狀態") return gps = self._gps_provider() epos = gps.get(self.enemy_id) now = time.monotonic() if not epos or not self._position_is_fresh(epos, now): self.status_message.emit("敵機定位遺失,無法開始包圍") return if self._position_alt(epos) is None: self.status_message.emit("敵機相對 Home 高度尚未收到,無法開始包圍") return friendly_ids = self._unique_keep_order(self.a_drone_ids + self.b_drone_ids) if not friendly_ids: self.status_message.emit("沒有可用友機") return if self._positions_for(friendly_ids, gps) is None: self.status_message.emit("有友機尚未定位,無法開始包圍") return if self._stop_groups_fn: try: self._stop_groups_fn() except Exception as e: _log("WARN", f"[orchestrator] 停止群組任務失敗: {e}") self._friendly_ids = friendly_ids self._assignment = {} self._last_assignment_change_at = 0.0 self._last_assignment_had_conflict = False self._enemy_hist = [] self._enemy_vel = (0.0, 0.0) self._enemy_alt = None self._enemy_heading_deg = None self._smoothed_center = None self._motion_dirs = {} self._slot_groups = {} # {slot_index: "A"/"B"} self._vertical_hold_ids = set() self._last_enemy_seen_at = self._position_received_at(epos, now) self._update_enemy_velocity(epos) center = self._planning_center(gps, epos, reset=True) slots = self._circle_slots_for_center(center, len(self._friendly_ids)) assignment, info = self._choose_assignment(gps, center, slots, force=True) self._merged_ex = self._make_executor() if not assignment: # 找不到完全滿足間距的方案時,不再先停/預分散。 # 改採 0-crossing 的最低風險 provisional assignment, # 一邊朝 slot 前進、一邊疊加 separation correction 拉開隊形。 assignment, info = self._choose_progress_assignment(gps, center, slots) if not assignment: self.status_message.emit("目前找不到 0-crossing 路徑,暫不開始包圍") self._merged_ex = None return self._assignment = assignment self._last_assignment_change_at = time.monotonic() self._last_assignment_had_conflict = True targets = self._moving_converge_targets(gps, center, slots, assignment) if targets is None: self.status_message.emit("無法建立移動中隊形調整目標") self._merged_ex = None return self._merged_ex.start(self._targets_to_plan(targets)) self.status_message.emit( "起始間距不足:保持前進,同時在移動中逐步拉開隊形" ) else: self._assignment = assignment self._last_assignment_change_at = time.monotonic() self._last_assignment_had_conflict = info['conflict'] targets = self._moving_converge_targets(gps, center, slots, assignment) self._merged_ex.start(self._targets_to_plan(targets)) self._last_predicted_center = center self._last_slots = slots self._mark_replanned(epos) self._set_phase(MissionPhase.CONVERGE) if assignment: self.status_message.emit("開始動態包圍:slot 鎖定,移動中同步展開隊形") def stop(self): """停止任務並回 IDLE。""" if self._merged_ex is not None: try: self._merged_ex.stop() except Exception: pass self._merged_ex = None self._friendly_ids = [] self._assignment = {} self._detect_since = 0.0 self._enemy_hist = [] self._enemy_vel = (0.0, 0.0) self._enemy_alt = None self._enemy_heading_deg = None self._last_enemy_seen_at = 0.0 self._last_predicted_center = None self._last_slots = None self._smoothed_center = None self._motion_dirs = {} self._slot_groups = {} # {slot_index: "A"/"B"} self._vertical_hold_ids = set() self._set_phase(MissionPhase.IDLE) self.status_message.emit("任務已停止") # ------------------------------------------------------------------ tick def tick(self): """由 GUI 週期呼叫,建議 5~10 Hz。""" if self.phase == MissionPhase.IDLE and self._configured(): self._set_phase(MissionPhase.WATCH) elif self.phase == MissionPhase.WATCH and not self._configured(): self._detect_since = 0.0 self._set_phase(MissionPhase.IDLE) if self.phase == MissionPhase.WATCH: self._tick_watch() elif self.phase == MissionPhase.CONVERGE: self._tick_converge() elif self.phase == MissionPhase.ENCIRCLE: self._tick_encircle() elif self.phase == MissionPhase.TARGET_LOST: self._tick_target_lost() def _tick_watch(self): gps = self._gps_provider() epos = gps.get(self.enemy_id) now = time.monotonic() if not epos or not self._position_is_fresh(epos, now): self._detect_since = 0.0 return nearest_id = None nearest_d = None for did in self.a_drone_ids: pos = gps.get(did) if not pos or not self._position_is_fresh(pos, now): continue d = _haversine(epos['lat'], epos['lon'], pos['lat'], pos['lon']) if nearest_d is None or d < nearest_d: nearest_id, nearest_d = did, d if nearest_d is not None and nearest_d <= self.r_detect: if self._detect_since == 0.0: self._detect_since = now elif now - self._detect_since >= self.detect_dwell_sec: self._set_phase(MissionPhase.DETECTED) self.detection_prompt.emit( f"偵測到敵機({nearest_id} 距 {nearest_d:.1f} m)— 請確認動態包圍" ) else: self._detect_since = 0.0 def _tick_converge(self): gps = self._gps_provider() epos = gps.get(self.enemy_id) now = time.monotonic() if (not epos or not self._position_is_fresh(epos, now) or self._position_alt(epos) is None): self._handle_enemy_missing(gps, now) return self._last_enemy_seen_at = self._position_received_at(epos, now) self._update_enemy_velocity(epos) if self._positions_for(self._friendly_ids, gps) is None: return if self._all_in_slots(gps, epos): self._set_phase(MissionPhase.ENCIRCLE) self.status_message.emit("所有友機已進入各自包圍 slot") # 進 ENCIRCLE 當下仍送一次精確 slot self._update_encircle_command(gps, epos, force=True) return if self._should_replan(now, epos): self._update_converge_command(gps, epos) def _tick_encircle(self): gps = self._gps_provider() epos = gps.get(self.enemy_id) now = time.monotonic() if (not epos or not self._position_is_fresh(epos, now) or self._position_alt(epos) is None): self._handle_enemy_missing(gps, now) return self._last_enemy_seen_at = self._position_received_at(epos, now) self._update_enemy_velocity(epos) if self._positions_for(self._friendly_ids, gps) is None: return # 敵機持續移動,圓心跟著預測位置移動。 # 若任何友機明顯脫離自己的 slot,回 CONVERGE 重新做曲線收斂。 if not self._all_in_slots(gps, epos, tolerance=self.encircle_tolerance * 1.8): self._set_phase(MissionPhase.CONVERGE) self.status_message.emit("包圍幾何失配,重新收斂") self._update_converge_command(gps, epos, force_assignment=False) return if self._should_replan(now, epos): self._update_encircle_command(gps, epos) def _tick_target_lost(self): gps = self._gps_provider() epos = gps.get(self.enemy_id) now = time.monotonic() if (not epos or not self._position_is_fresh(epos, now) or self._position_alt(epos) is None): return # 目標重新出現:不沿用過期軌跡,重新估速並回 CONVERGE。 self._enemy_hist = [] self._enemy_vel = (0.0, 0.0) self._last_enemy_seen_at = self._position_received_at(epos, now) self._update_enemy_velocity(epos) self._set_phase(MissionPhase.CONVERGE) self.status_message.emit("敵機定位恢復,重新建立包圍軌跡") self._update_converge_command(gps, epos, force_assignment=True) # ------------------------------------------------------------------ 敵機遺失 / hold def _handle_enemy_missing(self, gps, now): if self._last_enemy_seen_at <= 0.0: self._last_enemy_seen_at = now return if now - self._last_enemy_seen_at < self.target_lost_timeout_sec: return self._hold_current_positions(gps) self._set_phase(MissionPhase.TARGET_LOST) self.status_message.emit("敵機定位逾時:友機保持目前位置") def _hold_current_positions(self, gps): if self._merged_ex is None: return targets = {} now = time.monotonic() for did in self._friendly_ids: p = gps.get(did) if not p or not self._position_is_fresh(p, now): continue current_alt = self._position_alt(p) if current_alt is None: continue targets[did] = ( p['lat'], p['lon'], max(self.min_alt_floor, current_alt), ) if len(targets) == len(self._friendly_ids): self._merged_ex.update_plan(self._targets_to_plan(targets)) # ------------------------------------------------------------------ 敵機速度 / 預測中心 def _set_enemy_heading(self, heading_deg): """以最短角度差低通更新敵機 heading,正確處理 359°/0° 邊界。""" heading_deg = float(heading_deg) % 360.0 if self._enemy_heading_deg is None: self._enemy_heading_deg = heading_deg return err = ((heading_deg - self._enemy_heading_deg + 180.0) % 360.0) - 180.0 self._enemy_heading_deg = ( self._enemy_heading_deg + self.heading_smooth_alpha * err ) % 360.0 def _update_enemy_velocity(self, epos): """更新敵機高度/heading,並以 GPS 差分估水平速度 (east, north) m/s。""" try: enemy_alt = float(epos.get('alt')) except (TypeError, ValueError): enemy_alt = None if enemy_alt is not None and math.isfinite(enemy_alt): self._enemy_alt = enemy_alt # heading convention:0°=North、90°=East、順時針增加。 # 優先讀 heading/hdg/course;yaw 若像 radians 就自動轉 degree。 raw_heading = None for key in ('heading', 'hdg', 'course'): try: v = epos.get(key) if v is not None and math.isfinite(float(v)): v = float(v) if key == 'hdg' and abs(v) > 360.0: v *= 0.01 raw_heading = v % 360.0 break except (TypeError, ValueError): pass if raw_heading is None: try: v = epos.get('yaw') if v is not None and math.isfinite(float(v)): v = float(v) raw_heading = (math.degrees(v) if abs(v) <= 2.0 * math.pi + 0.2 else v) % 360.0 except (TypeError, ValueError): pass if raw_heading is not None: self._set_enemy_heading(raw_heading) now = time.monotonic() self._enemy_hist.append((now, epos['lat'], epos['lon'])) while ( len(self._enemy_hist) >= 2 and now - self._enemy_hist[0][0] > self.enemy_vel_window_sec ): self._enemy_hist.pop(0) if len(self._enemy_hist) >= 2: t0, la0, lo0 = self._enemy_hist[0] t1, la1, lo1 = self._enemy_hist[-1] dt = t1 - t0 if dt > 1e-3: dx, dy = _ll_to_m(la1, lo1, la0, lo0) self._enemy_vel = (dx / dt, dy / dt) # 若 provider 沒有 heading,以每次更新的水平航跡方向作持續備援; # 不能只在第一次設定,否則敵機轉彎後 X 陣形不會跟著旋轉。 if raw_heading is None: evx, evy = self._enemy_vel if math.hypot(evx, evy) > 0.3: course_deg = math.degrees(math.atan2(evx, evy)) % 360.0 self._set_enemy_heading(course_deg) def enemy_speed(self): return math.hypot(*self._enemy_vel) def _predict_enemy_center(self, gps, epos): """ 預測「包圍圓未來中心」,而不是給 leader 一個追擊點。 lead time 取友機到敵機平均距離 / friendly_speed,並封頂。 """ gaps = [] for did in self._friendly_ids: p = gps.get(did) if p: gaps.append(_haversine(p['lat'], p['lon'], epos['lat'], epos['lon'])) avg_gap = sum(gaps) / len(gaps) if gaps else 0.0 raw_lead_t = min( self.lead_time_cap_sec, avg_gap / max(0.1, self.friendly_speed), ) # 遠距離保留前置攔截;接近包圍圈時逐步把前置量淡出。 # 這樣最終包圍圓會貼著敵機當下位置,不會因敵機速度 × lead time # 讓視覺上/實際上看起來像半徑被拉到 20~30 m。 if self.lead_fade_start <= self.lead_fade_end + 1e-6: lead_scale = 0.0 if avg_gap <= self.lead_fade_end else 1.0 elif avg_gap >= self.lead_fade_start: lead_scale = 1.0 elif avg_gap <= self.lead_fade_end: lead_scale = 0.0 else: lead_scale = (avg_gap - self.lead_fade_end) / ( self.lead_fade_start - self.lead_fade_end ) lead_t = raw_lead_t * lead_scale evx, evy = self._enemy_vel plat, plon = _m_to_ll(evx * lead_t, evy * lead_t, epos['lat'], epos['lon']) return plat, plon def _planning_center(self, gps, epos, reset=False): """ 對預測圓心做低通。只在真正產生命令/初始規劃時呼叫,避免同一 tick 多次查詢時重複濾波。這能明顯降低 moving target/GPS 抖動造成的曲線折感。 """ raw = self._predict_enemy_center(gps, epos) if reset or self._smoothed_center is None: self._smoothed_center = raw return raw plat, plon = self._smoothed_center dx, dy = _ll_to_m(raw[0], raw[1], plat, plon) a = self.center_smooth_alpha filt = _m_to_ll(dx * a, dy * a, plat, plon) self._smoothed_center = filt return filt # ------------------------------------------------------------------ slot 生成 / 高度 def _group_of(self, did): if did in self.a_drone_ids: return "A" if did in self.b_drone_ids: return "B" return None def _group_ids(self, group): src = self.a_drone_ids if group == "A" else self.b_drone_ids friendly = set(self._friendly_ids) return [d for d in src if d in friendly] def _layer_alt(self, group): """以敵機即時高度為中心:A 在上層、B 在下層。""" base_alt = ( self._enemy_alt if self._enemy_alt is not None else self.formation_alt ) half = self.alt_layer_gap / 2.0 lower = max(self.min_alt_floor, base_alt - half) # 敵機高度過低而觸發最低高度限制時,仍保留完整 A/B 垂直間隔。 upper = max(self.min_alt_floor, base_alt + half, lower + self.alt_layer_gap) return upper if group == "A" else lower @staticmethod def _position_alt(pos): """讀取有效相對高度;缺值或非有限值回傳 None。""" if not pos: return None try: alt = float(pos.get('alt')) except (TypeError, ValueError): return None return alt if math.isfinite(alt) else None def _position_is_fresh(self, pos, now=None): """檢查 GPS 水平位置是否仍在可接受的遙測時間內。""" if not pos: return False updated_at = pos.get('_gps_updated_at') # 保留舊 gps_provider/測試資料的相容性;GUI 實飛資料一定帶時間戳。 if updated_at is None: return True try: updated_at = float(updated_at) except (TypeError, ValueError): return False if not math.isfinite(updated_at): return False now = time.monotonic() if now is None else float(now) age = now - updated_at return -0.1 <= age <= self.target_lost_timeout_sec @staticmethod def _position_received_at(pos, now): """取得最後一筆水平定位的 monotonic 時間,舊資料則使用目前時間。""" try: updated_at = float(pos.get('_gps_updated_at')) except (AttributeError, TypeError, ValueError): return now if not math.isfinite(updated_at) or updated_at > now + 0.1: return now return updated_at def _cross_layer_vertical_hold_ids(self, gps): """ 找出必須先原地垂直分層的 A/B 友機。 只有 A/B 水平距離小於 cross_layer_hold_distance 時才介入;待實際 高度符合 A 在 B 上方,且垂直差接近規劃層差後,才解除水平鎖定。 """ hold = set() a_ids = [d for d in self._group_ids("A") if gps.get(d)] b_ids = [d for d in self._group_ids("B") if gps.get(d)] required_gap = min( self._layer_alt("A") - self._layer_alt("B"), self.cross_layer_min_vertical_gap, ) for aid in a_ids: apos = gps[aid] for bid in b_ids: bpos = gps[bid] horizontal = _haversine( apos['lat'], apos['lon'], bpos['lat'], bpos['lon'] ) if horizontal >= self.cross_layer_hold_distance: continue a_alt = self._position_alt(apos) b_alt = self._position_alt(bpos) # 高度缺失時採保守策略:不允許近距離跨層水平接近。 if a_alt is None or b_alt is None or a_alt - b_alt < required_gap: hold.update((aid, bid)) return hold def _apply_cross_layer_vertical_gate(self, targets, gps): """把跨層近距離且未完成垂直分離的水平目標鎖在當前位置。""" hold = self._cross_layer_vertical_hold_ids(gps) for did in hold: p = gps.get(did) if not p or did not in targets: continue targets[did] = (p['lat'], p['lon'], self._layer_alt(self._group_of(did))) self._motion_dirs.pop(did, None) if hold != self._vertical_hold_ids: if hold: labels = ", ".join(sorted(hold)) self.status_message.emit( f"A/B 近距離:{labels} 先垂直分層,再恢復水平移動" ) elif self._vertical_hold_ids: self.status_message.emit("A/B 垂直分層完成,恢復水平移動") self._vertical_hold_ids = hold return targets def _slot_alt(self, slot_index): group = self._slot_groups.get(slot_index, "A") return self._layer_alt(group) def _circle_slots_for_center(self, center, n): """ 以敵機機體座標系建立真正的 X 形包圍。 body-frame: forward = 敵機 heading 0° 軸 right = 敵機右側 90° 軸 四機、A/B 各兩機: A 層:front-right、rear-left B 層:front-left、rear-right heading=0° 時,四個 slot 固定落在 NE / NW / SW / SE 四個對角方向。 """ clat, clon = center slots = {} self._slot_groups = {} heading_deg = self._enemy_heading_deg if self._enemy_heading_deg is not None else 0.0 h = math.radians(heading_deg) # Local map frame: x=East, y=North # heading: 0°=North, 90°=East, clockwise fx, fy = math.sin(h), math.cos(h) rx, ry = math.cos(h), -math.sin(h) s = 1.0 / math.sqrt(2.0) body_dirs = { "front_right": ((fx + rx) * s, (fy + ry) * s), "front_left": ((fx - rx) * s, (fy - ry) * s), "rear_left": ((-fx - rx) * s, (-fy - ry) * s), "rear_right": ((-fx + rx) * s, (-fy + ry) * s), } group_names = { "A": ("front_right", "rear_left"), "B": ("front_left", "rear_right"), } idx = 0 for group in ("A", "B"): gids = self._group_ids(group) m = len(gids) if m <= 0: continue if m == 2: dirs = [body_dirs[name] for name in group_names[group]] else: base_deg = 45.0 if group == "A" else -45.0 dirs = [] for j in range(m): off = math.radians(base_deg + 360.0 * j / m) bx = math.sin(off) by = math.cos(off) dx = bx * rx + by * fx dy = bx * ry + by * fy dirs.append((dx, dy)) for dx, dy in dirs: x = self.circle_radius * dx y = self.circle_radius * dy lat, lon = _m_to_ll(x, y, clat, clon) slots[idx] = (lat, lon, self._layer_alt(group)) self._slot_groups[idx] = group idx += 1 return slots def _lane_offset_for(self, did): """同一層內給每架固定的同心圓半徑車道,避免車道在飛行中交換。""" group = self._group_of(did) gids = self._group_ids(group) if did not in gids or len(gids) <= 1: return 0.0 i = gids.index(did) return (i - (len(gids) - 1) / 2.0) * self.layer_lane_spacing # ------------------------------------------------------------------ Bezier 路徑 def _assignment_target_angles(self, assignment, gps, center, slots): """ 把 slot 角度展開成與友機目前角度相同的連續角域。 當 assignment 保持環形順序時,所有友機的角度插值全程保持順序, 不會因為跨過 -pi/pi 邊界而突然反向或互相超車。 """ rows = [] for did in self._friendly_ids: p = gps.get(did) if not p or did not in assignment: return None x, y = _ll_to_m(p['lat'], p['lon'], center[0], center[1]) rows.append((math.atan2(y, x), did)) rows.sort(key=lambda z: z[0]) src = [r[0] for r in rows] raw_targets = [] prev = None for _, did in rows: slot = slots[assignment[did]] sx, sy = _ll_to_m(slot[0], slot[1], center[0], center[1]) th = math.atan2(sy, sx) if prev is not None: while th <= prev: th += 2.0 * math.pi raw_targets.append(th) prev = th # 全體只加同一個 2pi 倍數,讓總旋轉量最小,同時保持拓樸順序。 best = None best_err = float('inf') for k in range(-2, 3): cand = [th + k * 2.0 * math.pi for th in raw_targets] err = sum((cand[i] - src[i]) ** 2 for i in range(len(src))) if err < best_err: best_err, best = err, cand return {rows[i][1]: best[i] for i in range(len(rows))} def _trajectory_for(self, did, slot_index, gps, center, slots, assignment=None): """ C2 平滑、近似最短路徑的 Cartesian 收斂軌跡。 舊版使用極座標 theta/r 插值,當起點與 slot 的方位角差較大時, 即使沒有交叉,也可能沿敵機外圍繞出很大的弧。 新版改成: base(t) = P0 -> P3 的 quintic smoothstep offset(t) = 小幅「向外」bump 因此主體接近兩點間的直接路徑,只保留少量發散來增加機間分離; crossing / safe separation 仍由全局 assignment 的硬限制負責。 quintic smoothstep 使起終點的一、二階導數為 0,保持平滑。 """ p = gps.get(did) if not p: return None if assignment is None: assignment = self._assignment clat, clon = center p0 = _ll_to_m(p['lat'], p['lon'], clat, clon) slot = slots[slot_index] p3 = _ll_to_m(slot[0], slot[1], clat, clon) dx = p3[0] - p0[0] dy = p3[1] - p0[1] dist_to_slot = math.hypot(dx, dy) # 外擴方向:以敵機/包圍中心為原點,由目前位置向外。 # 若目前位置非常接近中心,退化成與直達方向垂直的方向。 ox, oy = _normalize(p0[0], p0[1], fallback=_normalize(-dy, dx)) # 不再隨距離大幅外擴。 # 最多 spread_distance(預設 3m),近距離時再自動縮小。 bump = min( self.spread_distance, max(0.0, 0.08 * dist_to_slot), ) points = [] for i in range(self.path_samples): t = i / (self.path_samples - 1) q = _smoothstep5(t) # 近似直達的 C2 smoothstep 主路徑。 bx = p0[0] + dx * q by = p0[1] + dy * q # 小幅外擴:首尾為 0,中間最大。 # 使用 q^2(1-q)^2 讓 offset 在兩端也非常柔順。 shape = 16.0 * q * q * (1.0 - q) * (1.0 - q) x = bx + ox * bump * shape y = by + oy * bump * shape points.append((x, y)) return { 'p0': points[0], 'p3': points[-1], 'points': points, 'length': _polyline_length(points), } def _lookahead_target(self, did, slot_index, gps, center, slots): traj = self._trajectory_for( did, slot_index, gps, center, slots, assignment=self._assignment ) if traj is None: return None dist_to_slot = math.hypot( traj['p3'][0] - traj['p0'][0], traj['p3'][1] - traj['p0'][1], ) if dist_to_slot <= self.final_direct_distance: slot = slots[slot_index] return slot[0], slot[1], slot[2] # 固定「公尺」前視,比固定參數 t 更不容易因曲線長短改變而突然加減速。 want = min(self.lookahead_distance, max(6.0, 0.70 * dist_to_slot)) acc = 0.0 chosen = traj['points'][-1] for a, b in zip(traj['points'], traj['points'][1:]): seg = math.hypot(b[0] - a[0], b[1] - a[1]) if acc + seg >= want and seg > 1e-9: u = (want - acc) / seg chosen = (a[0] + u * (b[0] - a[0]), a[1] + u * (b[1] - a[1])) break acc += seg lat, lon = _m_to_ll(chosen[0], chosen[1], center[0], center[1]) cur_alt = self._position_alt(gps[did]) if cur_alt is None: return None target_alt = slots[slot_index][2] # 高度用距離比例平滑,但近場直接交給 slot。 frac = min(1.0, want / max(1e-6, traj['length'])) q = _smoothstep5(frac) alt = cur_alt + q * (target_alt - cur_alt) return lat, lon, max(self.min_alt_floor, alt) def _converge_targets(self, gps, center, slots, assignment): targets = {} for did in self._friendly_ids: sidx = assignment.get(did) if sidx is None: return None target = self._lookahead_target(did, sidx, gps, center, slots) if target is None: return None targets[did] = target return targets def _separation_correction_xy(self, did, gps, ref_lat, ref_lon): """目前太靠近其他友機時,計算小幅排斥修正;不取代前進目標,只疊加在其上。""" p = gps.get(did) if not p: return 0.0, 0.0 x, y = _ll_to_m(p['lat'], p['lon'], ref_lat, ref_lon) cx = cy = 0.0 my_group = self._group_of(did) for j, oid in enumerate(self._friendly_ids): if oid == did or self._group_of(oid) != my_group: continue op = gps.get(oid) if not op: continue ox, oy = _ll_to_m(op['lat'], op['lon'], ref_lat, ref_lon) dx, dy = x - ox, y - oy d = math.hypot(dx, dy) # 8m 是硬安全門檻,但隊形不等到 8m 才開始整理; # 在 formation_spacing(預設 12m)內就逐步往外展開。 if d >= self.formation_spacing: continue if d < 1e-6: # 完全重合時用固定但每機不同的方向,避免除零。 idx = self._friendly_ids.index(did) th = 2.0 * math.pi * idx / max(1, len(self._friendly_ids)) ux, uy = math.cos(th), math.sin(th) d = 0.0 else: ux, uy = dx / d, dy / d # 以「隊形希望間距」而非硬安全距離算修正,因此會更早開始展開。 deficit = self.formation_spacing - d closeness = deficit / max(0.1, self.formation_spacing) boost = 1.0 + 0.7 * closeness mag = 0.5 * self.separation_correction_gain * deficit * boost cx += ux * mag cy += uy * mag n = math.hypot(cx, cy) if n > self.separation_correction_max > 0.0: k = self.separation_correction_max / n cx *= k cy *= k return cx, cy def _segments_cross_for_targets(self, gps, targets, ref_lat, ref_lon): """只檢查『當前位置 -> 下一個 lookahead』短線段是否彼此相交。""" ids = [d for d in self._friendly_ids if d in targets and gps.get(d)] segs = {} for did in ids: p = gps[did] a = _ll_to_m(p['lat'], p['lon'], ref_lat, ref_lon) t = targets[did] b = _ll_to_m(t[0], t[1], ref_lat, ref_lon) segs[did] = (a, b) for i in range(len(ids)): for j in range(i + 1, len(ids)): if self._group_of(ids[i]) != self._group_of(ids[j]): continue if _segments_intersect(segs[ids[i]][0], segs[ids[i]][1], segs[ids[j]][0], segs[ids[j]][1]): return True return False def _layer_conflict_flags(self, gps, center, slots, assignment): """判斷各高度層是否需要啟用同心圓車道。跨層完全不互相觸發。""" flags = {"A": False, "B": False} ref_lat, ref_lon = center for group in ("A", "B"): gids = [d for d in self._group_ids(group) if d in assignment and gps.get(d)] for i in range(len(gids)): di = gids[i] pi = gps[di] aix = _ll_to_m(pi['lat'], pi['lon'], ref_lat, ref_lon) si = slots[assignment[di]] bix = _ll_to_m(si[0], si[1], ref_lat, ref_lon) for j in range(i + 1, len(gids)): dj = gids[j] pj = gps[dj] ajx = _ll_to_m(pj['lat'], pj['lon'], ref_lat, ref_lon) sj = slots[assignment[dj]] bjx = _ll_to_m(sj[0], sj[1], ref_lat, ref_lon) d = math.hypot(aix[0] - ajx[0], aix[1] - ajx[1]) if d < self.layer_avoid_distance or _segments_intersect(aix, bix, ajx, bjx): flags[group] = True break if flags[group]: break return flags def _concentric_lane_carrot(self, did, p, slot, center, dist_to_slot): """ 同層避讓用的同心圓 carrot。 - 每架固定一條暫時半徑車道。 - 沿角向前進,同時平滑靠近該車道半徑。 - 接近最終 slot 時 lane offset 淡出為 0,回到共同包圍半徑。 """ ref_lat, ref_lon = center px, py = _ll_to_m(p['lat'], p['lon'], ref_lat, ref_lon) sx, sy = _ll_to_m(slot[0], slot[1], ref_lat, ref_lon) r = max(0.5, math.hypot(px, py)) th = math.atan2(py, px) sth = math.atan2(sy, sx) dth = _wrap_pi(sth - th) # 遠處完整使用分層車道;進入 slot 前逐步收回共同半徑。 fade = min(1.0, max(0.0, (dist_to_slot - self.final_direct_distance) / max(1.0, self.layer_lane_fade_distance - self.final_direct_distance) )) lane_r = max(2.0, self.circle_radius + self._lane_offset_for(did) * fade) horizon = min(self.lookahead_distance, max(4.0, dist_to_slot)) # 約 70% 用於角向前進、30% 用於半徑收斂,避免突然徑向橫切。 arc_budget = 0.72 * horizon radial_budget = 0.35 * horizon th_step = min(abs(dth), arc_budget / max(2.0, r)) nth = th + (1.0 if dth >= 0.0 else -1.0) * th_step dr = max(-radial_budget, min(radial_budget, lane_r - r)) nr = max(2.0, r + dr) tx = nr * math.cos(nth) ty = nr * math.sin(nth) return tx, ty def _moving_converge_targets(self, gps, center, slots, assignment): """ A/B 分層持續移動控制: - A/B 高度層跟隨敵機即時高度。 - 跨層近距離且垂直間隔不足時,先鎖住水平位置完成分層。 - 同層若過近或直達線可能交叉,切換到不同半徑的同心圓車道。 - 非跨層垂直分離狀態時,持續保持前方 carrot。 """ ref_lat, ref_lon = center layer_avoid = self._layer_conflict_flags(gps, center, slots, assignment) raw = {} for did in self._friendly_ids: p = gps.get(did) sidx = assignment.get(did) if not p or sidx is None: return None slot = slots[sidx] px, py = _ll_to_m(p['lat'], p['lon'], ref_lat, ref_lon) sx, sy = _ll_to_m(slot[0], slot[1], ref_lat, ref_lon) vx, vy = sx - px, sy - py dist = math.hypot(vx, vy) if dist <= self.final_direct_distance: raw[did] = (slot[0], slot[1], self._layer_alt(self._group_of(did))) continue group = self._group_of(did) if layer_avoid.get(group, False): tx, ty = self._concentric_lane_carrot(did, p, slot, center, dist) dux, duy = _normalize(tx - px, ty - py) else: dux, duy = _normalize(vx, vy) horizon = min(self.lookahead_distance, dist) tx, ty = px + dux * horizon, py + duy * horizon # 同層近距離排斥仍保留,但只取側向,不會抵消前進分量。 rx, ry = self._separation_correction_xy(did, gps, ref_lat, ref_lon) dot = rx * dux + ry * duy lx, ly = rx - dot * dux, ry - dot * duy ln = math.hypot(lx, ly) cap = min(self.separation_correction_max, self.max_lateral_ratio * self.lookahead_distance) if ln > cap > 0.0: k = cap / ln lx *= k; ly *= k tx += lx ty += ly ndx, ndy = _normalize(tx - px, ty - py, fallback=(dux, duy)) prev = self._motion_dirs.get(did) if prev is not None: a = self.direction_smooth_alpha ndx, ndy = _normalize(prev[0]*(1-a)+ndx*a, prev[1]*(1-a)+ndy*a, fallback=(ndx, ndy)) h = min(self.lookahead_distance, max(4.0, dist)) tx, ty = px + ndx*h, py + ndy*h self._motion_dirs[did] = (ndx, ndy) lat, lon = _m_to_ll(tx, ty, ref_lat, ref_lon) target_alt = self._layer_alt(group) cur_alt = self._position_alt(p) if cur_alt is None: return None alt = cur_alt + 0.35 * (target_alt - cur_alt) raw[did] = (lat, lon, max(self.min_alt_floor, alt)) return self._apply_cross_layer_vertical_gate(raw, gps) def _recovery_spread_targets(self, gps): """ 找不到安全 slot assignment 時的短距離預分散。 每架只沿「友軍形心 -> 自己」方向往外移 recovery_spread_step; 不繞敵機、不做大圓,只把原本過密的起始幾何稍微拉開。 """ pts = [] for did in self._friendly_ids: p = gps.get(did) if not p: return None pts.append((p['lat'], p['lon'])) if not pts: return None ref_lat = sum(p[0] for p in pts) / len(pts) ref_lon = sum(p[1] for p in pts) / len(pts) xy = {} for did in self._friendly_ids: p = gps[did] xy[did] = _ll_to_m(p['lat'], p['lon'], ref_lat, ref_lon) cx = sum(v[0] for v in xy.values()) / len(xy) cy = sum(v[1] for v in xy.values()) / len(xy) targets = {} n = len(self._friendly_ids) for idx, did in enumerate(self._friendly_ids): x, y = xy[did] ox, oy = x - cx, y - cy if math.hypot(ox, oy) < 1e-6: th = 2.0 * math.pi * idx / max(1, n) ox, oy = math.cos(th), math.sin(th) ox, oy = _normalize(ox, oy) tx = x + ox * self.recovery_spread_step ty = y + oy * self.recovery_spread_step lat, lon = _m_to_ll(tx, ty, ref_lat, ref_lon) current_alt = self._position_alt(gps[did]) if current_alt is None: return None alt = max(self.min_alt_floor, current_alt) targets[did] = (lat, lon, alt) return targets # ------------------------------------------------------------------ assignment / crossing / separation def _assignment_cost(self, assignment, gps, center, slots): """ 評估一個完整 assignment。 crossing 仍是硬限制。水平間距改成「漸進安全」: - 若兩機起始已 >= safe_separation,後續不得低於 safe_separation。 - 若起始本來 < safe_separation,不要求瞬間跳到 8m;只要求路徑不要 比起始距離再惡化超過 initial_sep_tolerance,並且永遠不得低於 emergency_separation。 這避免四機原本只隔 4~7m 時,所有 assignment 在第一個 sample 就被 8m 硬門檻全部淘汰。 """ trajectories = {} total = 0.0 for did in self._friendly_ids: sidx = assignment[did] traj = self._trajectory_for(did, sidx, gps, center, slots, assignment=assignment) if traj is None: return float('inf'), True, 0, 0.0 trajectories[did] = traj total += traj['length'] old = self._assignment.get(did) if old is not None and old != sidx: total += self.slot_switch_penalty crossing_count = 0 separation_violation = False min_sep = float('inf') ids = self._friendly_ids for i in range(len(ids)): for j in range(i + 1, len(ids)): # A/B 高度層不同:只對同層做水平 crossing / separation 約束。 if self._group_of(ids[i]) != self._group_of(ids[j]): continue a = trajectories[ids[i]]['points'] b = trajectories[ids[j]]['points'] # 幾何 crossing:從頭到尾都不允許真正穿越。 crossed = False for a0, a1 in zip(a, a[1:]): if crossed: break for b0, b1 in zip(b, b[1:]): if _segments_intersect(a0, a1, b0, b1): crossed = True break if crossed: crossing_count += 1 total += self.crossing_penalty # 起始實際水平距離。 initial_sep = math.hypot(a[0][0] - b[0][0], a[0][1] - b[0][1]) # 已經足夠遠:維持完整 8m;本來較近:不能再明顯靠得更近。 if initial_sep >= self.safe_separation: required_sep = self.safe_separation else: required_sep = max( self.emergency_separation, initial_sep - self.initial_sep_tolerance, ) # 同 normalized t 的預測水平距離。略過 t=0,因為那是既成狀態。 pair_min = float('inf') for k in range(1, min(len(a), len(b))): d = math.hypot(a[k][0] - b[k][0], a[k][1] - b[k][1]) pair_min = min(pair_min, d) min_sep = min(min_sep, pair_min) if pair_min < required_sep: separation_violation = True ratio = (required_sep - pair_min) / max(0.1, required_sep) total += self.separation_penalty * (1.0 + ratio) if min_sep == float('inf'): min_sep = 9999.0 conflict = crossing_count > 0 or separation_violation return total, conflict, crossing_count, min_sep def _cyclic_assignments(self, gps, center, slots): """A/B 各自在自己的高度層做 cyclic slot rotation,再取笛卡兒積組合。""" per_group = [] for group in ("A", "B"): ids = self._group_ids(group) if not ids: continue rows = [] for did in ids: p = gps.get(did) if not p: return [] x, y = _ll_to_m(p['lat'], p['lon'], center[0], center[1]) rows.append((math.atan2(y, x), did)) rows.sort(key=lambda z: z[0]) ordered_ids = [d for _, d in rows] slot_rows = [] for sidx, sp in slots.items(): if self._slot_groups.get(sidx) != group: continue x, y = _ll_to_m(sp[0], sp[1], center[0], center[1]) slot_rows.append((math.atan2(y, x), sidx)) slot_rows.sort(key=lambda z: z[0]) ordered_slots = [s for _, s in slot_rows] if len(ordered_slots) != len(ordered_ids): return [] group_candidates = [] m = len(ordered_ids) for shift in range(m): group_candidates.append({ ordered_ids[i]: ordered_slots[(i + shift) % m] for i in range(m) }) per_group.append(group_candidates) if not per_group: return [] out = [] for combo in product(*per_group): merged = {} for part in combo: merged.update(part) out.append(merged) return out def _group_permutation_assignments(self, slots): """只允許 A->A slots、B->B slots;小隊內可做完整 permutation 保底。""" per_group = [] for group in ("A", "B"): ids = self._group_ids(group) if not ids: continue sidxs = [s for s in slots if self._slot_groups.get(s) == group] if len(ids) != len(sidxs): return [] if len(ids) > 6: return [] gc = [] for perm in permutations(sidxs): gc.append({did: perm[i] for i, did in enumerate(ids)}) per_group.append(gc) if not per_group: return [] out=[] for combo in product(*per_group): d={} for part in combo: d.update(part) out.append(d) return out def _choose_assignment(self, gps, center, slots, force=False): """ Crossing 是硬限制,不再只是 penalty。 優先搜尋保持環形順序的 n 個 rotation;只有找不到安全解時才擴大到全部 permutation。任何 crossings > 0 或違反『漸進安全間距』的方案都不採用。 若目前 assignment 仍安全就鎖住,不為了省幾公尺頻繁換位。 """ ids = list(self._friendly_ids) n = len(ids) bad = {'conflict': True, 'crossings': 999, 'min_sep': 0.0, 'cost': float('inf')} if n == 0 or len(slots) != n: return None, bad # 一旦開始移動,只要目前 assignment 的「幾何路徑沒有交叉」就鎖定。 # 暫時的間距不足交給 moving separation correction 處理,不因此突然換 slot。 # 這可避免飛行途中目標位置跳到另一側而先煞停再重新排隊。 if self._assignment and not force: cost, conflict, crossings, min_sep = self._assignment_cost( self._assignment, gps, center, slots ) if crossings == 0: return dict(self._assignment), { 'cost': cost, 'conflict': conflict, 'crossings': 0, 'min_sep': min_sep, } candidates = self._cyclic_assignments(gps, center, slots) # cyclic 不足時只擴大到「各層內」的 permutation,不允許 A/B 交換高度層。 all_perm = self._group_permutation_assignments(slots) seen = {tuple(c.get(d) for d in ids) for c in candidates} candidates += [c for c in all_perm if tuple(c.get(d) for d in ids) not in seen] valid = [] for assignment in candidates: cost, conflict, crossings, min_sep = self._assignment_cost( assignment, gps, center, slots ) # 幾何不交叉 + 漸進安全間距,兩者都是硬條件。 if crossings == 0 and not conflict: valid.append((cost, -min_sep, assignment, { 'cost': cost, 'conflict': False, 'crossings': 0, 'min_sep': min_sep, })) if not valid: return None, bad valid.sort(key=lambda x: (x[0], x[1])) return valid[0][2], valid[0][3] def _choose_progress_assignment(self, gps, center, slots): """ 找不到完整安全間距方案時的移動中保底。 crossing 仍是硬限制:只從 0-crossing assignment 中選最低風險方案。 允許暫時低於 safe_separation,之後由 moving separation correction 在持續前進的同時把距離拉開。 """ ids = list(self._friendly_ids) n = len(ids) bad = {'conflict': True, 'crossings': 999, 'min_sep': 0.0, 'cost': float('inf')} if n == 0 or len(slots) != n: return None, bad candidates = self._cyclic_assignments(gps, center, slots) all_perm = self._group_permutation_assignments(slots) seen = {tuple(c.get(d) for d in ids) for c in candidates} candidates += [c for c in all_perm if tuple(c.get(d) for d in ids) not in seen] valid = [] for assignment in candidates: cost, conflict, crossings, min_sep = self._assignment_cost( assignment, gps, center, slots ) if crossings != 0: continue # 先偏好較大的預測最小間距,再看路徑成本;不接受幾何交叉。 valid.append((-min_sep, cost, assignment, { 'cost': cost, 'conflict': conflict, 'crossings': 0, 'min_sep': min_sep, })) if not valid: return None, bad valid.sort(key=lambda x: (x[0], x[1])) return valid[0][2], valid[0][3] def _greedy_assignment(self, gps, center, slots): """大於 7 機時的保底 nearest-slot greedy。""" remain = set(slots.keys()) assignment = {} for did in self._friendly_ids: p = gps.get(did) if not p: continue best_s = min( remain, key=lambda s: _haversine( p['lat'], p['lon'], slots[s][0], slots[s][1] ), ) assignment[did] = best_s remain.remove(best_s) return assignment # ------------------------------------------------------------------ command 更新 def _update_converge_command(self, gps, epos, force_assignment=False): if self._merged_ex is None: return center = self._planning_center(gps, epos) slots = self._circle_slots_for_center(center, len(self._friendly_ids)) new_assignment, info = self._choose_assignment( gps, center, slots, force=force_assignment ) if not new_assignment: # 若任務已經在移動中,優先沿用既有 slot,不在半路 hold。 # 只有「一開始尚未建立任何 assignment」時才找 provisional 0-crossing 配置。 if self._assignment: new_assignment = dict(self._assignment) _, conflict, crossings, min_sep = self._assignment_cost( new_assignment, gps, center, slots ) info = { 'cost': 0.0, 'conflict': conflict, 'crossings': crossings, 'min_sep': min_sep, } else: new_assignment, info = self._choose_progress_assignment(gps, center, slots) if not new_assignment: # 尚未開始飛行時若連 0-crossing topology 都沒有,才暫停。 self._hold_current_positions(gps) self.status_message.emit("起始暫停:目前連 0-crossing 路徑都不存在") return changed = new_assignment != self._assignment if changed: self._assignment = new_assignment self._last_assignment_change_at = time.monotonic() msg = ( f"slot 重新分配:cross={info['crossings']}," f"預測最小間距={info['min_sep']:.1f} m" ) _log("INFO", f"[orchestrator] {msg}") self.status_message.emit(msg) self._last_assignment_had_conflict = info['conflict'] targets = self._moving_converge_targets(gps, center, slots, self._assignment) if targets is None: return self._merged_ex.update_plan(self._targets_to_plan(targets)) self._last_predicted_center = center self._last_slots = slots self._mark_replanned(epos) def _update_encircle_command(self, gps, epos, force=False): if self._merged_ex is None: return now = time.monotonic() if not force and not self._should_replan(now, epos): return center = self._planning_center(gps, epos) slots = self._circle_slots_for_center(center, len(self._friendly_ids)) # ENCIRCLE 內原則上不交換 slot,以免四機繞圈互換造成穿越。 # 只有現有 assignment 已無效才重新建立。 if not self._assignment or any(d not in self._assignment for d in self._friendly_ids): assignment, _ = self._choose_assignment(gps, center, slots, force=True) if assignment: self._assignment = assignment self._last_assignment_change_at = now targets = { did: slots[self._assignment[did]] for did in self._friendly_ids if did in self._assignment } if len(targets) != len(self._friendly_ids): return targets = self._apply_cross_layer_vertical_gate(targets, gps) self._merged_ex.update_plan(self._targets_to_plan(targets)) self._last_predicted_center = center self._last_slots = slots self._mark_replanned(epos) def _targets_to_plan(self, targets): return { 'drone_ids': list(self._friendly_ids), 'waypoints': [[targets[d]] for d in self._friendly_ids], 'rendezvous_indices': [], } # ------------------------------------------------------------------ 包圍完成判斷 def _all_in_slots(self, gps, epos, tolerance=None): if not self._assignment or not self._friendly_ids: return False tolerance = self.encircle_tolerance if tolerance is None else float(tolerance) # 水平 slot 即使已到達,只要近距離 A/B 尚未建立足夠垂直差,仍不可 # 宣告包圍完成或提前解除「先垂直、後水平」保護。 if self._cross_layer_vertical_hold_ids(gps): return False center = self._last_predicted_center or self._predict_enemy_center(gps, epos) slots = self._circle_slots_for_center(center, len(self._friendly_ids)) for did in self._friendly_ids: p = gps.get(did) sidx = self._assignment.get(did) if not p or sidx is None: return False slot = slots[sidx] d = _haversine(p['lat'], p['lon'], slot[0], slot[1]) if d > tolerance: return False current_alt = self._position_alt(p) if (current_alt is None or abs(current_alt - slot[2]) > self.cross_layer_alt_tolerance): return False return True # ------------------------------------------------------------------ replan def _mark_replanned(self, epos): self._last_replan_at = time.monotonic() self._last_plan_enemy = (epos['lat'], epos['lon']) def _should_replan(self, now, epos): if self._last_plan_enemy is None: return True moved = _haversine( self._last_plan_enemy[0], self._last_plan_enemy[1], epos['lat'], epos['lon'] ) return ( moved >= self.replan_move_m or (now - self._last_replan_at) >= self.replan_cap_sec ) # ------------------------------------------------------------------ helpers def _positions_for(self, drone_ids, gps): out = [] now = time.monotonic() for did in drone_ids: p = gps.get(did) if not p or not self._position_is_fresh(p, now): return None try: lat = float(p['lat']) lon = float(p['lon']) except (KeyError, TypeError, ValueError): return None alt = self._position_alt(p) if not math.isfinite(lat) or not math.isfinite(lon) or alt is None: return None out.append((lat, lon, alt)) return out @staticmethod def _unique_keep_order(items): seen = set() out = [] for x in items: if x in seen: continue seen.add(x) out.append(x) return out def _set_phase(self, phase): if phase == self.phase: return self.phase = phase _log("INFO", f"[orchestrator] phase -> {phase.value}") self.phase_changed.emit(phase.value) # --------------------------------------------------------------------------- # Engagement Panel class EngagementPanel(QWidget): """ 交戰控制面板。 - 敵機下拉 + 即時敵機到最近友機距離 - A/B 僅用於偵察前的群組指定 - 偵測確認後由 MissionOrchestrator 合併為無角色 friendly 集合 - 速度比例 UI 保留,方便既有 GUI 發 DO_CHANGE_SPEED """ enemy_changed = pyqtSignal(str) confirm_requested = pyqtSignal() stop_requested = pyqtSignal() groups_changed = pyqtSignal(str, str) apply_speed_requested = pyqtSignal(float, float) def __init__(self, parent=None, r_detect=DEFAULT_R_DETECT, friendly_speed=DEFAULT_FRIENDLY_SPEED): super().__init__(parent) self.r_detect = float(r_detect) self._init_friendly_speed = float(friendly_speed) self._enemy_id = "" self._suppress_enemy = False self._suppress_group = False self._group_sig = None self._build_ui() # ------------------------------------------------------------------ UI def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(12, 12, 12, 12) root.setSpacing(10) root.setAlignment(Qt.AlignmentFlag.AlignTop) title = QLabel("交戰設定") title.setStyleSheet( "color: #DDD; font-size: 14px; font-weight: bold; padding: 2px;" ) root.addWidget(title) mode_hint = QLabel( "偵測確認後:A/B 跟隨敵機高度分層;近距離先垂直後水平," "同層以同心圓路徑避讓" ) mode_hint.setWordWrap(True) mode_hint.setStyleSheet("color: #888; font-size: 11px;") root.addWidget(mode_hint) combo_css = ( "QComboBox { background-color: #333; color: #EEE; border: 1px solid #555;" " border-radius: 3px; padding: 3px 6px; font-size: 12px; min-width: 120px; }" ) enemy_row = QHBoxLayout() enemy_row.setSpacing(8) el = QLabel("敵機 sysid:") el.setStyleSheet("color: #CCC; font-size: 12px;") self.enemy_combo = QComboBox() self.enemy_combo.setStyleSheet(combo_css) self.enemy_combo.addItem(_NONE_LABEL) self.enemy_combo.currentTextChanged.connect(self._on_enemy_changed) enemy_row.addWidget(el) enemy_row.addWidget(self.enemy_combo) enemy_row.addStretch() root.addLayout(enemy_row) a_row = QHBoxLayout() a_row.setSpacing(8) al = QLabel("偵察組 A:") al.setStyleSheet("color: #CCC; font-size: 12px;") self.a_group_combo = QComboBox() self.a_group_combo.setStyleSheet(combo_css) self.a_group_combo.addItem(_NONE_LABEL, None) self.a_group_combo.currentTextChanged.connect(self._on_group_changed) a_row.addWidget(al) a_row.addWidget(self.a_group_combo) a_row.addStretch() root.addLayout(a_row) b_row = QHBoxLayout() b_row.setSpacing(8) bl = QLabel("待機組 B:") bl.setStyleSheet("color: #CCC; font-size: 12px;") self.b_group_combo = QComboBox() self.b_group_combo.setStyleSheet(combo_css) self.b_group_combo.addItem(_NONE_LABEL, None) self.b_group_combo.currentTextChanged.connect(self._on_group_changed) b_row.addWidget(bl) b_row.addWidget(self.b_group_combo) b_row.addStretch() root.addLayout(b_row) spin_css = ( "QDoubleSpinBox { background-color: #333; color: #EEE;" " border: 1px solid #555; border-radius: 3px; padding: 2px 4px;" " font-size: 12px; min-width: 70px; }" ) speed_row = QHBoxLayout() speed_row.setSpacing(8) sl = QLabel("友機速度:") sl.setStyleSheet("color: #CCC; font-size: 12px;") self.friendly_speed_spin = QDoubleSpinBox() self.friendly_speed_spin.setStyleSheet(spin_css) self.friendly_speed_spin.setRange(0.5, 20.0) self.friendly_speed_spin.setSingleStep(0.5) self.friendly_speed_spin.setDecimals(1) self.friendly_speed_spin.setSuffix(" m/s") self.friendly_speed_spin.setValue(self._init_friendly_speed) rl = QLabel("敵/友比例:") rl.setStyleSheet("color: #CCC; font-size: 12px;") self.enemy_ratio_spin = QDoubleSpinBox() self.enemy_ratio_spin.setStyleSheet(spin_css) self.enemy_ratio_spin.setRange(0.1, 1.0) self.enemy_ratio_spin.setSingleStep(0.05) self.enemy_ratio_spin.setDecimals(2) self.enemy_ratio_spin.setValue(0.6) speed_row.addWidget(sl) speed_row.addWidget(self.friendly_speed_spin) speed_row.addWidget(rl) speed_row.addWidget(self.enemy_ratio_spin) speed_row.addStretch() root.addLayout(speed_row) apply_speed_row = QHBoxLayout() self.apply_speed_btn = QPushButton("套用速度") self.apply_speed_btn.setStyleSheet( "QPushButton { background-color: #00796B; color: white; border: none;" " padding: 6px 14px; border-radius: 4px; font-weight: bold; }" " QPushButton:hover { background-color: #00897B; }" ) self.apply_speed_btn.clicked.connect(self._on_apply_speed) self.enemy_speed_hint = QLabel("") self.enemy_speed_hint.setStyleSheet("color: #777; font-size: 11px;") self._refresh_enemy_speed_hint() self.friendly_speed_spin.valueChanged.connect(self._refresh_enemy_speed_hint) self.enemy_ratio_spin.valueChanged.connect(self._refresh_enemy_speed_hint) apply_speed_row.addWidget(self.apply_speed_btn) apply_speed_row.addWidget(self.enemy_speed_hint) apply_speed_row.addStretch() root.addLayout(apply_speed_row) sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) sep.setStyleSheet("color: #444;") root.addWidget(sep) self.distance_label = QLabel("敵機 ↔ 最近友機:—") self.distance_label.setStyleSheet( "color: #AAA; font-size: 13px; padding: 4px 2px;" ) root.addWidget(self.distance_label) hint = QLabel(f"(偵測範圍 R_detect = {self.r_detect:.0f} m)") hint.setStyleSheet("color: #777; font-size: 11px;") root.addWidget(hint) self.phase_label = QLabel("階段:待機") self.phase_label.setStyleSheet( "color: #64B5F6; font-size: 13px; font-weight: bold; padding: 4px 2px;" ) root.addWidget(self.phase_label) btn_css = ( "QPushButton {{ background-color: {bg}; color: {fg}; border: none;" " padding: 8px 16px; border-radius: 4px; font-weight: bold; }}" " QPushButton:hover {{ background-color: {hover}; }}" " QPushButton:disabled {{ background-color: #444; color: #888; }}" ) btn_row = QHBoxLayout() btn_row.setSpacing(8) self.confirm_btn = QPushButton("開始動態包圍") self.confirm_btn.setStyleSheet( btn_css.format(bg='#F57C00', fg='white', hover='#FB8C00') ) self.confirm_btn.clicked.connect(lambda: self.confirm_requested.emit()) self.confirm_btn.setEnabled(False) self.stop_btn = QPushButton("停止") self.stop_btn.setStyleSheet( btn_css.format(bg='#555', fg='#DDD', hover='#666') ) self.stop_btn.clicked.connect(lambda: self.stop_requested.emit()) btn_row.addWidget(self.confirm_btn) btn_row.addWidget(self.stop_btn) btn_row.addStretch() root.addLayout(btn_row) # ------------------------------------------------------------------ 對外 API def current_enemy(self): return self._enemy_id or None def refresh_drone_list(self, drone_ids): items = [_NONE_LABEL] + sorted(drone_ids) current = self._enemy_id if self._enemy_id in drone_ids else "" self._suppress_enemy = True self.enemy_combo.clear() self.enemy_combo.addItems(items) idx = self.enemy_combo.findText(current) if current else 0 self.enemy_combo.setCurrentIndex(idx if idx >= 0 else 0) self._suppress_enemy = False if current != self._enemy_id: self._enemy_id = current self.enemy_changed.emit(self._enemy_id) def refresh_group_list(self, group_labels): """group_labels: list[(group_id, display_text)]。""" sig = tuple(group_labels) if sig == self._group_sig: return self._group_sig = sig self._suppress_group = True for combo in (self.a_group_combo, self.b_group_combo): prev = combo.currentData() combo.clear() combo.addItem(_NONE_LABEL, None) for gid, text in group_labels: combo.addItem(text, gid) if prev is not None: i = combo.findData(prev) combo.setCurrentIndex(i if i >= 0 else 0) self._suppress_group = False def selected_group_ids(self): return self.a_group_combo.currentData(), self.b_group_combo.currentData() def set_configuration(self, enemy_id, a_group_id, b_group_id): """由 GUI 程式化選定敵機與 A/B 群組,並發出一次同步訊號。""" enemy_index = self.enemy_combo.findText(enemy_id) a_index = self.a_group_combo.findData(a_group_id) b_index = self.b_group_combo.findData(b_group_id) if min(enemy_index, a_index, b_index) < 0: return False self._suppress_enemy = True self._suppress_group = True try: self.enemy_combo.setCurrentIndex(enemy_index) self.a_group_combo.setCurrentIndex(a_index) self.b_group_combo.setCurrentIndex(b_index) self._enemy_id = enemy_id finally: self._suppress_enemy = False self._suppress_group = False self.enemy_changed.emit(enemy_id) self.groups_changed.emit(a_group_id, b_group_id) return True def set_phase_text(self, text): self.phase_label.setText(f"階段:{text}") def set_confirm_enabled(self, enabled): self.confirm_btn.setEnabled(enabled) def tick(self, drone_positions): """drone_positions: {drone_id: (lat, lon)}。""" enemy = self._enemy_id base = "color: #AAA; font-size: 13px; padding: 4px 2px;" if not enemy: self.distance_label.setText("敵機 ↔ 最近友機:(尚未選擇敵機)") self.distance_label.setStyleSheet(base) return epos = drone_positions.get(enemy) if not epos: self.distance_label.setText(f"敵機 {enemy} ↔ 最近友機:(等待敵機定位)") self.distance_label.setStyleSheet(base) return nearest_id = None nearest_d = None for did, pos in drone_positions.items(): if did == enemy or not pos: continue d = _haversine(epos[0], epos[1], pos[0], pos[1]) if nearest_d is None or d < nearest_d: nearest_id, nearest_d = did, d if nearest_id is None: self.distance_label.setText(f"敵機 {enemy} ↔ 最近友機:(無友機定位)") self.distance_label.setStyleSheet(base) return color = "#E57373" if nearest_d <= self.r_detect else "#81C784" self.distance_label.setText( f"敵機 {enemy} ↔ 最近友機 {nearest_id}:{nearest_d:.1f} m" ) self.distance_label.setStyleSheet( f"color: {color}; font-size: 13px; font-weight: bold; padding: 4px 2px;" ) # ------------------------------------------------------------------ UI callbacks def _on_enemy_changed(self, text): if self._suppress_enemy: return self._enemy_id = "" if text == _NONE_LABEL else text _log("INFO", f"[交戰] 敵機選擇:{self._enemy_id or '(未選擇)'}") self.enemy_changed.emit(self._enemy_id) def _on_group_changed(self, _text): if self._suppress_group: return a_id, b_id = self.selected_group_ids() self.groups_changed.emit(a_id or "", b_id or "") def _refresh_enemy_speed_hint(self, *_): v = self.friendly_speed_spin.value() r = self.enemy_ratio_spin.value() self.enemy_speed_hint.setText( f"→ 友機 {v:.1f} m/s、敵機 {v * r:.1f} m/s" ) def _on_apply_speed(self): v = self.friendly_speed_spin.value() r = self.enemy_ratio_spin.value() _log( "INFO", f"[交戰] 套用速度:友機 {v:.1f} m/s、敵機 {v * r:.1f} m/s (比例 {r:.2f})" ) self.apply_speed_requested.emit(v, r)