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 解析 → 结构化记录输出。下图展示了这一处理链中各环节的输入输出关系,以及缺失值在哪个环节被拦截。
把解析函数封装成可复用的形式,是本节要交付的实用成果。下面的函数接收文件路径,返回结构化记录列表,并对每条记录保留原始行号,便于回溯异常数据。
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 语句中若含空字段,切分后索引会偏移,需先确认语句类型再取字段。三是半球符号遗漏,南纬西经未取负值,会导致位置整体偏移到另一半球,这类错误在数值范围检查中往往能暴露出来。完成这些检查后,一份原始航行记录才算真正完成了结构化提取。