打开 NOAA 的 FTP 目录找中国区域气象数据时,我最初的感受是“资料又多又乱”:几十年按年打包的 gz 文件、两套互相补充的数据集、一堆奇怪的编码字段。但真正把这套数据整理成一份能直接给模型用的 CSV 之后,我发现它几乎是免费可得、覆盖时段最长的中国地面观测序列。这篇文章把我这次整理NOAA 中国区域 18 类地面气象要素逐日数据(1942-2025 年 8 月)的完整过程、CSV 格式解析思路、以及踩过的坑都写下来,适合做气候分析、农业气象、建筑能耗模拟、水文计算的同学参考。
先说结论:这套数据以 NOAAGlobalSummaryOfTheDay(简称 GSOD)为主要来源,辅以 GHCN-Daily 补全降雪、雪深和天气现象;按中国区域站点过滤后,整理为 18 个要素、逐日一行、便于 pandas 或数据库直接读取的标准 CSV。文章后面会给出可复现的 Python 处理流程和各字段的编码约定,你可以直接拿去做长序列趋势分析,或者输入建筑能耗模型做典型气象年。
1. 数据源头和覆盖范围:为什么是 NOAA,为什么从 1942 年开始
1.1 数据来自哪里,和国内气象数据相比有什么优势
这套数据主要来自美国国家环境信息中心(NCEI,NOAA 下属机构)的两个公开数据集。
第一是 GSOD(Global Surface Summary of the Day),这是 NOAA 把全球交换的地面气象站逐小时观测资料汇总成“逐日摘要”后发布的产品,每天一条记录,包含温度、露点、气压、风速、降水、天气现象等。第二是 GHCN-Daily(全球历史气候网络逐日数据),补齐了 GSOD 中比较薄弱的降雪量、雪深以及多种天气编码。
使用这套数据最大的优点是:获取门槛低、时间连续性好、格式统一。国内很多气象数据平台要求实名申请、审核并且限定时段,而 NOAA 的数据公开在 FTP 上,按年份打包下载,不需要任何审批。缺点是中国的台站密度明显不如国内平台,而且部分站点早期缺测较多。我用经纬度框选了大致中国区域(东经 73°-135°,北纬 18°-54°),再结合 WMO 站号前缀筛选,最终整理出约 140 个有连续记录的中国交换站。
1.2 1942 年这个起点意味着什么
很多人看到 1942 年会觉得是不是数据有问题,其实不是。NOAA 的全球资料确实包含少数中国站点的二战前后观测记录,只是数量非常少。1942-1950 年间,每年能拉到的中国站只有个位数,而且多数是当时参与国际气象交换的台站,所以早期数据只能当作参考,不能拿来做严格的年均值统计。
我从 1951 年开始看到的记录才逐渐成规模,到 1960-1970 年代主要省会城市基本都有连续记录,1980 年代以后绝大多数年份覆盖完整。这对我后续做长期趋势分析很重要:早期数据参与统计时,我会单独加一个“观测天数”字段,缺测超过 20 天的年份直接排除,避免个别月份缺失把年均值拉偏。
1.3 整理后的数据形态
最终产物是一张长表,每一行代表“某站点某一天的观测摘要”,包含站点编号、日期、经纬度、海拔、18 类气象要素。没有把 140 个站点拆成 140 个文件,也没有把日期展开成列,因为长表格式对 pandas 分组、绘图、建模都最友好。CSV 不搞多级表头,第一行就是字段名,后续所有行统一按字符串读取再转类型。
2. 18 类要素逐项说明:字段定义、原始编码与换算逻辑
2.1 温度与湿度要素
GSOD 原始文件里的温度单位是华氏度,并且用十分之一华氏度来存储。整理时我统一转成摄氏度。
| 字段名 | 中文学名 | 原始单位 | 整理后单位 | 说明 |
|---|---|---|---|---|
| TEMP | 日平均气温 | 华氏度(1/10) | 摄氏度 | GSOD 对一天 24 小时的采样求平均 |
| MAX | 日最高气温 | 华氏度(1/10) | 摄氏度 | 当天观测最高温 |
| MIN | 日最低气温 | 华氏度(1/10) | 摄氏度 | 当天观测最低温 |
| DEWP | 日平均露点温度 | 华氏度(1/10) | 摄氏度 | 反映水汽含量,由露点算湿度 |
| RH | 相对湿度 | 无 | % | 由 TEMP 和 DEWP 近似计算,不是原始字段 |
相对湿度是我自己加的,因为 GSOD 本身没有 RH。经验公式是:先把温度和露点转成开尔文,计算饱和水汽压和实际水汽压,两者相除得到相对湿度。这个公式在气象数据处理里非常常用,虽然和自动站的直接观测有误差,但用于逐日分析和建筑能耗模拟足够。
2.2 气压与风要素
风场数据是这套 CSV 里最容易出问题的部分,因为 GSOD 的风速原始单位是“节”(海里/小时),而且同样是十分之一节存储。整理时必须先除以 10,再乘以 0.514444 换算成米每秒。
| 字段名 | 中文学名 | 原始单位 | 整理后单位 | 说明 |
|---|---|---|---|---|
| SLP | 海平面气压 | 毫巴(1/10) | hPa | 已做海平面订正 |
| STP | 测站气压 | 毫巴(1/10) | hPa | 站点实测气压,受海拔影响大 |
| WDSP | 日平均风速 | 节(1/10) | 米/秒 | 全天平均风速 |
| MXSPD | 日最大持续风速 | 节(1/10) | 米/秒 | 当日持续风速峰值 |
| GUST | 日最大阵风 | 节(1/10) | 米/秒 | 阵风瞬时最大值,缺测较多 |
| WD | 日盛行风向 | 度 | 度 | 0-360°,我取 GSOD 当天最多风向 |
海平面气压和测站气压的差值可以用来检查海拔数据是否合理。比如拉萨站海拔 3650 米左右,测站气压常年只有 650 hPa 上下,如果某一行突然出现 1000 hPa 的测站气压,那基本可以判定是原始数据异常直接剔除。
2.3 降水与雪要素
GSOD 的降水量以英寸存储,十分之一英寸为单位,整理后统一转成毫米。雪深也是在 GSOD 中以英寸存储的。但 GSOD 降雪量字段经常缺测,所以我用 GHCN-Daily 的 SNOW 和 SNWD 字段做了交叉补全。
| 字段名 | 中文学名 | 原始单位 | 整理后单位 | 说明 |
|---|---|---|---|---|
| PRCP | 日降水量 | 英寸(1/10) | 毫米 | 包括降雨和融化后的降雪 |
| SNOW | 日降雪量 | 英寸(1/10) | 毫米(水当量) | 优先取自 GHCN-Daily |
| SNWD | 雪深 | 英寸(1/10) | 厘米 | 当天观测到的积雪深度 |
降水字段在 GSOD 里有个容易看走眼的现象:当天气象站没观测到降水时,PRCP 可能是 0,也可能是 9999.9(缺测)。0 表示“确定无降水”,9999.9 表示“不知道有没有降水”。做降水日数统计时,必须先把 9999.9 替换成缺失值,否则会多出很多虚假的极端降水日。
2.4 天气现象与能见度
GSOD 有一个很紧凑的字段叫 FRSHTT,占 6 个字符,每一位代表一种天气现象是否出现。从高位到低位依次是雾、雨或毛毛雨、雪或冰粒、冰雹、雷暴、龙卷风。中国区域龙卷风极少见,但雷暴、雾、冰雹对农业和航空来说非常关键。
| 字段名 | 中文学名 | 原始编码 | 整理后形态 |
|---|---|---|---|
| VISIB | 日平均能见度 | 英里(1/10) | 公里 |
| FOG | 雾 | FRSHTT 第 1 位 | 0/1 |
| RAIN | 雨或毛毛雨 | FRSHTT 第 2 位 | 0/1 |
| SNOW_FLAG | 雪或冰粒 | FRSHTT 第 3 位 | 0/1 |
| HAIL | 冰雹 | FRSHTT 第 4 位 | 0/1 |
| THUNDER | 雷暴 | FRSHTT 第 5 位 | 0/1 |
| TORNADO | 龙卷风 | FRSHTT 第 6 位 | 0/1 |
我用“0/1”把天气现象拆成单独列,而不是保留 FRSHTT 原始编码,这样在做统计分析时可以直接 groupby 求和,不需要再解析位标志。
3. CSV 原始文件格式逐字节解析:从 GSOD 到标准表
3.1 GSOD 原始 CSV 字段结构
从 NOAA FTP 下载的单个台站文件,打开后长这样:
STATION,DATE,LATITUDE,LONGITUDE,ELEVATION,NAME,TEMP,TEMP_ATTRIBUTES,DEWP,DEWP_ATTRIBUTES,SLP,SLP_ATTRIBUTES,STP,STP_ATTRIBUTES,VISIB,VISIB_ATTRIBUTES,WDSP,WDSP_ATTRIBUTES,MXSPD,GUST,MAX,MAX_ATTRIBUTES,MIN,MIN_ATTRIBUTES,PRCP,PRCP_ATTRIBUTES,SNDP,FRSHTT 545110,19420101,39.933,116.400,54.0,BEIJING,281,-9999,-9999,-9999,10122,-9999,10107,10,60,3,60,1,2,-9999,269,-9999,291,-9999,0,0,-9999,000000这些字段分三组。第一组是站点及其静态属性:STATION(WMO 站号加 WBAN 编号拼成的 ID)、DATE(YYYYMMDD 格式)、经纬度、海拔、站点名。第二组是物理量:TEMP、DEWP、SLP、STP、VISIB、WDSP、MXSPD、GUST、MAX、MIN、PRCP、SNDP。第三组是每个物理量后面的“属性标志位”,这是 GSOD 里最容易被忽略的内容。
3.2 属性标志位到底在说什么
每个物理量后面跟着的 TEMP_ATTRIBUTES、DEWP_ATTRIBUTES 等字段,用来告诉使用者这个数值是实测、估算还是缺失。常见的标志包括:
- 空格:正常观测值
*:该值由其他要素估算得到,不是直接观测,比如 MAX 缺失时用 TEMP 推算6:该值是从 6 小时定时观测反正推出来的,不是 24 小时完整统计9:原始数据里没有这个值,通常伴随 9999.9 一起出现
我在写解析脚本时做了一个很简单的规则:只要数值是 9999.9,或者数值后面的属性标志不是空格,这一项统一塞成 NaN。不要试图用 9999.9 参与计算,出来的平均值毫无意义。
3.3 “一天”到底是怎么定义的
GSOD 的日期用的是 UTC 日期,但大多数中国站点本地时间是 UTC+8。也就是说,文件中某一条“2024年7月1日”的记录,实际覆盖的是北京时间 2024 年 7 月 1 日早上 8 点到 7 月 2 日早上 8 点。对逐日分析来说,这个时段偏差会影响极端温度的日期归属,尤其是降水和雷暴这种小时尺度特征明显的事件。
如果你只是做月平均或年统计,影响不大;但如果要把某一天的降水对应到具体天气过程,最好先把日期列转成北京时间:date_cn = 日期 + 1 天(当 UTC 日期对应北京时间下午时段时)。更严谨的做法是拿到逐小时数据去重切分,但那就不是逐日摘要能解决的粒度了。
4. 实操:用 pandas 把 NOAA 原始文件组装成 18 要素 CSV
4.1 下载和解压策略
GSOD 的 FTP 地址是ftp.ncdc.noaa.gov/pub/data/gsod/,每年一个 tar 包,例如gsod_2024.tar。里面是每个台站单独一个.op.gz文件。我建议不要手动一个个点,写个脚本按年份循环下载、解压到本地目录,然后用 pandas 循环读取。
mkdir -p gsod cd gsod # 2024 年数据示例 wget ftp://ftp.ncdc.noaa.gov/pub/data/gsod/2024/gsod_2024.tar tar -xf gsod_2024.tar rm -f gsod_2024.tar这样做的好处是保留压缩前的单站文件,后面按台站筛选时不用重复下载。如果你对带宽不敏感,也可以用 Python 直接读远程 gz。
4.2 读取、单位转换、缺失值处理
核心解析代码我贴完整版,核心逻辑是:读原始 CSV、按“中国区域站点代码”过滤、所有 9999.9 转 NaN、单位换算、天气标志拆列、合并日期。
import pandas as pd import numpy as np from pathlib import Path china_stations_prefix = ("54511", "58362", "57494", "56778", "52818", "53698", "54823", "57083", "59431", "59981") def parse_gsod_file(file_path): df = pd.read_csv(file_path, dtype={"STATION": str, "DATE": str}) # 中国站点过滤:这里简单判断站号前 5 位是否在中国交换站名单里 df = df[df["STATION"].str[:5].isin(china_stations_prefix)].copy() if df.empty: return df cols_float = ["TEMP", "DEWP", "SLP", "STP", "VISIB", "WDSP", "MXSPD", "GUST", "MAX", "MIN", "PRCP", "SNDP"] for col in cols_float: df[col] = pd.to_numeric(df[col], errors="coerce") df.loc[df[col] >= 9998.0, col] = np.nan # 单位换算 df["TEMP"] = (df["TEMP"] / 10 - 32) * 5 / 9 # 华氏度 -> 摄氏度 df["MAX"] = (df["MAX"] / 10 - 32) * 5 / 9 df["MIN"] = (df["MIN"] / 10 - 32) * 5 / 9 df["DEWP"] = (df["DEWP"] / 10 - 32) * 5 / 9 df["SLP"] = df["SLP"] / 10 # 毫巴 -> hPa df["STP"] = df["STP"] / 10 df["VISIB"] = df["VISIB"] / 10 * 1.60934 # 英里 -> 公里 df["WDSP"] = df["WDSP"] / 10 * 0.514444 # 节 -> 米/秒 df["MXSPD"] = df["MXSPD"] / 10 * 0.514444 df["GUST"] = df["GUST"] / 10 * 0.514444 df["PRCP"] = df["PRCP"] / 10 * 25.4 # 英寸 -> 毫米 df["SNDP"] = df["SNDP"] / 10 * 2.54 # 英寸 -> 厘米 # 拆分天气标志 FRSHTT def split_frshtt(s): s = str(s).zfill(6) return pd.Series({ "FOG": 1 if len(s) >= 6 and s[0] == "1" else 0, "RAIN": 1 if len(s) >= 6 and s[1] == "1" else 0, "SNOW_FLAG": 1 if len(s) >= 6 and s[2] == "1" else 0, "HAIL": 1 if len(s) >= 6 and s[3] == "1" else 0, "THUNDER": 1 if len(s) >= 6 and s[4] == "1" else 0, }) weather = df["FRSHTT"].apply(split_frshtt) df = pd.concat([df, weather], axis=1) # 日期 df["DATE"] = pd.to_datetime(df["DATE"], format="%Y%m%d") return df这段代码跑完后,df 里已经有 18 个要素对应的全部字段。需要说明的是,站号过滤我这里是示例写法,实际名单应该用 NOAA 提供的ish-history.csv完整过滤一次,避免只用少数几个站号。
4.3 拼装 18 要素标准表和输出
为了下游使用方便,我把最终 CSV 的列名固定为英文,但单位全部转成国际单位。列顺序为:STATION、DATE、LATITUDE、LONGITUDE、ELEVATION、TAVG、TMAX、TMIN、DEWP、RH、SLP、STP、WDSP、MXSPD、GUST、WD、VISIB、PRCP、SNOW、SNWD、FOG、RAIN、SNOW_FLAG、HAIL、THUNDER。
其中 RH 是计算列,WD 我取的是该站当天的盛行风向,SNOW 优先从 GHCN-Daily 补全。这两个字段的补充逻辑有必要单独说明:RH 用温度露点差法,公式如下。
def relative_humidity(temp_c, dewp_c): if pd.isna(temp_c) or pd.isna(dewp_c): return np.nan e_sat = 6.112 * np.exp(17.67 * temp_c / (temp_c + 243.5)) e_act = 6.112 * np.exp(17.67 * dewp_c / (dewp_c + 243.5)) return 100 * e_act / e_sat输出时我用to_csv写入,并且强制把缺失值写成空字符串,而不是NaN,这样其他语言(C#、PostgreSQL、Excel)读取时不会把 NaN 识别成字符串。
all_data = pd.concat(list_of_parsed_dfs, ignore_index=True) all_data.to_csv("noaa_china_daily_18vars.csv", index=False, na_rep="")4.4 与 GHCN-Daily 补全降雪数据
GSOD 的 SNDP 字段在中国站点上缺测率很高,尤其 2013 年之后大量站点不再上报雪深,所以雪深长期连续性依赖 GHCN-Daily。GHCN-Daily 的 CSV 是“station,date,element,value”的长表,过滤出 SNOW 和 SNWD 元素后,按站点和日期做左连接。
ghcn = pd.read_csv("ghcnd_all.csv") ghcn_snow = ghcn[ghcn["ELEMENT"].isin(["SNOW", "SNWD"])] ghcn_snow_wide = ghcn_snow.pivot_table( index=["STATION", "DATE"], columns="ELEMENT", values="VALUE", aggfunc="first" ).reset_index() merged = all_data.merge(ghcn_snow_wide, on=["STATION", "DATE"], how="left") merged["SNOW"] = merged["SNOW"].fillna(merged["SNDP"] / 2.54 / 10)GHCN 的值也需要换算:SNOW 和 SNWD 的原始单位都是毫米,除以 10 得到厘米,但 GSOD 的 SNDP 是英寸十分之一,规则不同。我在代码里统一转成厘米,便于国内习惯使用。
5. 过车容易踩的坑:单位、站点漂移和大文件读取
5.1 单位写错会让一切白干
我最早做这套数据时犯过一个典型错误:把 GSOD 的 PRCP 直接当成毫米,结果全国年降水量普遍偏小一半以上,后来翻了文档才发现是英寸的十分之一。GSOD 所有数值都走“十分之一”存储,所以读进来先除以 10,再乘以换算系数。温度是华氏度的十分之一,气压是毫巴的十分之一,风速是节的十分之一,全部都要经过两层变换。
给大家做一个速查表,贴在代码旁边比记在脑子里可靠:
| 原始字段 | 原始存储 | 实际单位 | 目标单位 | 换算顺序 |
|---|---|---|---|---|
| TEMP/MAX/MIN/DEWP | 十分之一华氏度 | 华氏度 | 摄氏度 | /10 → (°F-32)*5/9 |
| SLP/STP | 十分之一毫巴 | 毫巴 | hPa | /10 |
| WDSP/MXSPD/GUST | 十分之一节 | 节 | 米每秒 | /10 → *0.514444 |
| PRCP | 十分之一英寸 | 英寸 | 毫米 | /10 → *25.4 |
| SNDP | 十分之一英寸 | 英寸 | 厘米 | /10 → *2.54 |
| VISIB | 十分之一英里 | 英里 | 公里 | /10 → *1.60934 |
5.2 站点漂移和编号混乱问题
NOAA 中国区数据里,站点编号并不是完全稳定的。有些站点因为迁站、换址,WMO 编号前后发生变化;还有一些站点在某个时间段进入国际交换名单,过了几年又退出去。我做长期站点连续性检查时发现,1990 年代有几个西部站点出现过 3-4 年的间断,随后编号从 5 位变成 6 位。
处理办法是:不依赖站点名,而是把“经纬度+海拔”作为匹配键。如果一个站点的经纬度变动超过 0.1 度,或者海拔变化超过 50 米,我会单独给这个序列打上“迁站断点”标记。做趋势分析时遇到断点要分开统计,不然会算出一个莫名其妙的降温信号。
5.3 大 CSV 文件导入和编码兼容
整理后的全国逐日 CSV 大约有 250 万行,文件接近 400 MB。直接拿 Excel 打开基本会卡死,我用三个不同方案解决不同场景:
- pandas 读取大数据时不要用默认参数,指定
dtype和usecols,节省大量内存 - PostgreSQL 用
COPY命令导入最快,可以先用文本工具把空字符串统一替换为\N - 如果只查看前几行,用
less或命令行head,别用 GUI 工具反复打开
head -n 5 noaa_china_daily_18vars.csv在导入 MySQL 或 PostgreSQL 时,字段里的空字符串会导致类型转换失败。我的习惯是导出时直接写\N,然后用数据库工具把它映射为 NULL。这在处理 400 MB 级别数据时能省很多事。
5.4 早期数据缺测和“假零降水”
1942-1960 年之间的记录,TMAX 和 TMIN 两个字段缺失率可能达到 30% 以上。尤其 TMIN 缺测时,GSOD 会把 TEMP 乘以 2 再减去 TMAX 来反推,属性标志位会标记成*。这种反推值如果直接参与极值统计,会把本来不该有的低温记录混进来。
我的规则是:只要属性标志位不是空格,该要素直接置为 NaN。虽然会损失一部分样本量,但保证了统计口径统一。做极端事件分析时,样本量的损失比混入伪极值的后果小得多。
6. 数据检验和实际产出:从 CSV 到结论
6.1 快速验证:和公开气候记录对比
数据清洗完以后一定要先做“冒烟测试”。我会拿北京站 1991-2020 年的月平均气温和公众熟知的北京气候平均值对比,误差应该在 0.5°C 以内。如果误差超过 1°C,优先检查单位换算和站点过滤,而不是怀疑数据源。
bj = merged[merged["STATION"].str.startswith("54511")] bj["YM"] = bj["DATE"].dt.to_period("M") monthly = bj.groupby("YM")["TAVG"].mean() clim = monthly[(monthly.index.year >= 1991) & (monthly.index.year <= 2020)] print(clim.groupby(clim.index.month).mean())跑出来北京 1 月平均气温在 -3°C 左右,7 月在 26°C 左右,和公开资料基本吻合,就可以放心往下做了。
6.2 可以产出的实际应用
这套 18 要素 CSV 最大的价值是能直接对接多个下游模型。
建筑能耗模拟里,最常用的是把逐日数据展开成典型气象年,计算采暖度日数(HDD)和制冷度日数(CDD)。用 TAVG 就可以算:
base_heat = 18.0 merged["HDD18"] = (base_heat - merged["TAVG"]).clip(lower=0) merged["CDD26"] = (merged["TAVG"] - 26.0).clip(lower=0)农业气象里,可以用 TMAX 和 TMIN 计算有效积温,判断作物生长季长度。水文和干旱监测里,用 PRCP、TAVG、DEWP 算潜在蒸散量,虽然精度不如逐小时辐射数据,但长序列趋势分析完全够用。
6.3 多源数据交叉补全的思路
我这次只用了 GSOD 加 GHCN-Daily 两套数据。如果你需要更高密度的站点,还可以加入 NOAA 的 ISD(集成地面数据集)逐小时数据,但文件体积会暴增。另外一个思路是以国内公开的站点数据做二次校准,用线性回归把 NOAA 序列和国内站点序列的趋势对齐,再拼接成更长、更均匀的序列。
我个人的体会是,整理这类数据时,最重要的不是算法多复杂,而是把字段含义、单位换算、缺失值规则逐条写清楚。CSV 本身只是一个载体,真正值钱的是那套经过校验的清洗逻辑。这次整理的 18 要素 CSV,已经把 GSOD 里最容易踩的单位坑、缺测坑、天气标志位坑全部处理掉了,后续再跑任何统计都不用回头折腾原始文件。如果你也想做类似的长序列分析,建议先只挑一个站、一个年份,把整个解析流程跑通,确认无误后再放开到全量数据。