2. 航行数据读取与解析基础

船舶航行数据最常见的落地形式是 CSV 文件:船端或岸基系统按固定周期把时间、位置、航向、航速等字段写入一行,日积月累形成原始航行记录。要用 Python 处理这些数据,第一步不是建模,而是把文件读进来、把字段认清楚、把类型转正确、把缺失值识别出来。本节围绕一条完整的处理链展开:从 CSV 文件读取,到表头与数据类型判断,再到 NMEA 语句中经纬度、航向、航速字段的解析,最终得到一份结构化的航行记录。

一、CSV 文件的结构与读取方式

一个典型的航行 CSV 文件通常包含表头行和若干数据行。表头决定了每列的含义,例如 timestamp、latitude、longitude、course、speed,也可能直接存放原始 NMEA 语句,如 nmea 列。读取时有两种常用路径:一是用内置 open() 配合 csv.reader 逐行读取,适合流式处理大文件;二是用 csv.DictReader 把每行映射为字典,字段名直接来自表头,代码可读性更好。

import csv

with open("voyage_log.csv", newline="", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    rows = list(reader)

print(reader.fieldnames)   # 表头字段列表
print(rows[0])             # 第一行数据,键为表头名

这里有两个容易忽略的细节。第一,open() 必须加 newline="",否则在 Windows 上可能出现空行问题。第二,编码要显式指定,船端设备导出的文件可能是 utf-8,也可能是 gbk,读错编码会直接抛 UnicodeDecodeError。实际排查时,可以先尝试 utf-8,失败再换 gbk,并把编码作为读取函数的参数暴露出来。

二、表头、数据类型与缺失值

CSV 读出来的所有值默认都是字符串,这是后续计算出错的主要来源。例如航速 "12.5" 是字符串,直接参与算术会报错;时间戳 "2024-05-01 08:00:00" 需要转成 datetime 才能做时间差。因此读取之后必须做一次类型转换,并明确每列的目标类型:时间转 datetime,经纬度、航向、航速转 float。

缺失值在航行数据中有多种表示:空字符串、NaN、None、-、999.9 这类哨兵值。不同来源的约定不同,不能一概而论。稳妥的做法是维护一个缺失值集合,在转换函数里统一判断。下面的示例把空串、"NaN"、"-" 都视为缺失,返回 None,避免把哨兵值当成真实航速参与统计。

MISSING = {"", "NaN", "nan", "-", "None"}

def to_float(value):
    if value is None or value.strip() in MISSING:
        return None
    try:
        return float(value)
    except ValueError:
        return None

需要强调的是,999.9 这类哨兵值是否算缺失,取决于数据源约定,必须在解析前确认,不能凭经验直接过滤,否则可能误删真实数据。

三、NMEA 语句中的经纬度、航向与航速

很多航行记录并不直接给出十进制度,而是保存原始 NMEA 语句。以常见的 $GPRMC 为例,一条语句形如:

$GPRMC,080000.00,A,3112.3456,N,12128.7890,E,12.5,45.0,010524,,,A*6A

按逗号切分后,各字段位置是固定的:第 3 字段为纬度原始值,第 4 字段为南北半球,第 5 字段为经度原始值,第 6 字段为东西半球,第 7 字段为航速(节),第 8 字段为航向(度)。关键点在于经纬度的格式是 度分 混合:3112.3456 表示 31 度 12.3456 分,需要先拆出度和分,再换算成十进制度,公式为 度 + 分 / 60。南纬和西经要取负值,这是坐标系约定,必须在全文和图示中保持一致。

def parse_latlon(raw, hemi):
    if not raw or raw in MISSING:
        return None
    dot = raw.index(".")
    deg = float(raw[:dot - 2])
    minute = float(raw[dot - 2:])
    value = deg + minute / 60
    if hemi in ("S", "W"):
        value = -value
    return round(value, 6)

def parse_rmc(sentence):
    parts = sentence.split(",")
    return {
        "lat": parse_latlon(parts[3], parts[4]),
        "lon": parse_latlon(parts[5], parts[6]),
        "speed": to_float(parts[7]),
        "course": to_float(parts[8]),
    }

以示例语句计算:纬度 3112.3456 拆为 31 度和 12.3456 分,得 31 + 12.3456/60 ≈ 31.205760;经度 12128.7890 得 121 + 28.7890/60 ≈ 121.479817。航速 12.5 节,航向 45.0 度。这些数值属于示例演算,用于说明换算过程,不代表真实船舶实测。

四、结构化提取的整体流程

把上述环节串起来,就形成一条清晰的数据流:文件读取 → 表头识别 → 逐行字段提取 → 类型转换与缺失判断 → NMEA 解析 → 结构化记录输出。下图展示了这一处理链中各环节的输入输出关系,以及缺失值在哪个环节被拦截。

航行 CSV 结构化提取数据流 voyage_log.csv 表头 + 数据行 DictReader 按表头映射字典 类型转换 float / datetime 缺失值判断 空串 / NaN / - → None NMEA 解析 度分 → 十进制度 结构化记录 lat / lon / speed / course 缺失值拦截点 转换失败或哨兵值 → 返回 None,不进入计算 示例输出(单条记录) {"lat": 31.205760, "lon": 121.479817, "speed": 12.5, "course": 45.0} 注:数值为示例演算结果,用于说明换算过程

把解析函数封装成可复用的形式,是本节要交付的实用成果。下面的函数接收文件路径,返回结构化记录列表,并对每条记录保留原始行号,便于回溯异常数据。

def load_voyage(path, encoding="utf-8"):
    records = []
    with open(path, newline="", encoding=encoding) as f:
        for i, row in enumerate(csv.DictReader(f), start=2):
            nmea = row.get("nmea", "")
            if nmea.startswith("$GPRMC"):
                rec = parse_rmc(nmea)
                rec["line"] = i
                records.append(rec)
    return records

五、结果检查与常见问题

解析完成后不能直接进入下一步,应先做几项基本检查:记录条数是否与文件数据行数匹配;经纬度是否落在合理范围(纬度 -90 到 90,经度 -180 到 180);航向是否在 0 到 360 之间;航速是否为非负值。若某条记录经纬度为 None,说明该行 NMEA 字段缺失或格式异常,应单独记录而不是静默丢弃。

常见问题有三类。一是编码错误,表现为读取阶段直接抛异常,解决方式是显式指定编码。二是字段错位,NMEA 语句中若含空字段,切分后索引会偏移,需先确认语句类型再取字段。三是半球符号遗漏,南纬西经未取负值,会导致位置整体偏移到另一半球,这类错误在数值范围检查中往往能暴露出来。完成这些检查后,一份原始航行记录才算真正完成了结构化提取。