4. 航行时间序列的索引与重采样

船舶航行数据天然是时间序列:AIS 报文、GPS 定位、罗经航向、计程仪航速,每一条记录都带有一个时间戳。但在原始数据里,时间戳往往只是普通的一列,行序也可能因为多设备合并、断点补传而乱序,甚至同一时刻出现多条记录。要让后续的航速统计、航向变化分析、航次分段可靠进行,第一步就是把时间真正变成数据的“骨架”——把时间戳设为索引、按时间排序、处理重复时间戳,然后再用 resample 把高频原始点聚合成分钟级、小时级视图。

本节围绕一条完整的处理链展开:原始记录 → 时间索引 → 有序去重 → 重采样聚合 → 航次分段视图。下面这张图给出了整条链上各环节的输入、关键操作与输出形态,可作为实现时的对照。

航行时间序列处理链:从原始记录到航次分段视图 ① 原始记录 timestamp 为普通列 行序可能乱序 同一时刻多条记录 字段:时间/经纬度/ 航速SOG/航向COG ② 时间索引 to_datetime 解析 set_index('timestamp') sort_index() 排序 DatetimeIndex 就位 索引单调递增 ③ 重复时间戳处理 index.duplicated() 保留策略:first / last 或按设备优先级取 groupby(level=0).mean() 索引唯一化 ④ resample 聚合 规则:'1min' / '1h' SOG → mean() COG → 环形均值 计数 → count() 空桶 → 标记缺失 分钟级聚合结果示意(示例数据,非实测) 时间桶 原始点数 平均航速 SOG (kn) 航向变化 ΔCOG (°) 状态 08:00 6 12.4 +3.2 正常 08:01 5 12.6 -1.8 正常 08:02 0 NaN — 缺失 08:03 6 12.9 +2.5 正常 航次 A(离港—到港) 航次 B(下一段)

一、把时间戳变成索引

原始航行数据常见两种时间格式:ISO 字符串(如 2024-03-15 08:00:00)和 Unix 时间戳(秒或毫秒)。无论哪种,都应先用 pd.to_datetime 统一解析为 datetime64[ns],再设为索引。示例数据假设为某内河货船 2024 年 3 月 15 日 08:00 至 10:00 的 GPS 记录,采样间隔约 10 秒,字段包括 timestamp、lat、lon、sog(对地航速,节)、cog(对地航向,度,正北为 0、顺时针为正)。

import pandas as pd

df = pd.read_csv("voyage_raw.csv")
df["timestamp"] = pd.to_datetime(df["timestamp"], errors="coerce")
df = df.dropna(subset=["timestamp"])          # 丢弃无法解析的时间
df = df.set_index("timestamp").sort_index()   # 设为索引并按时间升序
print(df.index.is_monotonic_increasing)       # 期望 True

这里有两个容易忽略的细节。第一,errors="coerce" 会把非法时间变成 NaT,必须显式丢弃,否则后续重采样会静默跳过这些行,造成点数统计偏差。第二,sort_index() 不只是“看起来整齐”:resample 依赖索引有序才能正确划分时间桶,乱序索引会导致同一桶被拆开或聚合结果错位。排序后应检查 is_monotonic_increasing 为 True,这是进入下一步的前置条件。

二、处理重复时间戳

多设备合并时,同一秒可能同时收到 GPS 与 AIS 两条记录,索引出现重复。重复索引会让 resample 的聚合语义变得含糊,也可能让按位置取值的操作出错。处理前先量化问题:

dup_mask = df.index.duplicated(keep=False)
print("重复时间戳条数:", dup_mask.sum())
print(df[dup_mask].head())

处理策略取决于业务含义,常见三种:若同一时刻多条记录来自不同设备且精度相近,用 df.groupby(level=0).mean(numeric_only=True) 取均值最稳妥;若设备有明确优先级(例如 GPS 优于 AIS),先加 source_priority 列排序后 df[~df.index.duplicated(keep="first")] 保留高优先级记录;若重复只是补传造成的完全重复行,直接 keep="first" 去重即可。无论哪种,处理后都应验证 df.index.is_unique 为 True。

三、分钟级与小时级重采样

resample 的本质是按固定时间规则切桶,再对每个桶内的数据施加聚合函数。航速是标量,直接取均值;航向是角度,不能简单平均——350° 与 10° 的算术平均是 180°,方向完全错误。正确做法是转为弧度后取正弦、余弦均值,再用 atan2 还原:

import numpy as np

def circ_mean(deg):
    r = np.deg2rad(deg.dropna())
    if r.empty:
        return np.nan
    return np.rad2deg(np.arctan2(np.sin(r).mean(), np.cos(r).mean())) % 360

minute = df.resample("1min").agg({
    "sog": "mean",
    "cog": circ_mean,
    "lat": "count",
}).rename(columns={"lat": "n_points"})

minute["cog_delta"] = minute["cog"].diff()   # 相邻分钟航向变化
hourly = df.resample("1h").agg({"sog": "mean", "cog": circ_mean})

聚合后要关注两类异常。其一是空桶:若某分钟没有任何原始点,sog 为 NaN、n_points 为 0,这通常意味着信号中断,不应直接填 0,而应标记为缺失,避免把“无数据”误读为“停船”。其二是 cog_delta 的跨零跳变:从 359° 到 1° 的差值算出来是 -358°,实际只转了 2°,需要用 (delta + 180) % 360 - 180 归一化到 [-180°, 180°]。

四、生成航次分段的时间序列视图

航次分段的判据来自业务:靠泊时 sog 长时间低于阈值(例如 0.5 kn),离港后持续高于阈值。可在分钟级结果上先标记状态,再对状态变化点切分:

minute["moving"] = minute["sog"].fillna(0) >= 0.5
seg_id = (minute["moving"] != minute["moving"].shift()).cumsum()
for sid, seg in minute.groupby(seg_id):
    if seg["moving"].iloc[0]:
        print(sid, seg.index.min(), seg.index.max(),
              round(seg["sog"].mean(), 2))

这样得到的每个 seg 就是一段连续航行区间,其起止时间、平均航速、航向变化可直接用于航次视图。把分钟级与小时级结果叠加绘制,小时级曲线看整体趋势,分钟级曲线看操纵细节,两者共用同一时间轴,便形成了本节要求的航次分段视图。判断处理是否成功的依据很具体:索引唯一且单调、空桶被显式标记、航向变化落在 [-180°, 180°]、每段航次的起止时间与靠离泊记录能对上。

重采样规则字符串要与数据节奏匹配:10 秒采样的数据用 "1min" 聚合后每桶约 6 点,统计较稳;若原始数据本身是 1 分钟一条,再用 "1min" 重采样几乎不改变粒度,此时应改用 "5min" 或 "1h" 才有聚合意义。