做气候数据处理的人,十有八九都跟“中国地面气候日值数据集(V3.0)”打过交道。这套数据由气象部门整理发布,要素全、序列长、站点多,做气象、水文、农业、生态分析基本绕不开它。但说实话,质量好的数据不代表好处理,V3.0的门槛不在数据本身,而在数据说明、格式细节和那些藏在角落里的特殊编码。我见过不少人拿着别人分享的代码跑出“看似合理”的结果,最后因为缺测值没处理、站点号被转成整数导致连接错位、质控码被当成气象要素参与统计,整个结论全部作废。
这篇东西就是我个人踩坑的记录。我不会从“什么是气象数据”这种基础概念讲起,而是直接进入实战:数据文件怎么读、缺测值怎么识别、质控码怎么理解、降水微量怎么区分、批量处理怎么提升效率,最后附一张错误速查表。不管你是刚接触这套数据的研究生,还是已经写了一年处理脚本的工程师,只要按着这个思路走一遍,能省下大量无意义的Debug时间。
1. 拿到数据集后的第一关:读懂文件结构和数据说明
1.1 数据说明才是真正的“避坑地图”
每次看到有人下载完数据集,二话不说直接解压,跳过说明文档就开始读数据,我就知道后面大概率要出事。V3.0数据集压缩包内除了逐站数据文件,一定有几份关键的说明文件,比如数据集说明、站点信息表、质量控制码说明。这些文档表面上不起眼,实际上决定了你后续所有处理逻辑是否正确。
先说文件组织方式。V3.0日值数据集通常是一个站点一个文本文件,或者按要素、按年份拆分文件。文件名以区站号命名,例如“54511.txt”代表北京站的逐日记录。文件内部每行对应一个观测日,列之间用空格或制表符分隔,依次包含区站号、纬度、经度、观测场海拔高度、年、月、日、各要素观测值、质量控制码等信息。
我强烈建议拿到数据的头十分钟,不要急着写代码,先把说明文档完整读一遍。重点看三样东西:缺测值标注方式、质控码含义、要素单位。V3.0里面各要素的缺测值通常用“32766”“32744”“9999”这类特殊大数表示,不是空字符;质控码有专门一列,含义可能是0正确、1可疑、2错误,也可能因版本不同略有差异,一切以配套文档为准。不看说明就写代码,等跑出负数降水、上千毫米降水的时候,再回头查文档就晚了。
1.2 别用记事本看大文件,先搞清楚编码和分隔符
V3.0的文本文件在Windows环境下生成,编码经常是GBK或GB18030,直接按UTF-8读取,大概率报解码错误,或者更糟,读进来一堆乱码但不报错。处理办法是读文件时显式指定编码:
import pandas as pd df = pd.read_csv( "54511.txt", sep="\\s+", # 兼容多个连续空格或制表符 encoding="gb18030", engine="python", header=None )第二个坑是分隔符。这套数据的列之间不是严格的单空格,而是多个空格或缩进,导致直接用sep=" "会解析出大量空列。用sep="\\s+"配合engine="python"能稳定处理连续空白,但如果文件特别大,python引擎会比较慢。这时候改用read_fwf按固定宽度读取会更可靠——前提是你已经从说明文档里拿到了准确的列宽。
import pandas as pd df = pd.read_fwf( "54511.txt", widths=[5, 8, 8, 7, 4, 2, 2, 8, 8, 8], encoding="gb18030" )注意:不同年份、不同版本的V3.0文件,列数和列宽可能有细微差别。最稳妥的方式是先打印几行原始文本数清楚列号,再选择read_csv或read_fwf。这十几分钟的前期确认,能让你后面少折腾好几个小时。
2. 高频翻车点之一:缺测值当成正常数值参与计算
2.1 32766、32744、9999到底怎么处理
这个坑我见得最多。V3.0里缺测值和微量降水等特殊观测值,是用特定的大数标识的。举个例子,气温要素的缺测值经常是32766,降水要素里微量降水可能用32744或32700系列表示,还有一些版本把“无降水”记成“0”,把“微量降水”单独编码。如果这些值不处理,直接参与求和、平均、极值统计,结果会非常离谱。
拿平均气温举例:某天缺测,气温字段写的是32766,假设当天真实温度是20度,你直接求整月平均,一个32766能把月均值拉到上千度,任何统计分析都没有意义。更隐蔽的是,用Pandas读取时这些大数会被识别为整数或浮点,反而让代码“正常”地跑出来一个错得离谱的结果,没有任何报错提示。
我的做法是:读取完原始数据后,立刻做一个缺失值映射步骤,把这些特殊值统一转成NaN。
import numpy as np import pandas as pd # 缺测值列表:根据V3.0说明文档和实际文件中出现的特殊值灵活补充 missing_values = [9999, 32700, 32744, 32766, 32744, 32766] df.replace(missing_values, np.nan, inplace=True)这里有个细节:不同要素的缺测编码未必一样,有些要素用32766,有些用32744,还有用9999的,一定不能只用一张固定列表。正确做法是先加载数据,统计每个要素列的取值分布,找出那些“异常大”的极值,再对照说明文档确认,最后统一替换。
2.2 质控码是单列还是伴随列,别把质控信息当气象值
V3.0的一个典型特点,是每个要素后面经常伴随一个质量控制码列。比如气温列后面跟着气温质量控制码,降水列后面跟着降水质量控制码。质量控制码用来标记该时次数据的可靠程度:通常0表示数据正确,1表示可疑,2表示错误,8表示缺测,具体定义以文档为准。
很多新手不区分这些列,读完数据后把所有列都当成数值型气象要素参与绘图、统计,结果图上出现一条“质控码序列”,或者质控码被求了平均,莫名其妙得到一个“平均质控值”,说出去都丢人。
所以我建议在读文件时就给列名做好规划,把质控码和要素值从逻辑上分开:
df.columns = [ "station", "lat", "lon", "elev", "year", "month", "day", "tem", "tem_qc", "pre", "pre_qc", "wind", "wind_qc" ]处理时,可以先按质控码过滤掉不可靠的记录,再填充缺测值。比如只保留质控码为0的数据:
df = df[(df["tem_qc"] == 0) | (df["tem"].isna())] df["pre"] = df["pre"].where(df["pre_qc"] == 0, np.nan)注意:过滤质控码和替换缺测值这两步的先后顺序,不同场景结论不一样。做站点气候态统计时,我通常先替换缺测为NaN,再按质控码把“可疑”和“错误”的数据也置为NaN,最后统一做描述统计。但如果是做极端事件提取,则需要保留“可疑”数据人工复核,不能一刀切。
3. 高频翻车点之二:降水、蒸发、日照的特殊性
3.1 0毫米降水与微量降水不能混为一谈
降水数据的处理是重灾区。V3.0里“0”通常表示当天无降水,“微量降水”是用某个特殊编码记录的,比如32744或32700,意思是降水量小于0.1毫米、雨量器测不到但确实发生了降水。这两种情况性质完全不同:0代表干旱日,微量代表湿润日,计算降水日数、连续无降水日数、干旱事件时,混在一起会让结论严重偏差。
举个例子,你统计某地春季降水日数,如果把微量降水编码当成缺测值删掉或当成0,那么3月份若干“微量降水日”就全部变成了“无降水日”,连续无降水日数被拉长,干旱评估结果会被高估。反过来,如果直接把微量编码当作真实降水量327.44毫米参与求和,那一个月的降水量能顶半年的量,彻底的灾难。
所以处理降水数据时,我一般单独做一列:
# trace: 是否为微量降水 df["trace_pre"] = df["pre"].isin([32700, 32744]).astype(int) df["pre"] = df["pre"].replace([32700, 32744], 0.0)这样既保留了“发生过降水”这一信息,又能让降水量值正确参与统计。后续写论文时,也可以清楚交代微量降水的处理方式,审稿人看了挑不出毛病。
3.2 蒸发量和日照时数里藏着的小计量单位
蒸发量和日照时数这两个要素,或者单位不是常规值,或者存在进阶的观测状态编码,也很容易埋雷。
V3.0里的蒸发量,单位可能是0.1毫米或者1毫米,取决于版本和站点。如果数据用整数形式存在,比如“235”表示23.5毫米,你直接当毫米去和其他数据对比,就会整体放大10倍。日照时数也有类似问题,有的版本单位是0.1小时,有的直接用整数表示十分之一小时。
我的经验是拿到文件后,先拿已知站点、已知年份做一次简单的数量级验证。例如夏季某站日照时数不会超过14小时;如果发现某天的日照值写成“136”,大概率是13.6小时被放大了10倍。或者对比相邻站点的同期数据,看量级是否协调。这个验证动作只要五分钟,但能避免后续整套分析全部返工。
还有一种情况,蒸发量在结冰期会缺测或标记为负值,不同版本可能有特殊编码。处理时不能只盯着气温和降水,一定要看完整字段说明,把所有要素的特殊状态都列出来。
4. 站号、日期、经纬度:最容易出错的主键与索引
4.1 站号前导零丢失后,所有连接操作全部错位
V3.0的区站号是5位数字,理论上“54511”这类站号没有前导零,所以不少人习惯把它读成整数。但如果你要跟其他来源的站点数据合并,比如气象站点信息表、环境监测站点坐标表,那些表里的站号很多是以字符串或者Excel文本格式存储的,一旦两边类型不一致,连接操作要么匹配不上,要么莫名其妙出现重复行。
更危险的情况是某些站号确实存在前导零,或者某些自定义台站编号包含字母(比如区域站“A1234”)。把站号当整数读,前导零自动被丢弃,再转回字符串时已经无法修复。
我的统一做法是:所有标识型字段一律用字符串读入,并且在代码开头就固定这一约定。
df["station"] = df["station"].astype(str).str.zfill(5)拼接文件时,也把station、year、month、day组成复合主键,保证多源数据对齐不出错。
4.2 日期字符串直接排序的坑:字母序和日历序是两回事
日期列从文本读进来后通常是整数或字符串,比如“20230515”。直接用字符串排序,表面上看起来对,因为“2023-01-01”在字典序上确实小于“2023-12-31”。但一旦日期格式不标准,或者你后续要按月份筛选、计算季节平均,不做类型转换就各种别扭。
正确做法是构造真正的日期时间索引:
df["date"] = pd.to_datetime( df[["year", "month", "day"]].rename( columns={"year": "year", "month": "month", "day": "day"} ) ) df.set_index("date", inplace=True) df.sort_index(inplace=True)处理日值转月值、季值、年值时,先按日期索引重采样:
monthly_mean = df["tem"].resample("ME").mean() yearly_mean = df["tem"].resample("YE").mean()这里有个小细节:闰年的2月29日,在日值转月值时会自动归入2月,如果没有统一处理,可能造成2月天数在不同年份不一致。做气候平均时一般问题不大,但如果做严格的水文日数统计,需要决定统一使用“民用日历”还是“气候日历”,并在方法部分写清楚。
4.3 站点经纬度直接浮点数存储,遇到坐标转换会怀疑人生
V3.0的经纬度字段一般是度为单位浮点数,比如“39.80”“116.47”。直接读取没问题,但有人会顺手把经纬度当成普通数值求个平均,产生一个“平均站点”,这在空间分析里毫无意义。
而且如果你要做空间插值、绘制站点分布图,需要把经纬度转换成公里网格或投影坐标,到时候必须注意数据集中经纬度基准是WGS84还是CGCS2000。不同版本的V3.0说明文档对此不一定明确,稳妥做法是对比真实站点的坐标和第三方权威资料,确认基准后再做地图投影。
5. 批量处理别傻傻for循环:效率、内存与可复现性
5.1 多站点文件合并,Pandas逐文件拼接的内存陷阱
V3.0数据动辄成百上千个站点文件,每个文件几十万行。最常见的新手写法是循环读取所有文件、拼接成一个超级DataFrame,然后统一处理。这种做法在站点数量50以内还好,一旦超过300个文件,内存占用轻松吃掉十几个GB,跑着跑着就死机。
我的策略是“按需加载,边读边算”。如果目标是生成全国站点的月平均数据,不需要把所有日值都驻留在内存里,完全可以逐文件处理,把每个站点的月平均结果保存下来,最后只合并“小结果”。
from pathlib import Path import pandas as pd def process_one_file(path): df = pd.read_csv(path, sep="\\s+", encoding="gb18030", engine="python") # ... 缺测值替换、质控过滤 ... df["date"] = pd.to_datetime(...) monthly = df.groupby(pd.Grouper(key="date", freq="ME"))["tem"].mean() return monthly.rename(path.stem) results = [] for file_path in Path("data").glob("*.txt"): results.append(process_one_file(file_path)) out = pd.concat(results, axis=1) out.to_csv("monthly_temperature_stations.csv")这样内存占用小,中途出错也容易定位到具体是哪个文件出了问题,不用从头排查。
5.2 多进程读取和Parquet格式:大数据量下的提速方案
单线程逐文件读取200个文件,光I/O可能就要十几分钟。更高效的做法是先用多进程并行读取,再把中间结果落盘成Parquet格式,后续调试就不用反复解析原始文本了。
from concurrent.futures import ProcessPoolExecutor with ProcessPoolExecutor(max_workers=8) as executor: monthly_list = list(executor.map(process_one_file, file_list)) result = pd.concat(monthly_list, axis=1) result.to_parquet("monthly_temperature.parquet")这里强调两点:一是process_one_file函数必须是自包含的,所有导入和依赖都写在函数内部,否则多进程会报错;二是如果只需要单个变量的月值,把结果写成窄表,列名带站号,后续透视和画图都方便。Parquet格式比CSV体积小、读取快,算是数据处理环节“磨刀”的最佳选择。
6. 我踩过的坑汇总:一份可直接对号入座的排查表
6.1 常见报错与解决方案对照表
下面这张表,是我在实际项目里反复遇到、帮别人排查时也高频出现的问题。遇到异常时,先对照这张表定位,大多数情况都能就地解决。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 读文件报“UnicodeDecodeError” | 文件是GBK编码,不是UTF-8 | 改用encoding="gb18030"读取 |
| 读进来列数不对,出现大量空列 | 分隔符是多个空格,不是单个 | 改用sep="\\s+"或read_fwf固定宽度读取 |
| 气温月均值出现几百上千度 | 缺测值32766没替换成NaN | 按文档统一替换缺测值后再做统计 |
| 降水总量突然大得离谱 | 微量降水编码被当成了毫米数值 | 将微量降水编码分离,单独标识 |
| 降水日数比实际明显偏多或偏少 | 微量降水处理与统计定义不一致 | 明确“降水日”是否包含微量降水,保持一致 |
| 站号右连接后大量NaN | 站号一边是字符串一边是整数 | 统一转为字符串并zfill(5) |
| 按年份分组统计时,结果顺序错乱 | 日期没转成datetime就排序 | 构造date索引并sort_index |
| 多文件合并时内存爆掉 | 把所有文件读入一个超大DataFrame | 改为边读边汇总,落盘小结果 |
| 月平均结果比预期偏大10倍 | 要素单位可能是0.1毫米或0.1小时 | 核对数据说明中的单位,必要时除以10 |
这张表不是万能的,但覆盖了V3.0处理中八成以上的翻车场景。
6.2 验证数据质量的小技巧:五分钟发现“带毒”数据
数据处理完,最后一步也是最容易被跳过的一步,是验证产出结果的合理性。我一般用三种方式快速校验:
第一,极值检验。逐要素设定气候学合理范围,比如气温在-60到50摄氏度之间,日降水量不超过500毫米(特殊情况另说),超过就直接标记异常。当年我处理西北站点的气温时,就是靠这个检查发现某个站点有半年的气温列全被质控码污染了。
第二,时间连续性检验。对每个站点检查时间索引是否连续,是否有重复日期,是否有“跳跃”。日值数据中间缺失几天可以接受,但如果出现2023年1月1日直接跳到2023年3月1日,那多半是原始文件解析出了问题。
idx = df.index gaps = idx.to_series().diff().dt.days print(gaps.value_counts())第三,空间一致性检验。把相邻站点的同期月均值画成时间序列叠在一起,如果某条曲线突然跳变而周边站点没有响应,八成是数据问题。这个方法看似简单,但非常可靠,尤其适合大范围自动批处理后的质量筛选。
分享一个我自己的习惯:每次处理V3.0数据,我会把“原始输入文件路径、Python脚本版本、缺测值列表、质控过滤规则”全部记录在一个README文件里。这样若干个月后论文需要补充方法细节时,还能准确还原当时每一步做了什么。数据处理的结果会随着代码版本、缺测规则变化而不同,做好记录不是形式主义,是对自己的劳动负责。
最后再说一点很实际的体会:气象数据处理的门槛,不在于会调用多高级的模型或算法,而在于理解和尊重数据本身的规则。V3.0的设计并不复杂,但它承载了多年累积的观测方式、特殊编码、质量控制体系。把说明文档当作第一优先级,把缺测值、质控码、站号类型这些细节当成第一等大事,你的处理流程就能少走一大半弯路。希望这篇避坑指南能帮你省下几个通宵,把更多精力留给真正值得研究的科学问题。