feat(GUI): 交戰高度分層改「跟著敵機高度 ±offset」浮動分層
修實飛重編隊後待機組 B 掉高度的 bug(原 _alt_for 寫死 formation_alt 沒讀敵機高度)。高度來源改用 local_pose z(相對 home,非 AMSL gps alt), 新增 rel_alt_provider / _enemy_base_alt(None 沿用上次有效值), A=base+offset、B=base−offset;EngagementPanel 加「高度分層 ±」欄位即時生效。 SITL 驗證通過。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -364,6 +364,7 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.engagement_panel.stop_requested.connect(self._on_engagement_stop)
|
self.engagement_panel.stop_requested.connect(self._on_engagement_stop)
|
||||||
self.engagement_panel.groups_changed.connect(self._on_engagement_groups_changed)
|
self.engagement_panel.groups_changed.connect(self._on_engagement_groups_changed)
|
||||||
self.engagement_panel.apply_speed_requested.connect(self._on_engagement_apply_speed)
|
self.engagement_panel.apply_speed_requested.connect(self._on_engagement_apply_speed)
|
||||||
|
self.engagement_panel.alt_offset_changed.connect(self._on_engagement_alt_offset)
|
||||||
self.left_tab.addTab(self.engagement_panel, "交戰")
|
self.left_tab.addTab(self.engagement_panel, "交戰")
|
||||||
self._init_orchestrator()
|
self._init_orchestrator()
|
||||||
|
|
||||||
@@ -2503,6 +2504,9 @@ class ControlStationUI(QMainWindow):
|
|||||||
self.orchestrator = MissionOrchestrator(
|
self.orchestrator = MissionOrchestrator(
|
||||||
make_executor=self._make_orchestrator_executor,
|
make_executor=self._make_orchestrator_executor,
|
||||||
gps_provider=lambda: self.monitor.drone_gps,
|
gps_provider=lambda: self.monitor.drone_gps,
|
||||||
|
# 敵機相對 home 高度(local_pose z,與 setpoint 同座標系);勿用 drone_gps.alt(AMSL)
|
||||||
|
rel_alt_provider=lambda did: (
|
||||||
|
getattr(self.monitor, 'drone_local', {}).get(did) or {}).get('z'),
|
||||||
stop_groups_fn=self._stop_engagement_group_missions,
|
stop_groups_fn=self._stop_engagement_group_missions,
|
||||||
)
|
)
|
||||||
self._engagement_a_group = None
|
self._engagement_a_group = None
|
||||||
@@ -2605,6 +2609,13 @@ class ControlStationUI(QMainWindow):
|
|||||||
f"套用速度:友機 {friendly_speed:.1f} m/s ({len(friendly_ids)} 台)、"
|
f"套用速度:友機 {friendly_speed:.1f} m/s ({len(friendly_ids)} 台)、"
|
||||||
f"敵機 {enemy_speed:.1f} m/s", 4000)
|
f"敵機 {enemy_speed:.1f} m/s", 4000)
|
||||||
|
|
||||||
|
def _on_engagement_alt_offset(self, offset):
|
||||||
|
"""高度分層偏移即時生效:A=敵機高度+offset、B=敵機高度−offset。
|
||||||
|
下個 tick 的瞬時 slot 就會套用(PURSUE/ENCIRCLE 中調整也立即反映)。"""
|
||||||
|
if hasattr(self, 'orchestrator'):
|
||||||
|
self.orchestrator.alt_layer_offset = float(offset)
|
||||||
|
self.statusBar().showMessage(f"高度分層 ±{offset:.1f} m(A 上層 / B 下層)", 3000)
|
||||||
|
|
||||||
def _on_engagement_confirm(self):
|
def _on_engagement_confirm(self):
|
||||||
if hasattr(self, 'orchestrator'):
|
if hasattr(self, 'orchestrator'):
|
||||||
self.orchestrator.confirm_detection()
|
self.orchestrator.confirm_detection()
|
||||||
@@ -2735,9 +2746,10 @@ class ControlStationUI(QMainWindow):
|
|||||||
|
|
||||||
elif msg_type == 'local_pose':
|
elif msg_type == 'local_pose':
|
||||||
x, y = data.get('x', 0), data.get('y', 0)
|
x, y = data.get('x', 0), data.get('y', 0)
|
||||||
|
z = data.get('z', 0) # 相對 home 高度(正向朝上),供交戰高度分層用
|
||||||
if not hasattr(self.monitor, 'drone_local'):
|
if not hasattr(self.monitor, 'drone_local'):
|
||||||
self.monitor.drone_local = {}
|
self.monitor.drone_local = {}
|
||||||
self.monitor.drone_local[drone_id] = {'x': x, 'y': y}
|
self.monitor.drone_local[drone_id] = {'x': x, 'y': y, 'z': z}
|
||||||
self.queue_overview_update(drone_id, 'local', f"{x:.1f}, {y:.1f}")
|
self.queue_overview_update(drone_id, 'local', f"{x:.1f}, {y:.1f}")
|
||||||
|
|
||||||
elif msg_type == 'loss_rate':
|
elif msg_type == 'loss_rate':
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ DEFAULT_REPLAN_CAP_SEC = 1.0 # 重算週期上限 (s)
|
|||||||
DEFAULT_CIRCLE_RADIUS = 10.0 # 包圍半徑 (m)
|
DEFAULT_CIRCLE_RADIUS = 10.0 # 包圍半徑 (m)
|
||||||
DEFAULT_LON_SPACING = 5.0 # 隊形縱向間距 (m)
|
DEFAULT_LON_SPACING = 5.0 # 隊形縱向間距 (m)
|
||||||
DEFAULT_LAT_OFFSET = 3.0 # 隊形橫向錯開 (m)
|
DEFAULT_LAT_OFFSET = 3.0 # 隊形橫向錯開 (m)
|
||||||
DEFAULT_FORMATION_ALT = 10.0 # 隊形基準高度 (m,相對 home)
|
DEFAULT_FORMATION_ALT = 10.0 # fallback 隊形基準高度 (m,相對 home):敵機相對高度都讀不到時才用
|
||||||
DEFAULT_ALT_LAYER_GAP = 6.0 # A/B 上下層垂直間隔 (m):A=基準+gap/2、B=基準-gap/2
|
DEFAULT_ALT_LAYER_OFFSET = 5.0 # A/B 相對敵機高度的上下偏移 (m):A=敵高+offset、B=敵高−offset
|
||||||
DEFAULT_MIN_ALT_FLOOR = 5.0 # 相對 home 的最低安全高度 (m):B 下層不得低於此值
|
DEFAULT_MIN_ALT_FLOOR = 5.0 # 相對 home 的最低安全高度 (m):B 下層不得低於此值
|
||||||
DEFAULT_LEAD_TIME_CAP = 4.0 # 前置攔截 lead_time 上限 (s)
|
DEFAULT_LEAD_TIME_CAP = 4.0 # 前置攔截 lead_time 上限 (s)
|
||||||
DEFAULT_FRIENDLY_SPEED = 5.0 # 友機假設水平速度 (m/s):估 lead_time = gap/此值
|
DEFAULT_FRIENDLY_SPEED = 5.0 # 友機假設水平速度 (m/s):估 lead_time = gap/此值
|
||||||
@@ -89,7 +89,8 @@ class MissionOrchestrator(QObject):
|
|||||||
"""
|
"""
|
||||||
階段狀態機。透過注入的 callback 與 GUI 解耦(便於 headless 測試):
|
階段狀態機。透過注入的 callback 與 GUI 解耦(便於 headless 測試):
|
||||||
make_executor() -> MissionExecutor(complete_stops=False,hover-hold)
|
make_executor() -> MissionExecutor(complete_stops=False,hover-hold)
|
||||||
gps_provider() -> {drone_id: {'lat','lon','alt'}}
|
gps_provider() -> {drone_id: {'lat','lon','alt'}}(alt 是 AMSL,勿當相對高度用)
|
||||||
|
rel_alt_provider(drone_id) -> 該機相對 home 的高度 z(local_pose,正向朝上)或 None
|
||||||
stop_groups_fn() -> 停掉操作員的 A/B 群組任務(重編隊時避免雙重送點)
|
stop_groups_fn() -> 停掉操作員的 A/B 群組任務(重編隊時避免雙重送點)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ class MissionOrchestrator(QObject):
|
|||||||
status_message = pyqtSignal(str)
|
status_message = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, make_executor, gps_provider, stop_groups_fn=None,
|
def __init__(self, make_executor, gps_provider, stop_groups_fn=None,
|
||||||
|
rel_alt_provider=None,
|
||||||
r_detect=DEFAULT_R_DETECT,
|
r_detect=DEFAULT_R_DETECT,
|
||||||
detect_dwell_sec=DEFAULT_DETECT_DWELL,
|
detect_dwell_sec=DEFAULT_DETECT_DWELL,
|
||||||
r_engage=DEFAULT_R_ENGAGE,
|
r_engage=DEFAULT_R_ENGAGE,
|
||||||
@@ -108,7 +110,7 @@ class MissionOrchestrator(QObject):
|
|||||||
lon_spacing=DEFAULT_LON_SPACING,
|
lon_spacing=DEFAULT_LON_SPACING,
|
||||||
lat_offset=DEFAULT_LAT_OFFSET,
|
lat_offset=DEFAULT_LAT_OFFSET,
|
||||||
formation_alt=DEFAULT_FORMATION_ALT,
|
formation_alt=DEFAULT_FORMATION_ALT,
|
||||||
alt_layer_gap=DEFAULT_ALT_LAYER_GAP,
|
alt_layer_offset=DEFAULT_ALT_LAYER_OFFSET,
|
||||||
min_alt_floor=DEFAULT_MIN_ALT_FLOOR,
|
min_alt_floor=DEFAULT_MIN_ALT_FLOOR,
|
||||||
lead_time_cap_sec=DEFAULT_LEAD_TIME_CAP,
|
lead_time_cap_sec=DEFAULT_LEAD_TIME_CAP,
|
||||||
friendly_speed=DEFAULT_FRIENDLY_SPEED,
|
friendly_speed=DEFAULT_FRIENDLY_SPEED,
|
||||||
@@ -117,6 +119,7 @@ class MissionOrchestrator(QObject):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._make_executor = make_executor
|
self._make_executor = make_executor
|
||||||
self._gps_provider = gps_provider
|
self._gps_provider = gps_provider
|
||||||
|
self._rel_alt_provider = rel_alt_provider
|
||||||
self._stop_groups_fn = stop_groups_fn
|
self._stop_groups_fn = stop_groups_fn
|
||||||
self.r_detect = float(r_detect)
|
self.r_detect = float(r_detect)
|
||||||
self.detect_dwell_sec = float(detect_dwell_sec)
|
self.detect_dwell_sec = float(detect_dwell_sec)
|
||||||
@@ -128,7 +131,7 @@ class MissionOrchestrator(QObject):
|
|||||||
self.lon_spacing = float(lon_spacing)
|
self.lon_spacing = float(lon_spacing)
|
||||||
self.lat_offset = float(lat_offset)
|
self.lat_offset = float(lat_offset)
|
||||||
self.formation_alt = float(formation_alt)
|
self.formation_alt = float(formation_alt)
|
||||||
self.alt_layer_gap = float(alt_layer_gap)
|
self.alt_layer_offset = float(alt_layer_offset)
|
||||||
self.min_alt_floor = float(min_alt_floor)
|
self.min_alt_floor = float(min_alt_floor)
|
||||||
self.lead_time_cap_sec = float(lead_time_cap_sec)
|
self.lead_time_cap_sec = float(lead_time_cap_sec)
|
||||||
self.friendly_speed = float(friendly_speed)
|
self.friendly_speed = float(friendly_speed)
|
||||||
@@ -147,6 +150,7 @@ class MissionOrchestrator(QObject):
|
|||||||
self._last_heading = (0.0, 1.0) # 上次隊形朝向(敵機太近時沿用,避免抖動)
|
self._last_heading = (0.0, 1.0) # 上次隊形朝向(敵機太近時沿用,避免抖動)
|
||||||
self._enemy_hist = [] # [(t, lat, lon)] 敵機軌跡(估速用)
|
self._enemy_hist = [] # [(t, lat, lon)] 敵機軌跡(估速用)
|
||||||
self._enemy_vel = (0.0, 0.0) # 估得的敵速 (east, north) m/s
|
self._enemy_vel = (0.0, 0.0) # 估得的敵速 (east, north) m/s
|
||||||
|
self._last_enemy_rel_z = None # 上次有效的敵機相對高度(provider 暫讀不到時沿用)
|
||||||
|
|
||||||
# ------------------------------------------------------------- 設定(可連續呼叫)
|
# ------------------------------------------------------------- 設定(可連續呼叫)
|
||||||
def configure(self, a_drone_ids, b_drone_ids, enemy_id):
|
def configure(self, a_drone_ids, b_drone_ids, enemy_id):
|
||||||
@@ -294,13 +298,29 @@ class MissionOrchestrator(QObject):
|
|||||||
self._apply_slots(self._circle_slots(gps, epos), epos)
|
self._apply_slots(self._circle_slots(gps, epos), epos)
|
||||||
|
|
||||||
# ------------------------------------------------------------- 高度分層
|
# ------------------------------------------------------------- 高度分層
|
||||||
def _alt_for(self, did):
|
def _enemy_base_alt(self):
|
||||||
"""S3 高度分層:A 組(含領隊)走上層、B 組走下層(不低於地面下限)。
|
"""分層基準 = 敵機當下『相對 home』高度(來自 local_pose z,與 setpoint 同座標系)。
|
||||||
同高度繞圈/追擊會交叉撞機,分層後 A/B 垂直錯開自然避開。"""
|
provider 暫讀不到(None)時沿用上一次有效值;連一次都沒有才退回 formation_alt。
|
||||||
|
注意:不可用 gps_provider 的 alt(那是 AMSL 海拔,座標系不同會炸高)。"""
|
||||||
|
z = None
|
||||||
|
if self._rel_alt_provider is not None and self.enemy_id is not None:
|
||||||
|
try:
|
||||||
|
z = self._rel_alt_provider(self.enemy_id)
|
||||||
|
except Exception:
|
||||||
|
z = None
|
||||||
|
if z is not None:
|
||||||
|
self._last_enemy_rel_z = float(z)
|
||||||
|
return float(z)
|
||||||
|
if self._last_enemy_rel_z is not None:
|
||||||
|
return self._last_enemy_rel_z
|
||||||
|
return self.formation_alt
|
||||||
|
|
||||||
|
def _alt_for(self, did, base):
|
||||||
|
"""S3 高度分層:以敵機高度 base 為中心,A 組(含領隊)走上層 base+offset、
|
||||||
|
B 組走下層 base−offset(不低於地面下限)。分層後 A/B 垂直錯開避免同高交叉撞機。"""
|
||||||
if did in set(self.b_drone_ids):
|
if did in set(self.b_drone_ids):
|
||||||
return max(self.min_alt_floor,
|
return max(self.min_alt_floor, base - self.alt_layer_offset)
|
||||||
self.formation_alt - self.alt_layer_gap / 2.0)
|
return base + self.alt_layer_offset
|
||||||
return self.formation_alt + self.alt_layer_gap / 2.0
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- 前置攔截(估敵速)
|
# ------------------------------------------------------------- 前置攔截(估敵速)
|
||||||
def _update_enemy_velocity(self, epos):
|
def _update_enemy_velocity(self, epos):
|
||||||
@@ -349,6 +369,7 @@ class MissionOrchestrator(QObject):
|
|||||||
h = (hx / n, hy / n)
|
h = (hx / n, hy / n)
|
||||||
self._last_heading = h
|
self._last_heading = h
|
||||||
px, py = -h[1], h[0] # 左垂直
|
px, py = -h[1], h[0] # 左垂直
|
||||||
|
base = self._enemy_base_alt() # 高度分層基準:敵機相對高度(每 tick 取一次)
|
||||||
slots = {}
|
slots = {}
|
||||||
for rank, d in enumerate(ids):
|
for rank, d in enumerate(ids):
|
||||||
lat_sign = -1 if rank % 2 == 0 else 1
|
lat_sign = -1 if rank % 2 == 0 else 1
|
||||||
@@ -356,7 +377,7 @@ class MissionOrchestrator(QObject):
|
|||||||
sx = -h[0] * (rank * self.lon_spacing) + px * (lat_sign * self.lat_offset)
|
sx = -h[0] * (rank * self.lon_spacing) + px * (lat_sign * self.lat_offset)
|
||||||
sy = -h[1] * (rank * self.lon_spacing) + py * (lat_sign * self.lat_offset)
|
sy = -h[1] * (rank * self.lon_spacing) + py * (lat_sign * self.lat_offset)
|
||||||
lat, lon = _m_to_ll(sx, sy, tlat, tlon)
|
lat, lon = _m_to_ll(sx, sy, tlat, tlon)
|
||||||
slots[d] = (lat, lon, self._alt_for(d))
|
slots[d] = (lat, lon, self._alt_for(d, base))
|
||||||
return slots
|
return slots
|
||||||
|
|
||||||
def _circle_slots(self, gps, epos):
|
def _circle_slots(self, gps, epos):
|
||||||
@@ -364,13 +385,14 @@ class MissionOrchestrator(QObject):
|
|||||||
ids = self._merged_ids
|
ids = self._merged_ids
|
||||||
elat, elon = epos['lat'], epos['lon']
|
elat, elon = epos['lat'], epos['lon']
|
||||||
n = len(ids)
|
n = len(ids)
|
||||||
|
base = self._enemy_base_alt() # 高度分層基準:敵機相對高度(每 tick 取一次)
|
||||||
slots = {}
|
slots = {}
|
||||||
for i, d in enumerate(ids):
|
for i, d in enumerate(ids):
|
||||||
theta = 2.0 * math.pi * i / max(1, n)
|
theta = 2.0 * math.pi * i / max(1, n)
|
||||||
x = self.circle_radius * math.cos(theta)
|
x = self.circle_radius * math.cos(theta)
|
||||||
y = self.circle_radius * math.sin(theta)
|
y = self.circle_radius * math.sin(theta)
|
||||||
lat, lon = _m_to_ll(x, y, elat, elon)
|
lat, lon = _m_to_ll(x, y, elat, elon)
|
||||||
slots[d] = (lat, lon, self._alt_for(d))
|
slots[d] = (lat, lon, self._alt_for(d, base))
|
||||||
return slots
|
return slots
|
||||||
|
|
||||||
def _apply_slots(self, slots, epos):
|
def _apply_slots(self, slots, epos):
|
||||||
@@ -453,12 +475,15 @@ class EngagementPanel(QWidget):
|
|||||||
stop_requested = pyqtSignal()
|
stop_requested = pyqtSignal()
|
||||||
groups_changed = pyqtSignal(str, str) # (a_group_id, b_group_id)
|
groups_changed = pyqtSignal(str, str) # (a_group_id, b_group_id)
|
||||||
apply_speed_requested = pyqtSignal(float, float) # (friendly_speed, enemy_ratio)
|
apply_speed_requested = pyqtSignal(float, float) # (friendly_speed, enemy_ratio)
|
||||||
|
alt_offset_changed = pyqtSignal(float) # A/B 相對敵機高度的上下偏移 (m)
|
||||||
|
|
||||||
def __init__(self, parent=None, r_detect=DEFAULT_R_DETECT,
|
def __init__(self, parent=None, r_detect=DEFAULT_R_DETECT,
|
||||||
friendly_speed=DEFAULT_FRIENDLY_SPEED):
|
friendly_speed=DEFAULT_FRIENDLY_SPEED,
|
||||||
|
alt_offset=DEFAULT_ALT_LAYER_OFFSET):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.r_detect = float(r_detect)
|
self.r_detect = float(r_detect)
|
||||||
self._init_friendly_speed = float(friendly_speed)
|
self._init_friendly_speed = float(friendly_speed)
|
||||||
|
self._init_alt_offset = float(alt_offset)
|
||||||
self._enemy_id = ""
|
self._enemy_id = ""
|
||||||
self._suppress_enemy = False
|
self._suppress_enemy = False
|
||||||
self._suppress_group = False
|
self._suppress_group = False
|
||||||
@@ -568,6 +593,28 @@ class EngagementPanel(QWidget):
|
|||||||
apply_speed_row.addStretch()
|
apply_speed_row.addStretch()
|
||||||
root.addLayout(apply_speed_row)
|
root.addLayout(apply_speed_row)
|
||||||
|
|
||||||
|
# 高度分層:A=敵機高度+offset、B=敵機高度−offset(即時生效,不需重編隊)
|
||||||
|
alt_row = QHBoxLayout()
|
||||||
|
alt_row.setSpacing(8)
|
||||||
|
altl = QLabel("高度分層 ±:")
|
||||||
|
altl.setStyleSheet("color: #CCC; font-size: 12px;")
|
||||||
|
self.alt_offset_spin = QDoubleSpinBox()
|
||||||
|
self.alt_offset_spin.setStyleSheet(spin_css)
|
||||||
|
self.alt_offset_spin.setRange(0.0, 30.0)
|
||||||
|
self.alt_offset_spin.setSingleStep(0.5)
|
||||||
|
self.alt_offset_spin.setDecimals(1)
|
||||||
|
self.alt_offset_spin.setSuffix(" m")
|
||||||
|
self.alt_offset_spin.setValue(self._init_alt_offset)
|
||||||
|
self.alt_offset_spin.valueChanged.connect(
|
||||||
|
lambda v: self.alt_offset_changed.emit(float(v)))
|
||||||
|
alt_hint = QLabel("(A 走上層 / B 走下層,繞敵機高度)")
|
||||||
|
alt_hint.setStyleSheet("color: #777; font-size: 11px;")
|
||||||
|
alt_row.addWidget(altl)
|
||||||
|
alt_row.addWidget(self.alt_offset_spin)
|
||||||
|
alt_row.addWidget(alt_hint)
|
||||||
|
alt_row.addStretch()
|
||||||
|
root.addLayout(alt_row)
|
||||||
|
|
||||||
sep = QFrame()
|
sep = QFrame()
|
||||||
sep.setFrameShape(QFrame.Shape.HLine)
|
sep.setFrameShape(QFrame.Shape.HLine)
|
||||||
sep.setStyleSheet("color: #444;")
|
sep.setStyleSheet("color: #444;")
|
||||||
|
|||||||
Reference in New Issue
Block a user