1. 基于AIS数据的船舶航迹预处理与清洗

任务对象与数据约定

本节处理一段典型的AIS岸基接收数据,字段包括:mmsi、ts(UTC时间戳)、lon、lat、sog(对地航速,kn)、cog(对地航向,°)、heading(船首向,°)。原始数据常见四类问题:时间戳重复或乱序、经纬度跳变(越界或瞬移)、SOG/COG越界或占位值(如1023、3600)、以及采样间隔不均导致的缺失点。

清洗流程按“时间对齐 → 异常剔除 → 缺失插补 → 指标统计”推进,其中异常判定采用速度一致性检验:由相邻两点的大圆距离与时间差反算隐含速度,若超过阈值(本文取目标船型合理上限 v_max = 30 kn 的1.5倍,即45 kn)则判为跳变点。参照约定:经纬度WGS-84,东经/北纬为正;航向自正北顺时针为正;距离用Haversine公式,单位n mile。

原始AIS报文 ts / lon / lat sog / cog / hdg 含重复·乱序·跳变 含越界·占位值 ① 时间戳对齐 按 ts 升序排序 同 ts 去重取首条 统一为 UTC 秒 Δt = t[i]-t[i-1] ② 异常点剔除 经纬度越界检查 SOG/COG 占位值 隐含速度 > 45 kn v = d/Δt ≤ v_th ③ 缺失值插补 Δt > 60 s 视为断点 线性插值经纬度 SOG/COG 保持前值 p = p0+(p1-p0)·r 干净航迹序列 等间隔 ts + 有效坐标 供几何特征提取使用 统计:点数 N、航程 S ΔN、ΔS 对比 剔除并记录异常点 reject_log[] 断点内插补点 interp_count 关键判定条件(本节参数) 经纬度有效域:lon∈[-180,180],lat∈[-90,90];SOG∈[0,102.2] kn,COG∈[0,360) 隐含速度阈值 v_th = 45 kn;断点阈值 Δt_max = 60 s;插补后目标采样间隔 = 10 s 注:以上为教学示例参数,实际应按目标船型与接收站采样率标定 主数据流 剔除分支 插补分支 清洗前后对比指标:轨迹点数 N、总航程 S(n mile)、平均采样间隔

可运行的清洗实现

下面用纯 Python(仅标准库)实现完整流程,输入为一段教学示例AIS记录,可直接运行并输出清洗前后统计。示例数据中人为植入了重复时间戳、乱序、越界坐标、SOG占位值1023、以及一个瞬移跳变点,用于验证各判定分支。

import math
from datetime import datetime, timezone

# ---------- 教学示例原始AIS数据(含各类脏数据) ----------
RAW = [
    ("2024-03-01T08:00:00Z", 121.4800, 31.2300, 12.1, 45.0, 44),
    ("2024-03-01T08:00:10Z", 121.4812, 31.2308, 12.3, 46.0, 45),
    ("2024-03-01T08:00:10Z", 121.4812, 31.2308, 12.3, 46.0, 45),   # 重复时间戳
    ("2024-03-01T08:00:00Z", 121.4800, 31.2300, 12.1, 45.0, 44),   # 乱序重复
    ("2024-03-01T08:00:30Z", 121.4836, 31.2324, 12.0, 47.0, 46),
    ("2024-03-01T08:00:40Z", 121.4848, 31.2332, 1023.0, 48.0, 47), # SOG占位值
    ("2024-03-01T08:00:50Z", 121.4860, 31.2340, 12.2, 3600.0, 48), # COG占位值
    ("2024-03-01T08:01:00Z", 121.4872, 31.2348, 12.4, 49.0, 49),
    ("2024-03-01T08:01:10Z", 125.0000, 35.0000, 12.5, 50.0, 50),   # 瞬移跳变
    ("2024-03-01T08:01:20Z", 121.4896, 31.2364, 12.6, 51.0, 51),
    ("2024-03-01T08:02:30Z", 121.4920, 31.2380, 12.8, 52.0, 52),   # 70s断点
    ("2024-03-01T08:02:40Z", 121.4932, 31.2388, 12.9, 53.0, 53),
]

V_TH = 45.0        # 隐含速度阈值 kn
DT_MAX = 60.0      # 断点阈值 s
TARGET_DT = 10.0   # 目标采样间隔 s

def parse_ts(s):
    return datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()

def haversine_nm(lon1, lat1, lon2, lat2):
    R = 3440.065  # 地球平均半径 n mile
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dp = p2 - p1
    dl = math.radians(lon2 - lon1)
    a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
    return 2 * R * math.asin(math.sqrt(a))

def valid_coord(lon, lat):
    return -180 <= lon <= 180 and -90 <= lat <= 90

def valid_sog(sog):
    return 0 <= sog <= 102.2

def valid_cog(cog):
    return 0 <= cog < 360

# ---------- ① 时间戳对齐:排序 + 去重 ----------
recs = []
for ts_s, lon, lat, sog, cog, hdg in RAW:
    recs.append({"t": parse_ts(ts_s), "lon": lon, "lat": lat,
                 "sog": sog, "cog": cog, "hdg": hdg})
recs.sort(key=lambda r: r["t"])
aligned, seen = [], set()
for r in recs:
    if r["t"] in seen:
        continue
    seen.add(r["t"])
    aligned.append(r)

# ---------- ② 异常点剔除 ----------
clean, reject_log = [], []
for i, r in enumerate(aligned):
    reason = None
    if not valid_coord(r["lon"], r["lat"]):
        reason = "坐标越界"
    elif not valid_sog(r["sog"]):
        reason = "SOG占位/越界"
    elif not valid_cog(r["cog"]):
        reason = "COG占位/越界"
    elif clean:
        prev = clean[-1]
        dt = r["t"] - prev["t"]
        if dt > 0:
            v = haversine_nm(prev["lon"], prev["lat"], r["lon"], r["lat"]) / (dt / 3600.0)
            if v > V_TH:
                reason = f"隐含速度{v:.1f}kn超阈"
    if reason:
        reject_log.append((r["t"], reason))
    else:
        clean.append(r)

# ---------- ③ 缺失值插补 ----------
interp_count = 0
filled = []
for i, r in enumerate(clean):
    filled.append(r)
    if i == len(clean) - 1:
        break
    nxt = clean[i + 1]
    dt = nxt["t"] - r["t"]
    if dt > DT_MAX:
        steps = int(dt // TARGET_DT)
        for k in range(1, steps):
            ratio = (k * TARGET_DT) / dt
            filled.append({
                "t": r["t"] + k * TARGET_DT,
                "lon": r["lon"] + (nxt["lon"] - r["lon"]) * ratio,
                "lat": r["lat"] + (nxt["lat"] - r["lat"]) * ratio,
                "sog": r["sog"], "cog": r["cog"], "hdg": r["hdg"],
            })
            interp_count += 1
filled.sort(key=lambda r: r["t"])

# ---------- ④ 统计对比 ----------
def total_nm(seq):
    return sum(haversine_nm(seq[i]["lon"], seq[i]["lat"],
                            seq[i+1]["lon"], seq[i+1]["lat"])
               for i in range(len(seq) - 1))

n_raw, s_raw = len(RAW), total_nm(sorted(
    [{"t": parse_ts(a), "lon": b, "lat": c} for a, b, c, *_ in RAW],
    key=lambda r: r["t"]))
n_clean, s_clean = len(filled), total_nm(filled)

print(f"原始点数 N_raw = {n_raw}, 原始航程 S_raw = {s_raw:.3f} nm")
print(f"清洗点数 N_clean = {n_clean}, 清洗航程 S_clean = {s_clean:.3f} nm")
print(f"剔除 {len(reject_log)} 点, 插补 {interp_count} 点")
for t, why in reject_log:
    print("  剔除:", datetime.fromtimestamp(t, timezone.utc).isoformat(), why)

结果判读与验证要点

按上述示例数据运行,预期输出为:原始12点、清洗后约11点(剔除1个跳变点、1个SOG占位点、1个COG占位点,同时去重2条重复记录),插补约6点,航程因剔除瞬移点而显著缩短。判读时关注三点:

  • 点数变化方向:去重与剔除使点数下降,插补使点数回升,最终点数应接近“有效时长 ÷ 目标采样间隔”。
  • 航程变化:瞬移点会虚增航程,剔除后航程应回落到与SOG积分一致的量级(示例中约0.13 nm/10s × 有效时长)。
  • 采样间隔一致性:清洗后相邻点 Δt 应集中在10 s附近,若仍存在大间隔说明断点阈值或插补步长需调整。
阈值不是固定值:内河船 v_th 可降至20 kn,远洋集装箱船可放宽至50 kn;断点阈值应不小于接收站平均上报间隔的2倍,否则会把正常稀疏采样误判为断点。
插补只对坐标做线性内插,不对 SOG/COG 做外推;若断点跨越转向,线性插值会“切角”,此时应保留断点标记,交由后续分段处理,而不是强行插补。