"""log.py —— 任务日志模块:结构化记录任务全流程(事件 + 周期遥测)。 预定流程 B 阶段:让任意一次 SITL / 实飞都能回溯出完整阶段轨迹与异常。 分层: * 事件日志(JSONL):状态机转移、飞行编排事件(解锁/起飞/返航)、异常/紧急。 低频、带语义、结构化,便于 grep / jq 定位「什么时候发生了什么」。 * 周期遥测日志(CSV):每控制周期的位姿 / GPS / 目标 / 控制输出,便于回放与画图。 * 简洁任务简报(task_brief.log,纯文本):一行一事件(阶段切换/地面站命令/ 飞控告警)+ 运行结束汇总(时长/最高/最近距目标/状态链),供人工阅读与归档; 机器日志(JSONL/CSV)格式不变。 时间戳:单调毫秒(now_ms,与运行时代码同源)+ 相对时间 t_s(自首个 now_ms 起)。 文件 IO 集中在 open / close;热路径只追加(并立即 flush 落盘——缓冲期间文件不可见, 地面站页面运行中需要读到最新状态与遥测,2026-10-14 实测)。 """ import csv import json import math import time from dataclasses import dataclass from datetime import datetime 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" # 周期遥测日志 brief_file: str = "task_brief.log" # 人类可读任务简报(追加) telemetry_every_n: int = 1 # 每 N 周期记一次遥测(1 = 每周期) telemetry_flush_ms: int = 1000 # 遥测落盘间隔(毫秒);0 = 每行立即 flush def _monotonic_ms() -> int: return int(time.monotonic() * 1000) _STATE_CN = {"wait": "待命", "arm_takeoff": "起飞", "goto": "坐标引导", "search": "搜索", "track": "追踪靠近", "approach": "接近悬停", "rtl": "返航"} def _fmt_t_s(t_s: float) -> str: """相对时间 → mm:ss.s(简报用)。""" m, s = divmod(max(0.0, float(t_s or 0.0)), 60.0) return f"{int(m):02d}:{s:04.1f}" 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._brief_fp = None self._t0_ms: Optional[int] = None self._tick = 0 self._last_tel_flush_ms: Optional[int] = None # 遥测上次落盘时刻 # 简报汇总统计(随遥测行/事件更新,close 时写一行汇总) self._n_events = 0 self._last_t_s = 0.0 self._max_rel_alt_m: Optional[float] = None self._min_dist_m: Optional[float] = None self._state_chain: list = [] # --- 生命周期 ----------------------------------------------------------- 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._brief_fp = (out_dir / self._cfg.brief_file).open( "a", encoding="utf-8") self._brief_fp.write( f"===== 任务开始 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} =====\n") self._last_tel_flush_ms = None self._event_fp.flush() self._tel_fp.flush() self._brief_fp.flush() def close(self) -> None: """写简报汇总并关闭全部日志文件。""" if self._brief_fp is not None: self._brief_fp.write(self._brief_summary() + "\n\n") self._brief_fp.flush() self._brief_fp.close() self._brief_fp = None 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 事件并**立即落盘**(事件低频且语义关键:地面站要实时看阶段)。 注意:遥测行不在这里 flush——见 log_telemetry 的节流落盘说明。 """ 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") self._event_fp.flush() self._n_events += 1 if self._brief_fp is not None: self._brief_fp.write(self._brief_text(record) + "\n") self._brief_fp.flush() def log_telemetry(self, now_ms: int, rec: TelemetryRecord) -> None: """追加一行遥测(按 telemetry_every_n 节流)。 落盘策略:**写入靠 Python 缓冲、按 telemetry_flush_ms 定期 flush**, 而不是每行 fsync 式 flush。原因:本方法在主控制循环里以 20Hz 调用, 每行都 flush 等于每秒 20 次同步磁盘 IO,在树莓派 SD 卡上会阻塞事件 循环(实测 MAVSDK 回调队列因此积压告警);事件行仍即时落盘,故页面 的阶段显示不受影响,只有图表数据最多滞后 telemetry_flush_ms。 close() 会做最后一次 flush,进程正常退出不丢数据。 """ 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]) self._flush_telemetry(now_ms) # 简报汇总统计(只随真正落盘的行更新) self._last_t_s = rec.t_s if math.isfinite(rec.relative_altitude_m): if (self._max_rel_alt_m is None or rec.relative_altitude_m > self._max_rel_alt_m): self._max_rel_alt_m = rec.relative_altitude_m dist = rec.distance_to_target_m if math.isfinite(dist) and dist > 0: if self._min_dist_m is None or dist < self._min_dist_m: self._min_dist_m = dist if rec.state and (not self._state_chain or self._state_chain[-1] != rec.state): if len(self._state_chain) < 32: self._state_chain.append(rec.state) # --- 简报(人类可读,一行一事件)---------------------------------------- @staticmethod def _brief_text(record: dict) -> str: t = _fmt_t_s(record.get("t_s", 0.0)) etype = record.get("type", "?") if etype == "state": to = _STATE_CN.get(record.get("to_state"), record.get("to_state", "?")) if record.get("from_state") == record.get("to_state"): return f"[{t}] 任务启动(当前: {to})" frm = _STATE_CN.get(record.get("from_state"), record.get("from_state", "?")) return f"[{t}] 阶段: {frm} → {to}({record.get('reason', '')})" if etype == "command": return f"[{t}] 地面站命令: {record.get('name', '')}" if etype == "fc_status": return (f"[{t}] 飞控 {record.get('severity', '')}: " f"{str(record.get('text', '')).strip()}") if etype == "takeoff": return f"[{t}] 起飞至 {record.get('altitude_m', '?')} m" if etype == "takeoff_failed": return f"[{t}] 起飞失败: {record.get('error', '')}" if etype == "rtl": return f"[{t}] 开始返航" fields = [f"{k}={v}" for k, v in record.items() if k not in ("type", "ts_ms", "t_s")] return f"[{t}] {etype}: {', '.join(fields)}" def _brief_summary(self) -> str: parts = [f"时长 {self._last_t_s:.1f}s"] if self._max_rel_alt_m is not None: parts.append(f"最高 {self._max_rel_alt_m:.1f}m") if self._min_dist_m is not None: parts.append(f"最近距目标 {self._min_dist_m:.1f}m") parts.append(f"事件 {self._n_events} 条") if self._state_chain: parts.append("状态链 " + "→".join( _STATE_CN.get(s, s) for s in self._state_chain)) return "----- 本次运行汇总: " + " · ".join(parts) + " -----" # --- 内部 --------------------------------------------------------------- def _flush_telemetry(self, now_ms: int) -> None: """按 telemetry_flush_ms 节流落盘遥测(0 = 每行立即落盘)。""" interval = self._cfg.telemetry_flush_ms if interval <= 0: self._tel_fp.flush() self._last_tel_flush_ms = now_ms return if (self._last_tel_flush_ms is None or now_ms - self._last_tel_flush_ms >= interval): self._tel_fp.flush() self._last_tel_flush_ms = now_ms 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