气象数据

气象数据工程化最容易踩的五个坑:命名、单位、时区、缺测与数组对齐

· 南京运梦科技算法团队 · 评审 算法负责人

气象数据工程化最容易踩的五个坑:命名、单位、时区、缺测与数组对齐 封面

做新能源的人迟早会接一段气象数据:风功率预测要 100m 风、光伏功率预测要 GHI/DNI/DHI、电量评估要长序列再分析。代码看起来不复杂——拉数组、塞模型、出曲线。但真正把一段气象数据从「原始文件」变成「能进模型、对得齐时间轴、单位无歧义」的特征矩阵,中间藏着一串极易踩的坑。这些坑不会让程序崩溃,只会让你的预测悄悄偏几个百分点,等月度考核按国家「两个细则」扣了分才回头发现是数据工程出了问题。本文把我们在生产里反复遇到的五类坑摊开讲:CF 变量命名 vs 原始 GRIB 名、单位换算、时区与时间戳对齐、缺测与异常值、JSON envelope 的数组对齐。

关键要点

  • 气象数据工程化最常见的五类坑:CF 变量命名 vs 原始 GRIB 名、单位换算、时区对齐、缺测与异常值、JSON 数组对齐——它们不会让程序崩溃,只会让功率预测悄悄偏几个百分点。
  • 单位是高发区:ERA5 原生 t2m 是开尔文(℃ = K − 273.15),ssrd 是 J·m⁻² 累积量,需除以累积窗口秒数换成 W·m⁻²(小时数据即 /3600),漏除会把 GHI 放大约三个数量级。
  • 时区必须显式:气象数据原生时间轴几乎都是 UTC,中国电力业务用北京时间(UTC+8),不声明时区会让整条特征系统性平移 8 小时。
  • 风分量要在 u/v 笛卡尔空间插值再合成风速风向,绝不能对风向角度直接线性插值(359° 和 1° 会插出 180° 的错误结果)。
  • 运梦气象 API 在命名、单位、时区三层做了归一(CF 命名、tas 返回 ℃、rsds 返回 W·m⁻²、timezone 必填),并以统一 JSON envelope 返回平行数组,调用时仍需先判 success/code、再校验各字段数组与 timeList 等长。

坑一:CF 变量命名 vs 原始 GRIB 名

第一类坑发生在你都还没开始算之前——变量到底叫什么。

国际气候学界有一套 CF(Climate and Forecast)命名约定,它给气象物理量规定了标准名(standard_name)和一套约定俗成的短名:2m 气温叫 tas、相对湿度叫 hurs、地面气压叫 sp、降水叫 pr、10m 风的两个分量叫 uas/vas、100m 风叫 u100/v100、地表入射短波(也就是光伏圈说的 GHI)叫 rsds、直接法向辐照叫 dni、散射水平叫 dhi。

但你从 ECMWF 原始 GRIB/NetCDF 里下下来的不是这些名字。ERA5 原生文件里 2m 气温叫 t2m、10m 风分量叫 u10/v10、地表短波累积叫 ssrd、气压可能是 sp 也可能是 msl(海平面气压,完全是另一个量)。GRIB 里还并行存在 paramId、shortName、cfVarName 三套标识,不同解码库(cfgrib、pygrib、wgrib2)给你的键名可能都不一样。于是同一个「2m 气温」,在再分析文件里是 t2m、在同事的脚本里是 temp、在模型特征里是 tas,三套名字在一个 pipeline 里反复打架。

这件事的危害不是看不懂,而是看错。最经典的事故是 u 分量和 v 分量搞反:CF 约定 uas 是纬向风(西风为正、指向东),vas 是经向风(南风为正、指向北),一旦把 u 当成 v,算出来的风向直接偏 90°,下游风电机组的偏航修正全错。另一个事故是 sp 与 msl 混用:地面气压随海拔显著变化,海平面气压做过订正,两者在高海拔场站能差几十 hPa,直接污染空气密度计算,进而带偏理论功率曲线。

工程上的纪律只有一条:在 pipeline 入口处就把字段名归一到一套标准命名,并显式记录每个字段的物理含义、高度层和正方向,不要让原始 GRIB 名扩散到业务代码里。我们的做法是维护一张「外部名 → 内部 CF 名」的映射表,所有解码出口强制过这张表。运梦气象 API 在这一层直接帮你省掉了——它对外统一用 CF 命名(tas/uas/vas/u100/v100/rsds/dni/dhi/ws/wd),不需要你再写一层 t2m→tas 的换名逻辑。

坑二:单位换算——K↔℃ 与 J·m⁻² 累积↔W·m⁻² 功率

第二类坑是单位。气象量的单位在「科学原始口径」和「工程可用口径」之间往往不一致,换错一个量级,曲线还在、模型照跑,但结论已经错了。

温度 K↔℃。 ERA5 原生 t2m 单位是开尔文(K),不是摄氏度。273.15 这个偏移量看着无害,但如果你把 290 K 当成 290℃ 喂进一个对温度敏感的组件功率模型,光伏的温度修正项会算出离谱的出力。换算本身是 ℃ = K − 273.15,问题在于「你以为已经换过了」——半个团队拿到的是 K、半个团队拿到的是 ℃,没有元数据标注就是定时炸弹。

辐射 J·m⁻² 累积↔W·m⁻² 功率。 这是辐射数据最致命的坑。ERA5 的 ssrd(地表短波)原生是累积量,单位 J·m⁻²,含义是「某段时间窗内累积到单位面积上的能量」。而光伏建模要的是瞬时/平均功率密度,单位 W·m⁻²。两者差一个时间因子:功率 = 累积能量 / 累积时间窗秒数。ERA5 单层小时数据的累积窗口是 1 小时,所以 W·m⁻² = J·m⁻² / 3600。如果你不除这个 3600,GHI 数值会放大约三个数量级(一个正午 800 W·m⁻² 的辐照会显示成约 2.88×10⁶),任何归一化都救不回来。更隐蔽的是 ERA5 累积量存在「累积起点」约定(每日某时刻重置),逐时差分时会出现负值或跳变,必须按官方约定先差分再当瞬时值用。

其它易错单位。 气压原生 Pa、工程常用 hPa(÷100);降水原生 m 或 kg·m⁻²、工程常用 mm(kg·m⁻² 与 mm 数值相等,但 m 要 ×1000);相对湿度有的源是 0–1、有的是 0–100。

运梦气象 API 在这一层做了归一:返回的 tas 直接是 ℃、rsds 直接是 W·m⁻²、sp 是 hPa、pr 是 mm/h,省掉了 −273.15 和 /3600 这两步最易出错的换算。但理解底层口径仍然必要——当你同时接入多个数据源、或拿原始 GRIB 做交叉校验时,知道「这个数到底是累积还是瞬时、是 K 还是 ℃」,才不会被自己的脚本骗。

坑三:时区与时间戳对齐——timezone 必须显式

第三类坑最阴险,因为它在小数据量测试时几乎不暴露,一上生产就偏。

气象再分析和数值预报的原生时间轴几乎都是 UTC。而中国的电力业务、调度口径、考核窗口全是北京时间(UTC+8)。如果你拉数据时没有明确时区,把 UTC 的时间戳当成本地时间直接对齐到负荷/出力曲线,整条特征会系统性平移 8 个小时——正午辐射峰值跑到下午、夜间低温扣到白天。模型还能训出来,因为它会自己学会这个 8 小时偏置,可一旦数据源或环境的时区行为变了,模型立刻失准,而且极难定位。

更细的坑:时刻标签代表瞬时值还是时段平均/累积。瞬时量(气温、风)的 12:00 就是那一刻;而累积量(辐射、降水)的 12:00 可能代表 11:00–12:00 这一小时的累积,时间戳打在区间右端还是左端,不同产品约定不同。把右端标签的累积量当成左端瞬时值,整条辐射曲线会相位错半小时到一小时,光伏早晚的功率爬坡就对不齐。

工程纪律:时区是必填项,不是默认项。所有进入特征库的时间戳统一带时区(建议内部一律存 UTC、展示时再转本地),并在元数据里写清「这一列是瞬时还是时段量、标签在区间哪一端」。运梦气象 API 把 timezone 设成请求体的必填字段就是这个用意——你必须显式声明用东八区(传 "8"),返回的 timeList 就按这个时区给你对齐好的 yyyy-MM-dd HH:mm,从源头消除「忘了转时区」这一类事故。

坑四:缺测与异常值处理

第四类坑是数据质量。再分析在大多数地区物理一致、时空连续,但「连续」不等于「没有坏点」,工程上你一定会遇到三种情况。

真缺测(missing)。 文件里用一个填充值(fill value,常见 −9999、1e20 或 NaN)表示没有数据。最危险的是数值型填充值(如 −9999)没被识别成缺测,直接进了均值/标准化——一个 −9999 能把整列统计量带偏到没法看。处理前必须先按数据源约定把 fill value 显式转成 NaN。

物理异常值(outlier)。 相对湿度算出 105%、风速出现 80 m·s⁻¹、辐射在深夜非零——这些往往是插值/单位/解码引入的伪值。要用物理上下界做硬校验:湿度 0–100%、地面风速一般 < 60 m·s⁻¹、夜间 GHI 应为 0、气温落在场站气候范围内。超界的点标记为可疑,而不是默默保留。

插补策略要分量纲。 不是所有缺口都能线性插值。短缺口(1–2 小时)的连续量(气温、气压)可以线性插值;风的分量应在 u/v 笛卡尔空间插值再合成风速风向,绝不能直接对风向角度(0°/360° 环绕)做线性插值,否则 359° 和 1° 会插出 180° 的荒谬结果;辐射的缺口要考虑昼夜,夜间补 0、白天宁可标缺也别乱插。长缺口(> 6 小时)不要硬补,宁可丢弃该样本或用同源邻近格点回填。

落地建议:把「校验 → 标缺 → 插补 → 留痕」做成 pipeline 里独立、可审计的一步,每一步都记录改了多少点、用了什么规则。质量问题最怕的不是有坏点,而是坏点被悄悄抹平、事后无从追溯。

坑五:JSON envelope 的数组对齐

第五类坑出现在 API 接入的最后一公里。运梦气象 API 不返回 NetCDF/CSV,也没有 format 参数,而是返回一个统一的 JSON envelope:外层是 code、success、data、msg、errorCode,真正的数据在 data 里——一个 timeList 时间数组,加上每个请求字段各自一个等长数值数组(data.tas、data.rsds…),靠下标一一对应。

这种「列式 + 平行数组」结构有两个高频坑。其一,默认数组等长、下标对齐,但你必须自己校验:拿到响应先断言 data 里 tas 的长度等于 timeList 的长度,任何一个字段长度对不上都说明这批数据不可信,绝不能直接 zip 进 DataFrame。其二,先判 success/code 再取 data:失败时 data 可能为 null 或缺字段,不判状态直接取 data 里的 tas 会抛 KeyError 或静默拿到空列表,把空数据混进训练集。

正确姿势是:先用 raise_for_status 过 HTTP 层 → 再判业务层 success → 再校验各字段数组与 timeList 等长 → 最后才按下标组装。把这三道关写进一个统一的解析函数,所有调用都过它。

在运梦气象 API 上手

下面给一段可直接跑的最小示例:拉一个点一段时间的气温、100m 风分量和地表短波辐射,覆盖上面讲的「显式时区 + 状态校验 + 数组等长校验 + 下标对齐」。历史用 ERA5(dataSourceId="era5"),预报用德国气象局(dataSourceId="ger",覆盖未来约 7 天)。

import os
import requests
import pandas as pd

API = "https://console.yun-meng.top/api/energy-weather/search/weather/action/downloadSync"
TOKEN = os.environ["YUNMENG_TOKEN"]  # 控制台创建的 sk- API Key

payload = {
    "dataSourceId": "era5",          # 历史再分析;预报改成 "ger"
    "lat": 32.03253,
    "lon": 117.35184,
    "stime": "2024-06-01 00:00",
    "etime": "2024-06-07 23:00",
    "timezone": "8",                 # 必填:东八区,从源头对齐时间轴
    "fields": ["tas", "u100", "v100", "rsds"],
}

resp = requests.post(
    API,
    headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
    json=payload,
    timeout=600,
)
resp.raise_for_status()                       # 1) 过 HTTP 层

result = resp.json()                          # 统一 JSON envelope
if not result.get("success"):                 # 2) 判业务层状态
    raise RuntimeError(result.get("msg", "查询失败"))

data = result["data"]
time_list = data["timeList"]

# 3) 校验每个字段数组与 timeList 等长,杜绝下标错位
for f in ("tas", "u100", "v100", "rsds"):
    assert len(data[f]) == len(time_list), f"字段 {f} 与 timeList 长度不一致"

# 4) 按下标一一对应组装;u/v 在笛卡尔空间合成 100m 风速
df = pd.DataFrame({
    "time": pd.to_datetime(time_list),        # 已按 timezone=8 对齐
    "tas":  data["tas"],                       # API 已返回 ℃,无需 -273.15
    "u100": data["u100"],
    "v100": data["v100"],
    "rsds": data["rsds"],                      # API 已返回 W·m⁻²,无需 /3600
}).set_index("time")

df["ws100"] = (df["u100"] ** 2 + df["v100"] ** 2) ** 0.5
print(df.head())
print(f"共 {len(df)} 条;时段 {df.index.min()} ~ {df.index.max()}")

注意几个细节:timezone 显式传 "8",timeList 回来就是东八区,不用再做 UTC→本地的平移;tas 直接是 ℃、rsds 直接是 W·m⁻²,省掉了最易错的 −273.15 和 /3600;100m 风速用 u/v 平方和开方,而不是对风向插值;取 data 前先判了 success、组装前先校验了数组等长。这套骨架可以直接套到风功率、光伏功率的特征工程里,把上面五类坑一次性挡在模型之外。

常见问题

ERA5 的辐射数据为什么要除以 3600?

ERA5 的 ssrd(地表短波)是累积量,单位 J·m⁻²,含义是一段时间窗内累积到单位面积上的能量;而光伏建模要的是瞬时/平均功率密度,单位 W·m⁻²。两者差一个时间因子,ERA5 单层小时数据的累积窗口是 1 小时,所以 W·m⁻² = J·m⁻² / 3600。不除这个 3600,GHI 数值会放大约三个数量级。

气象数据为什么一定要显式指定时区?

气象再分析和数值预报的原生时间轴几乎都是 UTC,而中国电力业务、调度和考核窗口用的是北京时间(UTC+8)。不明确时区就把 UTC 时间戳当本地时间对齐,整条特征会系统性平移 8 个小时,模型虽能学会这个偏置,但一旦时区行为变化就立刻失准且极难定位。运梦气象 API 把 timezone 设为必填字段,传 "8" 即按东八区对齐返回。

风的缺口可以直接对风向角度做线性插值吗?

不能。风向是 0°/360° 环绕的角度量,直接线性插值会让 359° 和 1° 插出 180° 的荒谬结果。正确做法是在 u/v 笛卡尔空间插值,再合成风速风向。

CF 变量命名和原始 GRIB 名有什么区别,为什么容易出错?

CF 是国际气候学界的标准命名约定(如 2m 气温 tas、100m 风 u100/v100、地表入射短波 rsds),而从 ECMWF 原始 GRIB/NetCDF 下下来的是 t2m、u10/v10、ssrd 等原生名,且 GRIB 里并行存在 paramId、shortName、cfVarName 三套标识。同一物理量在不同脚本里名字打架,最经典的事故是把 u 分量当成 v 分量、风向偏 90°。工程纪律是在 pipeline 入口处把字段名归一到一套标准命名。

运梦气象 API 返回的 JSON 数据要怎么安全解析?

它返回统一 JSON envelope:外层 code、success、data、msg、errorCode,data 里是一个 timeList 时间数组加每个字段各自一个等长数值数组,靠下标对应。正确姿势是先用 raise_for_status 过 HTTP 层、再判业务层 success、再校验各字段数组与 timeList 等长、最后才按下标组装,并把这三道关写进统一的解析函数。

收尾

气象数据工程的坑,本质上都是「口径没说清」:变量叫什么、单位是什么、时间在哪个时区、坏点怎么处理、数组怎么对齐。这些都不是高深算法,但每一个都能让你的功率预测悄悄偏几个点。可靠的做法是在 pipeline 入口处把命名、单位、时区一次性归一,把质量校验和数组对齐做成可审计的独立步骤,绝不让模糊口径流到模型里。把这几道关守住,数据这一层就不再是预测精度的隐形漏点。

参考与延伸阅读