2. 无人艇航行数据异常值检测与清洗

任务对象与判定口径

无人艇航行日志通常由飞控/导航计算机按固定频率记录,核心字段包括 timestamp、lat、lon、sog(对地速度,m/s)、cog(对地航向,°)、heading(艏向,°)、roll/pitch/yaw(IMU 姿态,°)以及 depth。本案例要处理三类质量问题:跳变(单点或短时突变,超出物理可达范围)、缺失(时间戳断档或字段为空)、重复(同一时间戳多条记录或整行重复)。

清洗前必须先定口径,否则“异常”无法判定。本案例采用以下可复现规则(示例阈值,可按艇型调整):

  • 重复:timestamp 完全相同视为重复,保留首条;若同时间戳但字段不同,保留字段完整度更高的一条。
  • 缺失:相邻记录时间差 > 2.5 倍标称采样周期(标称 1 Hz,即 > 2.5 s)判为断档;关键字段为空也计入缺失。
  • 跳变:位置用相邻点推算速度 v = Δs/Δt,超过 12 m/s 判为位置跳变;航向类字段用环形差值 Δθ = ((θ₂-θ₁+180) mod 360) - 180,|Δθ| > 45°/s 判为航向跳变;sog 超过 15 m/s 或为负判为速度异常。
航向是环形量,直接做线性差分会在 359°→1° 处产生 358° 的假跳变,必须用环形差值。位置跳变判定要用球面距离,不能用经纬度差直接当米。

清洗流程与数据流

下图给出从原始日志到干净数据集的处理链,以及每一步的判定分支和输出统计口径。跳变点不直接删除,而是先标记为缺失,再与真实断档一起进入插值环节,这样能保持时间轴连续、便于后续重采样。

原始航行日志 timestamp / lat / lon sog / cog / heading roll / pitch / yaw ① 去重 同 timestamp 保留首条 字段完整度高者优先 输出:重复条数 ② 排序与断档 按 timestamp 升序 Δt > 2.5 s 记断档 输出:断档段数 ③ 跳变检测 位置 v > 12 m/s 航向 |Δθ| > 45°/s sog > 15 或 < 0 跳变点 → 置为 NaN 与真实断档合并 输出:跳变点数 ④ 插值补全 位置/姿态:线性 航向:环形插值 干净数据集 等间隔 1 Hz 无重复、无跳变 ⑤ 质量统计 清洗前后对比 完整率 / 有效率 关键判定与处理对照 位置跳变 Δs 用 haversine 计算 v = Δs/Δt > 12 m/s 航向跳变 环形差值 Δθ |Δθ|/Δt > 45°/s 缺失补全 短断档(≤5 点)线性插值 长断档保留 NaN 并标记 重复记录 同 timestamp 去重 保留完整度最高行 质量指标(清洗前后各算一次) 完整率 = 非空关键字段数 / 应有字段总数   有效率 = 通过物理合理性判定的记录数 / 总记录数   时间连续率 = 实际覆盖时长 / 首末时间戳跨度

可运行的清洗实现

下面用 Python + pandas 完成全流程。示例数据由脚本内构造,包含人为注入的重复、断档和跳变,便于验证每一步是否生效;换成真实日志时只需替换 load_log() 的读取逻辑。

import numpy as np
import pandas as pd

R_EARTH = 6371000.0
NOMINAL_DT = 1.0          # 标称采样周期 1 s
GAP_FACTOR = 2.5          # 断档判定倍数
V_MAX = 12.0              # 位置跳变速度阈值 m/s
YAW_RATE_MAX = 45.0       # 航向跳变角速度阈值 °/s
SOG_MAX = 15.0            # 速度上限 m/s
SHORT_GAP = 5             # 短断档最大插值点数

def haversine(lat1, lon1, lat2, lon2):
    p1, p2 = np.radians(lat1), np.radians(lat2)
    dp = p2 - p1
    dl = np.radians(lon2 - lon1)
    a = np.sin(dp/2)**2 + np.cos(p1)*np.cos(p2)*np.sin(dl/2)**2
    return 2 * R_EARTH * np.arcsin(np.sqrt(a))

def wrap_diff(a, b):
    """环形差值 b-a,结果落在 (-180, 180]"""
    return (b - a + 180.0) % 360.0 - 180.0

def load_log():
    # 构造 60 s 示例日志:1 Hz,注入重复、断档、跳变
    t = pd.date_range("2024-06-01 08:00:00", periods=60, freq="1s")
    lat = 30.0 + np.arange(60) * 1e-5
    lon = 122.0 + np.arange(60) * 1e-5
    sog = np.full(60, 3.2)
    cog = np.full(60, 45.0)
    heading = np.full(60, 45.0)
    df = pd.DataFrame({"timestamp": t, "lat": lat, "lon": lon,
                       "sog": sog, "cog": cog, "heading": heading})
    # 注入跳变:第 20 点纬度突变
    df.loc[20, "lat"] += 0.01
    # 注入航向跳变:第 35 点艏向突变
    df.loc[35, "heading"] = 200.0
    # 注入重复:复制第 10 行
    df = pd.concat([df, df.iloc[[10]]], ignore_index=True)
    # 注入断档:删除第 45~49 行
    df = df.drop(index=range(45, 50)).reset_index(drop=True)
    return df

def clean_log(df):
    stats = {}
    df = df.sort_values("timestamp").reset_index(drop=True)
    stats["raw_rows"] = len(df)

    # ① 去重
    before = len(df)
    df = df.drop_duplicates(subset="timestamp", keep="first").reset_index(drop=True)
    stats["dup_removed"] = before - len(df)

    # ② 断档识别
    dt = df["timestamp"].diff().dt.total_seconds()
    gap_mask = dt > NOMINAL_DT * GAP_FACTOR
    stats["gap_segments"] = int(gap_mask.sum())

    # ③ 跳变检测(位置)
    d = haversine(df["lat"].shift(), df["lon"].shift(), df["lat"], df["lon"])
    v = d / dt
    pos_jump = v > V_MAX
    stats["pos_jumps"] = int(pos_jump.sum())

    # ③ 跳变检测(航向,环形)
    dyaw = wrap_diff(df["heading"].shift(), df["heading"])
    yaw_rate = dyaw.abs() / dt
    yaw_jump = yaw_rate > YAW_RATE_MAX
    stats["yaw_jumps"] = int(yaw_jump.sum())

    # ③ 速度异常
    sog_bad = (df["sog"] > SOG_MAX) | (df["sog"] < 0)
    stats["sog_bad"] = int(sog_bad.sum())

    # 跳变点置为 NaN,与断档合并
    bad = pos_jump | yaw_jump | sog_bad
    for col in ["lat", "lon", "sog", "cog", "heading"]:
        df.loc[bad, col] = np.nan

    # ④ 插值:短断档线性,长断档保留 NaN
    df = df.set_index("timestamp").asfreq("1s")
    for col in ["lat", "lon", "sog", "cog"]:
        df[col] = df[col].interpolate(method="linear", limit=SHORT_GAP)
    # 航向用环形插值:先展开再插值再折回
    h = df["heading"].copy()
    unwrapped = np.degrees(np.unwrap(np.radians(h.fillna(method="ffill"))))
    df["heading"] = pd.Series(unwrapped, index=df.index).interpolate(
        method="linear", limit=SHORT_GAP) % 360.0

    df = df.reset_index()
    stats["clean_rows"] = len(df)
    stats["nan_remaining"] = int(df[["lat","lon","sog","cog","heading"]].isna().any(axis=1).sum())
    return df, stats

def quality_report(raw, clean):
    key = ["lat", "lon", "sog", "cog", "heading"]
    def completeness(d):
        return d[key].notna().sum().sum() / (len(d) * len(key))
    span = (clean["timestamp"].max() - clean["timestamp"].min()).total_seconds()
    covered = clean["timestamp"].notna().sum() * NOMINAL_DT
    return {
        "完整率_前": round(completeness(raw), 4),
        "完整率_后": round(completeness(clean), 4),
        "时间连续率": round(covered / span, 4) if span else 0.0,
    }

if __name__ == "__main__":
    raw = load_log()
    clean, stats = clean_log(raw)
    print(stats)
    print(quality_report(raw, clean))

结果判读与验证

运行后应看到:dup_removed = 1、gap_segments = 1、pos_jumps = 1、yaw_jumps = 1,说明三类注入问题都被识别。清洗后 clean_rows 为 60(1 Hz 等间隔),nan_remaining 为 0,因为断档长度 5 点正好等于 SHORT_GAP 上限,被线性插值补全;若把断档拉长到 8 点,则 nan_remaining 应大于 0,这是“长断档不硬插”的预期行为。

验证要点:

  • 抽查第 20 点纬度,清洗后应回到相邻点连线上,而不是保留突变值。
  • 抽查第 35 点艏向,环形插值后应落在 45° 附近,不能出现 200° 或 45°→200° 的线性穿越。
  • 对比 完整率_前 与 完整率_后,后者应更高;时间连续率 应接近 1.0。
  • 用 clean.to_csv("usv_clean.csv", index=False) 导出,供后续重采样与剖面分析使用。
阈值不是固定真理:高速滑行艇的 V_MAX 可放宽到 20 m/s,低速测绘艇可收紧到 6 m/s;航向阈值同理。关键是阈值来源可解释、写入配置、并在质量报告中记录,保证清洗过程可追溯、可复现。