- 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结论与实现状态
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""log.py —— 任务日志模块:结构化记录任务全流程(事件 + 周期遥测)。
|
|
|
|
预定流程 B 阶段:让任意一次 SITL / 实飞都能回溯出完整阶段轨迹与异常。
|
|
|
|
分层:
|
|
* 事件日志(JSONL):状态机转移、飞行编排事件(解锁/起飞/返航)、异常/紧急。
|
|
低频、带语义、结构化,便于 grep / jq 定位「什么时候发生了什么」。
|
|
* 周期遥测日志(CSV):每控制周期的位姿 / GPS / 目标 / 控制输出,便于回放与画图。
|
|
|
|
时间戳:单调毫秒(now_ms,与运行时代码同源)+ 相对时间 t_s(自首个 now_ms 起)。
|
|
文件 IO 集中在 open / close,热路径只追加一行,不做 flush 决策。
|
|
"""
|
|
|
|
import csv
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
_TELEMETRY_FIELDS = (
|
|
"t_s", "state",
|
|
"pos_valid", "fix_type", "num_satellites",
|
|
"latitude_deg", "longitude_deg", "relative_altitude_m", "yaw_deg",
|
|
"target_injected", "distance_to_target_m", "target_locked",
|
|
"off_target_x_deg", "off_target_y_deg",
|
|
"mode", "yaw_rate_dps", "vx_body_mps", "vz_mps",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class TelemetryRecord:
|
|
"""单周期遥测记录(字段顺序即 CSV 列序)。"""
|
|
|
|
t_s: float = 0.0
|
|
state: str = ""
|
|
pos_valid: bool = False
|
|
fix_type: int = 0
|
|
num_satellites: int = 0
|
|
latitude_deg: float = 0.0
|
|
longitude_deg: float = 0.0
|
|
relative_altitude_m: float = 0.0
|
|
yaw_deg: float = 0.0
|
|
target_injected: bool = False
|
|
distance_to_target_m: float = float("inf")
|
|
target_locked: bool = False
|
|
off_target_x_deg: float = 0.0
|
|
off_target_y_deg: float = 0.0
|
|
mode: str = ""
|
|
yaw_rate_dps: float = 0.0
|
|
vx_body_mps: float = 0.0
|
|
vz_mps: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class TaskLogConfig:
|
|
"""任务日志配置(全外置)。"""
|
|
|
|
enabled: bool = True
|
|
dir: str = "logs" # 输出目录(相对工作目录)
|
|
event_file: str = "task_events.jsonl" # 事件日志
|
|
telemetry_file: str = "task_telemetry.csv" # 周期遥测日志
|
|
telemetry_every_n: int = 1 # 每 N 周期记一次遥测(1 = 每周期)
|
|
|
|
|
|
def _monotonic_ms() -> int:
|
|
return int(time.monotonic() * 1000)
|
|
|
|
|
|
class TaskLogger:
|
|
"""结构化任务日志。用法:
|
|
|
|
logger = TaskLogger(cfg, now_ms_fn)
|
|
logger.open() # 建目录 + 写 CSV 表头
|
|
logger.log_event(now_ms, "state", from_state="wait", ...)
|
|
logger.log_telemetry(now_ms, record)
|
|
logger.close()
|
|
|
|
enabled=False 或未 open 时,所有 log 方法为空操作(不抛异常、不写文件)。
|
|
"""
|
|
|
|
def __init__(self, cfg: TaskLogConfig = TaskLogConfig(),
|
|
now_ms_fn=_monotonic_ms) -> None:
|
|
self._cfg = cfg
|
|
self._now_ms = now_ms_fn
|
|
self._event_fp = None
|
|
self._tel_fp = None
|
|
self._tel_writer = None
|
|
self._t0_ms: Optional[int] = None
|
|
self._tick = 0
|
|
|
|
# --- 生命周期 -----------------------------------------------------------
|
|
def open(self) -> None:
|
|
"""创建输出目录并打开两个日志文件;disabled 时跳过。"""
|
|
if not self._cfg.enabled:
|
|
return
|
|
out_dir = Path(self._cfg.dir)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
self._event_fp = (out_dir / self._cfg.event_file).open(
|
|
"a", encoding="utf-8")
|
|
self._tel_fp = (out_dir / self._cfg.telemetry_file).open(
|
|
"a", encoding="utf-8", newline="")
|
|
self._tel_writer = csv.writer(self._tel_fp)
|
|
if (out_dir / self._cfg.telemetry_file).stat().st_size == 0:
|
|
self._tel_writer.writerow(_TELEMETRY_FIELDS)
|
|
self._event_fp.flush()
|
|
self._tel_fp.flush()
|
|
|
|
def close(self) -> None:
|
|
"""flush 并关闭两个日志文件。"""
|
|
if self._event_fp is not None:
|
|
self._event_fp.flush()
|
|
self._event_fp.close()
|
|
self._event_fp = None
|
|
if self._tel_fp is not None:
|
|
self._tel_fp.flush()
|
|
self._tel_fp.close()
|
|
self._tel_fp = None
|
|
self._tel_writer = None
|
|
|
|
# --- 记录 ---------------------------------------------------------------
|
|
def log_event(self, now_ms: int, event_type: str, **fields) -> None:
|
|
"""追加一条 JSONL 事件。"""
|
|
if self._event_fp is None:
|
|
return
|
|
record = {"type": event_type, "ts_ms": now_ms,
|
|
"t_s": self._rel_s(now_ms)}
|
|
record.update(fields)
|
|
self._event_fp.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
|
|
def log_telemetry(self, now_ms: int, rec: TelemetryRecord) -> None:
|
|
"""追加一行遥测(按 telemetry_every_n 节流)。"""
|
|
if self._tel_writer is None:
|
|
return
|
|
self._tick += 1
|
|
if self._tick % max(1, self._cfg.telemetry_every_n) != 0:
|
|
return
|
|
rec.t_s = self._rel_s(now_ms)
|
|
self._tel_writer.writerow([getattr(rec, f) for f in _TELEMETRY_FIELDS])
|
|
|
|
# --- 内部 ---------------------------------------------------------------
|
|
def _rel_s(self, now_ms: int) -> float:
|
|
if self._t0_ms is None:
|
|
self._t0_ms = now_ms
|
|
return (now_ms - self._t0_ms) / 1000.0
|
|
|
|
@property
|
|
def fieldnames(self) -> tuple:
|
|
return _TELEMETRY_FIELDS
|