Files
code-test-drone/task_runner_sitl.py
T
keita ded17d7995 实现预定流程全链路: 任务状态机 + 编排器 + 地面站 + 真机/SITL入口
- utils/task_runner: 6态状态机(WAIT→ARM_TAKEOFF→GOTO(L2)→TRACK(L1)→APPROACH→RTL)
  + 编排器(快照采集/状态动作) + 双层日志(事件JSONL/遥测CSV) + 吊舱控制指令(0x07/0x0F/0x0E/0x08)
- pod_bridge: ground_link 地面坐标UDP注入(超龄判无效,无激光替代表4);
  set_heading_servo_enabled() 运行时L1/L2切换(切换即reset清积分防输出跳变);
  pod_locked 提为公共门控; config 新增 ground_inject 段
- virtual_pod 模拟吊舱(无电机语义) + verify_px4 增量④仅飞控验证 +
  pod_sitl_test L1 SITL全流程(飞行编排不塞进 run_bridge, 保持纯offboard闭环入口)
- 入口: task_runner_sitl(VirtualPod+虚拟目标, SITL端到端跑通约1m24s) /
  task_runner_real(真串口+UDP注入+吊舱控制)
- 地面站 ground_station: 坐标注入 + 状态可视化(Flask)
- S1标定回填(2026-10-09): beta_sign=+1 / alpha_sign=-1, 加标定回归测试守护
- 新增测试: task_runner x4 + ground_link/virtual_pod*/bridge/px4_gateway 增量; 全量 483 passed
- 文档: 预定流程(需求) + 预定流程-操作指南(操作/待办) + L1方案补S1结论与实现状态
2026-10-13 15:50:32 +08:00

240 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""task_runner_sitl.py —— 任务级状态机 SITL 全流程集成测试(预定流程.md 天空侧)。
用状态机驱动完整流程:WAIT → ARM_TAKEOFF → GOTO(L2 坐标引导) → TRACK(L1 航向伺服)
→ APPROACH(悬停) → RTL(返航落地)。相比 pod_sitl_test.py(固定场景、人工编排),
本脚本把「飞行编排 + 控制律切换」交给 TaskRunner 状态机统一驱动。
场景:
* 起飞前注入虚拟目标坐标(模拟地面站给坐标)——目标 = home 北 30m / 东 15m;
* VirtualPod 按无电机语义合成表2-2(框架角恒0、无激光、锁定=目标在视场内);
* 后台任务持续刷新注入坐标(模拟地面站持续上报),坐标始终新鲜;
* GOTO→TRACK 由状态机按「距目标 < switch_radius 且吊舱锁定」守卫切换。
运行前:启动 PX4 SITL(默认本机 14540)。用法:
python task_runner_sitl.py
python task_runner_sitl.py --conn udp://:14540 --config utils/config/pod_bridge.json
"""
import argparse
import asyncio
import logging
import math
import sys
import time
from pathlib import Path
_ROOT = Path(__file__).resolve().parent
_UTILS = _ROOT / "utils"
for _p in (_UTILS, _ROOT):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
from mavsdk import System
from pod_bridge.bridge import PodBridge
from pod_bridge.config import load_from_json
from pod_bridge.ground_link import GroundInjectConfig, GroundLink, GroundTarget
from pod_bridge.guidance import haversine_distance_m
from pod_bridge.px4_gateway import Px4Gateway
from pod_bridge.virtual_pod import (CarrierView, VirtualPod, VirtualTarget,
VirtualTargetConfig)
from task_runner.log import TaskLogConfig, TaskLogger
from task_runner.runner import TaskRunner
from task_runner.state_machine import TaskStateConfig, TaskStateMachine
from pod_sitl_test import (TelemetryHub, now_ms, print_flight_mode,
set_telemetry_rates, wait_until_ready)
logger = logging.getLogger("task_sitl")
CONNECTION = "udpin://0.0.0.0:14540"
CONFIG_PATH = _UTILS / "config" / "pod_bridge.json"
# 虚拟目标:相对 home(NED 原点)北 30m / 东 15m / 与悬停同高度(不追垂直)
TARGET_NORTH_M = 30.0
TARGET_EAST_M = 15.0
TARGET_DOWN_M = -5.0 # 与 5m 悬停高度持平 → 垂直通道无动作
TASK_TIMEOUT_S = 180.0 # 全流程总超时
INJECT_PERIOD_S = 0.5 # 注入坐标刷新周期(地面站上报快于 guidance.target_stale_ms)
_DEG_LAT_M = 111320.0
def ned_offset_to_latlon(lat0: float, lon0: float, north_m: float,
east_m: float) -> tuple[float, float]:
"""NED 偏移(米)转经纬度(局部平面近似,SITL 小范围够用)。"""
lat = lat0 + north_m / _DEG_LAT_M
lon = lon0 + east_m / (_DEG_LAT_M * math.cos(math.radians(lat0)))
return lat, lon
async def read_home_gps(drone) -> tuple[float, float]:
"""起飞前读 home 经纬度(放虚拟目标 + 注入坐标)。"""
try:
async with asyncio.timeout(10.0):
async for pos in drone.telemetry.position():
return pos.latitude_deg, pos.longitude_deg
except TimeoutError:
raise RuntimeError("10 s 内未取得 home GPS") from None
def build_sm_config() -> TaskStateConfig:
"""SITL 状态机参数(占位值,仅用于仿真验证链路)。"""
return TaskStateConfig(
takeoff_altitude_m=5.0,
switch_radius_m=25.0, # 目标在 30m,飞进 25m 内切 TRACK
goto_timeout_s=60.0,
track_timeout_s=60.0,
center_hold_s=3.0,
center_threshold_deg=3.0, # 脱靶量 < 3° 判居中
approach_hover_s=5.0, # 悬停 5s 后 RTL
loop_frequency_hz=20.0,
)
async def inject_loop(ground: GroundLink, lat: float, lon: float,
stop: asyncio.Event) -> None:
"""后台持续刷新注入坐标(模拟地面站持续上报,保坐标始终新鲜)。"""
first = True
while not stop.is_set():
ground.inject(GroundTarget(latitude_deg=lat, longitude_deg=lon,
abs_alt_m=0.0))
if first:
print(f"-- 首次注入坐标 time_ms={ground.latest().time_ms}")
first = False
await asyncio.sleep(INJECT_PERIOD_S)
async def run(args) -> int:
cfg = load_from_json(args.config)
cfg.ground_inject.enabled = True # 启用地面注入(骨架期 JSON 默认关)
cfg.guidance.target_stale_ms = 5000 # 注入坐标超时放宽(表4 默认 1000 对低频注入太紧)
sm_cfg = build_sm_config()
drone = System()
await drone.connect(system_address=args.conn)
try:
await wait_until_ready(drone)
except RuntimeError as e:
print(f"! {e}")
return 1
await set_telemetry_rates(drone)
home_lat, home_lon = await read_home_gps(drone)
tgt_lat, tgt_lon = ned_offset_to_latlon(home_lat, home_lon,
TARGET_NORTH_M, TARGET_EAST_M)
print(f"-- home ({home_lat:.6f}, {home_lon:.6f}),"
f"注入目标 ({tgt_lat:.6f}, {tgt_lon:.6f})(北 {TARGET_NORTH_M}m "
f"东 {TARGET_EAST_M}m)")
# 装配:真 SITL 飞控 + 模拟吊舱 + 注入坐标 + 状态机编排器
hub = TelemetryHub()
hub.start(drone)
target = VirtualTarget(
VirtualTargetConfig(ahead_m=TARGET_NORTH_M, right_m=TARGET_EAST_M,
down_m=TARGET_DOWN_M, arc_radius_m=0.1,
arc_speed_mps=0.0, arc_ccw=True),
(0.0, 0.0, 0.0), 0.0) # anchor=home NED 原点,yaw=0 → 北30/东15
pod = VirtualPod(target, hub.view,
fov_h_deg=cfg.tracking.camera.fov_h_deg,
fov_v_deg=cfg.tracking.camera.fov_v_deg)
px4 = Px4Gateway(drone)
ground = GroundLink(cfg.ground_inject, now_ms_fn=now_ms)
bridge = PodBridge(cfg, px4, pod, now_ms_fn=now_ms,
ground_target_fn=lambda: ground.latest())
sm = TaskStateMachine(sm_cfg)
task_log = TaskLogger(TaskLogConfig(), now_ms_fn=now_ms)
runner = TaskRunner(cfg, sm_cfg, px4, bridge, sm, ground, drone,
now_ms_fn=now_ms, logger=task_log)
stop = asyncio.Event()
if args.ground_station:
# 地面站模式:UDP 接收地面站注入坐标(地面站页面 POST /api/inject → 14555)
ground_task = asyncio.ensure_future(ground.run(stop))
else:
ground_task = asyncio.ensure_future(inject_loop(ground, tgt_lat, tgt_lon, stop))
runner_task = asyncio.ensure_future(runner.run(stop))
print("-- 启动任务状态机全流程(WAIT→…→RTL)")
if args.ground_station:
print(f" [地面站模式] 等待 UDP {cfg.ground_inject.port} 注入坐标…")
print(f" 状态机: takeoff={sm_cfg.takeoff_altitude_m}m "
f"switch_radius={sm_cfg.switch_radius_m}m "
f"center={sm_cfg.center_threshold_deg}°/{sm_cfg.center_hold_s}s "
f"hover={sm_cfg.approach_hover_s}s")
# 状态打印 + 总超时保护
started = time.monotonic()
last_state = None
diag_count = 0
try:
while not runner.rtl_done:
await asyncio.sleep(1.0)
if runner.state is not last_state:
last_state = runner.state
print(f"[任务] 进入状态: {runner.state.value}")
if runner_task.done():
exc = runner_task.exception()
if exc is not None:
print(f"! runner 异常退出: {type(exc).__name__}: {exc}")
break
if runner.state.value == "goto" and diag_count % 3 == 0:
own = px4.own_state()
g = ground.latest()
dist = haversine_distance_m(own.latitude_deg, own.longitude_deg,
g.latitude_deg, g.longitude_deg)
try:
async for m in drone.telemetry.flight_mode():
mode = str(m)
break
except Exception:
mode = "?"
print(f"[诊断] pos_valid={own.position_valid} "
f"pos_ms={own.position_time_ms} now={now_ms()} "
f"mode={mode} yaw={own.yaw_rad:.2f} dist={dist:.1f}m "
f"lat={own.latitude_deg:.5f} lon={own.longitude_deg:.5f}")
diag_count += 1
if time.monotonic() - started > TASK_TIMEOUT_S:
print("! 全流程超时,强制返航")
runner.request_rtl()
if runner.rtl_done:
print("-- 任务完成(RTL 落地)")
except asyncio.CancelledError:
raise
finally:
stop.set()
ground_task.cancel()
runner_task.cancel()
try:
await runner_task
except asyncio.CancelledError:
pass
try:
await ground_task
except asyncio.CancelledError:
pass
await hub.stop()
await print_flight_mode(drone)
print("\n===== 任务状态机全流程完成 =====")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="任务级状态机 SITL 全流程")
ap.add_argument("--conn", default=CONNECTION, help="MAVSDK 连接串")
ap.add_argument("--config", default=str(CONFIG_PATH), help="桥接配置 JSON 路径")
ap.add_argument("--ground-station", action="store_true",
help="地面站模式:UDP 接收地面站注入坐标(而非内置虚拟目标自动注入)")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s.%(msecs)03d %(levelname)s %(message)s",
datefmt="%H:%M:%S")
return asyncio.run(run(args))
if __name__ == "__main__":
sys.exit(main())