归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。 目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。 自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。 任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。 入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。 新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
205 lines
7.7 KiB
Python
205 lines
7.7 KiB
Python
"""log_test.py —— 任务日志模块单元测试。
|
||
|
||
覆盖:open 建目录/写表头、事件 JSONL、遥测 CSV、telemetry_every_n 节流、
|
||
disabled 时零副作用、close 收尾、简洁任务简报(task_brief.log)。
|
||
"""
|
||
|
||
import csv
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from task_runner.log import TaskLogConfig, TaskLogger, TelemetryRecord
|
||
|
||
|
||
def _cfg(tmp_path, **kw):
|
||
c = TaskLogConfig(dir=str(tmp_path), **kw)
|
||
return c
|
||
|
||
|
||
def test_open_creates_dir_and_csv_header(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path))
|
||
logger.open()
|
||
logger.close()
|
||
|
||
assert (tmp_path / "task_events.jsonl").exists()
|
||
assert (tmp_path / "task_telemetry.csv").exists()
|
||
|
||
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
|
||
header = next(csv.reader(f))
|
||
assert header[0] == "t_s"
|
||
assert "state" in header
|
||
assert "distance_to_target_m" in header
|
||
|
||
|
||
def test_log_event_writes_jsonl(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path))
|
||
logger.open()
|
||
logger.log_event(1000, "state", from_state="wait",
|
||
to_state="arm_takeoff", reason="收到地面坐标")
|
||
logger.log_event(2000, "emergency", reason="RC 接管")
|
||
logger.close()
|
||
|
||
with open(tmp_path / "task_events.jsonl", encoding="utf-8") as f:
|
||
lines = [json.loads(x) for x in f if x.strip()]
|
||
assert len(lines) == 2
|
||
assert lines[0]["type"] == "state"
|
||
assert lines[0]["from_state"] == "wait"
|
||
assert lines[0]["to_state"] == "arm_takeoff"
|
||
assert lines[0]["ts_ms"] == 1000
|
||
assert lines[0]["t_s"] == 0.0 # 首个事件 t_s = 0
|
||
assert lines[1]["t_s"] == 1.0 # 第二个事件 t_s = 1.0s
|
||
|
||
|
||
def test_log_telemetry_writes_csv_rows(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path))
|
||
logger.open()
|
||
rec = TelemetryRecord(state="goto", pos_valid=True, latitude_deg=47.39,
|
||
distance_to_target_m=33.5, mode="VELOCITY_HEADING",
|
||
yaw_rate_dps=-30.0, vx_body_mps=1.7)
|
||
logger.log_telemetry(1000, rec)
|
||
logger.log_telemetry(1100, rec)
|
||
logger.close()
|
||
|
||
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
|
||
rows = list(csv.DictReader(f))
|
||
assert len(rows) == 2
|
||
assert rows[0]["state"] == "goto"
|
||
assert rows[0]["pos_valid"] == "True"
|
||
assert rows[0]["distance_to_target_m"] == "33.5"
|
||
assert rows[0]["t_s"] == "0.0"
|
||
assert rows[1]["t_s"] == "0.1"
|
||
|
||
|
||
def test_telemetry_throttled_by_every_n(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path, telemetry_every_n=5))
|
||
logger.open()
|
||
rec = TelemetryRecord(state="goto")
|
||
for i in range(12):
|
||
logger.log_telemetry(1000 + i * 100, rec)
|
||
logger.close()
|
||
|
||
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
|
||
rows = list(csv.DictReader(f))
|
||
assert len(rows) == 2 # 第 5、10 次各记一次
|
||
|
||
|
||
def test_disabled_logs_nothing(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path, enabled=False))
|
||
logger.open()
|
||
logger.log_event(1000, "state", from_state="wait", to_state="arm_takeoff")
|
||
logger.log_telemetry(1000, TelemetryRecord(state="goto"))
|
||
logger.close()
|
||
|
||
# 未 open(disabled)→ 不建目录、不写文件
|
||
assert not (tmp_path / "task_events.jsonl").exists()
|
||
assert not (tmp_path / "task_telemetry.csv").exists()
|
||
|
||
|
||
def test_log_before_open_is_noop(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path))
|
||
logger.log_event(1000, "state", from_state="wait", to_state="arm_takeoff")
|
||
logger.log_telemetry(1000, TelemetryRecord(state="goto"))
|
||
logger.close()
|
||
assert not (tmp_path / "task_events.jsonl").exists()
|
||
|
||
|
||
def test_append_keeps_existing_rows(tmp_path):
|
||
"""重复 open(追加模式)不清空既有内容,表头不重复写。"""
|
||
logger = TaskLogger(_cfg(tmp_path))
|
||
logger.open()
|
||
logger.log_telemetry(1000, TelemetryRecord(state="wait"))
|
||
logger.close()
|
||
|
||
logger2 = TaskLogger(_cfg(tmp_path))
|
||
logger2.open()
|
||
logger2.log_telemetry(2000, TelemetryRecord(state="goto"))
|
||
logger2.close()
|
||
|
||
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
|
||
rows = list(csv.DictReader(f))
|
||
assert len(rows) == 2
|
||
assert rows[0]["state"] == "wait"
|
||
assert rows[1]["state"] == "goto"
|
||
|
||
|
||
# --- 简洁任务简报(task_brief.log)-------------------------------------------
|
||
def test_brief_log_lines_and_summary(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path))
|
||
logger.open()
|
||
logger.log_event(1000, "state", from_state="wait", to_state="wait",
|
||
reason="任务启动")
|
||
logger.log_event(2000, "state", from_state="wait", to_state="arm_takeoff",
|
||
reason="收到地面坐标")
|
||
logger.log_event(3000, "command", name="rtl")
|
||
logger.log_event(4000, "fc_status", severity="WARNING", text="Low battery\t")
|
||
logger.log_event(5000, "takeoff", altitude_m=5.0)
|
||
logger.log_event(6000, "rtl")
|
||
logger.log_telemetry(1000, TelemetryRecord(
|
||
state="wait", relative_altitude_m=0.0, distance_to_target_m=33.5))
|
||
logger.log_telemetry(1100, TelemetryRecord(
|
||
state="arm_takeoff", relative_altitude_m=5.2, distance_to_target_m=20.0))
|
||
logger.log_telemetry(1200, TelemetryRecord(
|
||
state="arm_takeoff", relative_altitude_m=5.0,
|
||
distance_to_target_m=float("inf")))
|
||
logger.close()
|
||
|
||
text = (tmp_path / "task_brief.log").read_text(encoding="utf-8")
|
||
assert "任务开始" in text
|
||
assert "[00:00.0] 任务启动(当前: 待命)" in text
|
||
assert "[00:01.0] 阶段: 待命 → 起飞(收到地面坐标)" in text
|
||
assert "[00:02.0] 地面站命令: rtl" in text
|
||
assert "[00:03.0] 飞控 WARNING: Low battery" in text # 尾随 tab 已 strip
|
||
assert "[00:04.0] 起飞至 5.0 m" in text
|
||
assert "[00:05.0] 开始返航" in text
|
||
assert "本次运行汇总" in text
|
||
assert "时长 0.2s" in text # 最后一行遥测的 t_s
|
||
assert "最高 5.2m" in text
|
||
assert "最近距目标 20.0m" in text # inf 不计入
|
||
assert "事件 6 条" in text
|
||
assert "状态链 待命→起飞" in text
|
||
|
||
|
||
def test_brief_required_no_file_when_disabled_or_closed(tmp_path):
|
||
logger = TaskLogger(_cfg(tmp_path, enabled=False))
|
||
logger.open()
|
||
logger.log_event(1000, "rtl")
|
||
logger.close()
|
||
assert not (tmp_path / "task_brief.log").exists()
|
||
|
||
|
||
# --- 遥测落盘节流(2026-10-15:主循环 20Hz 不得每行同步 flush)----------------
|
||
def test_telemetry_buffered_until_flush_interval(tmp_path):
|
||
"""telemetry_flush_ms 内只写不 flush;close 兜底落盘,一行不丢。"""
|
||
logger = TaskLogger(_cfg(tmp_path, telemetry_flush_ms=1000))
|
||
logger.open()
|
||
rec = TelemetryRecord(state="track")
|
||
logger.log_telemetry(1000, rec) # 首行:落盘一次并打戳
|
||
for i in range(1, 19): # 其后 19 行都在 1s 内 → 不再 flush
|
||
logger.log_telemetry(1000 + i * 50, rec)
|
||
logger.close()
|
||
|
||
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
|
||
rows = list(csv.DictReader(f))
|
||
assert len(rows) == 19
|
||
|
||
|
||
def test_telemetry_flush_zero_flushes_every_row(tmp_path):
|
||
"""telemetry_flush_ms=0 保留旧的「每行立即 flush」行为(兼容/调试)。"""
|
||
logger = TaskLogger(_cfg(tmp_path, telemetry_flush_ms=0))
|
||
logger.open()
|
||
logger.log_telemetry(1000, TelemetryRecord(state="goto"))
|
||
assert logger._last_tel_flush_ms == 1000
|
||
logger.close()
|
||
|
||
|
||
def test_events_still_flushed_immediately(tmp_path):
|
||
"""事件(阶段切换)仍即时落盘:地面站页面实时性不受遥测节流影响。"""
|
||
logger = TaskLogger(_cfg(tmp_path, telemetry_flush_ms=10_000))
|
||
logger.open()
|
||
logger.log_event(1000, "state", from_state="goto", to_state="search")
|
||
with open(tmp_path / "task_events.jsonl", encoding="utf-8") as f:
|
||
lines = [json.loads(x) for x in f if x.strip()]
|
||
assert len(lines) == 1
|
||
logger.close()
|