干气候数据分析这行的朋友,一定绕不开Niño 3.4这个区域:西经170度到120度、南纬5度到北纬5度,这块不到全球海面千分之一面积的海域,它的海温距平直接定义了我们常说的厄尔尼诺/拉尼娜事件。我最近用GEE Python API配合Xarray的xee后端,把整个计算流程压缩到了30行以内:数据直接从Google Earth Engine取,时间序列在Xarray里用groupby和rolling处理,最后输出ONI风格(3个月滑动平均)的Niño 3.4指数。这篇文章不光是给代码,更想把为什么这么设计、哪些参数必须留神、跑数据会踩哪些报错都讲透,对刚上手GEE Python、又想认真做气候时序的人应该挺有用。
1. 方案选型与设计思路
1.1 Niño 3.4指数是什么,为什么有人非要自己算
Niño 3.4指数本质上就是一个区域海温异常值:先算出Niño 3.4区域内逐月平均海表温度,减去对应月份的多年气候态,得到一个“距平”,再做3个月滑动平均。超过正负0.5度通常就被定义为El Niño或La Niña事件。官方机构发布的是现成的,但自己算index在科研、业务预报和项目复现中都非常常见——你可能换了数据集、换了区域权重方案、或者想做历史事件的重建,都不可能直接拿官方序列来顶。
用GEE来干这件事的好处是明摆着的:几十年的海温数据在上面都有,OISST、ERA5、MODIS SST随手就能取,省去了挨个下载NetCDF的麻烦。真正麻烦的是怎么把GEE的ImageCollection变成一条好处理的时间序列。传统做法是for循环里排队调reduceRegion,一次算一个影像,几十年的月度数据要循环几百次,看着就想关机。
1.2 为什么这次的主角是Xarray而不是纯GEE
如果只是算“每年某几个月的平均”,GEE自带reduceRegion完全够用,还能几十个影像并行跑。一旦你进入时序分析的深水区——逐月距平、季节循环剔除、滑动平均、异常事件筛选——纯GEE写起来就非常别扭,要维护很多中间状态,影像集合的波段和属性操作也远不如数据框直观。
Xarray刚好补上这块短板。它内部感知NetCDF风格的维度坐标,你可以把GEE的影像集合直接当成一个带lon、lat、time三维的DataArray来处理,然后像操作普通数组那样做切片、平均、滚动窗口。xee这个库就是干这个桥接工作的:它实现了一个新的后端,让xr.open_dataset可以直接读取ee.ImageCollection。代码量一下子砍掉一大截,可读性也高得多。
1.3 整体数据流设计
我在动手前先画了条清晰的链路,后面所有代码都沿着它走:
- 第一步,初始化GEE,选定SST数据源。
- 第二步,用xee把ImageCollection加载成xarray DataSet,明确坐标系和分辨率。
- 第三步,裁剪到Niño 3.4矩形区域,并整理lat/lon顺序。
- 第四步,按区域求月平均,得到一维的时间序列。
- 第五步,按月份分组计算气候态,再求逐月距平。
- 第六步,做3个月中心滑动平均,得到Niño 3.4指数。
- 第七步,画图核对、导出CSV。
这个流程里,第三步到第六步是最容易出错的,第四步到第六步则是Xarray最能发挥价值的地方。我把每一步的细节和原理都拆在下面了。
2. 环境准备与数据源选择
2.1 装环境与认证,天猫不是最坑的,认证才是
环境配置其实很常规,Python 3.9以上都行。我本地用的是3.11,虚拟环境里装了下面几个包:
pip install earthengine-api xarray xee dask[array] matplotlib pandas核心就一个容易版本冲突的xee。如果你习惯用conda,也可以走conda-forge渠道,版本更稳。装完之后第一件必须做的事情是认证:
import ee ee.Authenticate()同意授权后会生成一个token,之后在代码里初始化:
ee.Initialize(project='your-gcp-project-id')新版earthengine-api要求显式指定project,否则会报项目未指定的错误。这个细节在官方文档里容易忽略,我第一次跑就被它卡了十分钟。你要是在国内跑,GEE的接口访问本身一般没问题,我这里只提一句网络原因导致的超时别先怀疑代码。
2.2 三个常用SST数据源怎么选
GEE上海温数据不少,但做Niño 3.4指数真正靠谱的就是下面三个,各有各的脾气。我给它们整理了一个对照表:
| 数据源 | GEE集合ID | 时间分辨率 | 空间分辨率 | 适用场景 |
|---|---|---|---|---|
| ERA5月平均 | ECMWF/ERA5/MONTHLY | 月 | 0.25° | 快速验证、长时间序列 |
| NOAA OISST | NOAA/CDR/OISST/DAILY | 日 | 0.25° | 业务级ENSO监测 |
| MODIS Aqua L3SST | NASA/GSFC/MODISA/L3SST/MONTHLY | 月 | 约4.6km | 近实时遥感反演研究 |
我这次推荐ERA5月平均做默认演示,原因很实在:数据量小、下载快、覆盖时间从1979年到现在,变量名也好找。你要想做更精细的日尺度分析或者和官方ONI序列比对,建议改用OISST,但代价是数据量会大不少,对网络和内存要求更高,建议把区域裁小后再做重采样。
2.3 区域边界和单位这些坑先避开
Niño 3.4的区域经纬度容易抄错——西经170°到西经120°,南纬5°到北纬5°。在GEE里,这个矩形是ee.Geometry.Rectangle([-170, -5, -120, 5]),注意坐标顺序是[west, south, east, north],不是随便两个对角点就行。我在初学阶段把顺序写反过,出来的区域跑到赤道另一侧,指数完全对不上。
另外一个高频错误是单位。ERA5的海温变量sea_surface_temperature单位是开尔文(K),OISST的sst也是K,MODIS同样。计算距平的时候因为减掉气候态,单位偏移会被消除,但画图时一定要减273.15转成摄氏度,否则一条300K的“异常曲线”会让人误以为全球变暖到了离谱程度。我习惯在读数据之后立刻转单位,避免后面对数轴时犯迷糊。
3. 核心实现:用Xarray把指数一步步算出来
3.1 读取ImageCollection并裁剪
加载数据是xee最漂亮的时刻。之前我要么写循环调用reduceRegion,要么笨拙地拼接数组,现在一行就能把GEE的影像集合变成一个标准的xarray对象:
import ee import xarray as xr import xee ee.Initialize(project='your-gcp-project-id') ds = xr.open_dataset( 'ee://ECMWF/ERA5/MONTHLY', engine='ee', crs='EPSG:4326', scale=0.25 )这个操作会把集合里的每个影像当作一个时间切片,自动生成time坐标。你可以继续用xarray的sel直接切片:
sst_raw = ds['sea_surface_temperature'] sst = sst_raw.sel( time=slice('1991-01-01', '2024-12-31') ).where( (sst_raw.lat >= -5) & (sst_raw.lat <= 5) & (sst_raw.lon >= -170) & (sst_raw.lon <= -120), drop=True )这里我没有直接用sel(lat=slice(...)),而是用where + drop=True。原因很实际:GEE返回的纬度顺序不一定是从小到大,万一是降序,slice(-5, 5)会直接筛出空数组,而where不管顺序只管数值大小,逻辑更稳。后面记得再做一次sortby('lat'),方便后续绘图和权重计算。
3.2 区域平均:直接mean还是余弦加权
区域平均看起来简单,但有个纬向权重问题。海温格点在不同纬度上代表的实际面积不一样,如果要严格按面积求平均,需要乘上纬度余弦权重:
import numpy as np weights = np.cos(np.deg2rad(sst.lat)) # 在纬度上广播权重,再按面积加权平均 sst_area_weighted = ( (sst * weights).mean(dim=['lon', 'lat']) / weights.mean(dim=['lat']) )对于Niño 3.4这块窄窄的赤道区域,纬度跨度只有10度,余弦权重的影响微乎其微,所以直接mean(dim=['lon', 'lat'])其实也完全可以。我算过,两种方法在指数序列上的差距大约在0.01°C以内,对事件判定没有本质影响。但既然写进博客,我就把严谨版本放上来,平时偷懒用简单版也行,心里有数就好。
这里有个性能小技巧:如果你只是求区域平均,根本不用把整个区域的二维网格都载入内存。xee在读取后端支持scale和crs参数,你可以把分辨率设得比原始低一些,比如ERA5用1°再去平均,指数照样稳定,数据量却直接降到十六分之一。批量测试方案、切换数据源的时候,这种降采样能让你跑得飞快。
3.3 逐月距平与3个月滑动平均
拿到月平均序列后,先要给数据一个完整的月度时间索引。ERA5月平均数据本身是按月存储的,但如果用OISST日数据,就得先做月合成。这里我先展示从月数据直接算的过程,日数据降尺度在后面的兼容方案里讲。
第一步,算1991-2020年的逐月气候态。这是判断异常值的基准,年份选30年比较稳,行业惯例也是30年:
clim = sst_area.sel( time=slice('1991-01-01', '2020-12-31') ).groupby('time.month').mean(dim='time')第二步,用groupby对齐每个月的气候态,求逐月距平:
anom = sst_area.groupby('time.month') - clim anom = anom.drop_vars('month', errors='ignore')这里drop_vars是防止groupby运算后把月份残留成坐标,干扰后面的时序操作。算距平这一步是整个流程里最体现Xarray优势的地方:groupby自动帮你把1月对1月、2月对2月,完全不用手写循环匹配。之前用纯GEE做同样的事情,代码又臭又长还容易错。
第三步,做3个月滑动平均,让指数反映出持续性的暖/冷状态:
nino34_index = anom.rolling(time=3, center=True).mean().dropna('time')center=True表示以当前月份为中心,用前后各一个月做平均,这正是ONI定义里“3个月滑动平均”的标准做法。rolling落在这个月,自动把前一个月和后一个月一起平均,得到的结果通常比简单滞后滑动更平滑、更贴近官方定义。
3.4 可视化与导出
算完指数不画图总觉得少点什么。matplotlib直接和xarray无缝集成,一行就能出曲线:
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(12, 4)) nino34_index.plot(ax=ax, color='#333333', linewidth=1.5, label='Niño 3.4 index') ax.axhline(0.5, color='red', linestyle='--', linewidth=0.8) ax.axhline(-0.5, color='blue', linestyle='--', linewidth=0.8) ax.axhline(0, color='black', linewidth=0.8) ax.set_title('Niño 3.4 SST Anomaly (3-month running mean)') ax.legend() plt.tight_layout() plt.savefig('nino34_index.png', dpi=150)导出CSV就更直接了,xarray的to_dataframe会把time索引一起带出来:
nino34_index.to_dataframe(name='nino34_sst_anom').to_csv('nino34_index.csv')导出的CSV里别忘看时间范围是不是和你期望的一致。我在第一次导出时发现时间索引末尾多了几个NaN,这是因为滑动窗口在序列边缘没有足够的邻居。用dropna('time')去掉之后干净很多。
4. 常见问题与排查技巧
4.1 典型报错速查
跑这套流程我前后踩了不少报错,有些错误信息极其抽象,新手遇到基本会懵。我把最常见的几个整理成速查表,方便直接对照:
| 报错/现象 | 大概率原因 | 解决办法 |
|---|---|---|
Projection EPSG:4326 at scale...not aligned | 数据原始投影和请求的crs不一致 | 把scale改到数据源原始分辨率,或用匹配的投影 |
Resource has expired | GEE句柄超时或认证过期 | 重新ee.Initialize,检查网络 |
| 时间坐标为NaT | 集合里的影像没有统一的system:time_start | 转换前给集合设置system:time_start |
| 数据只有一个时间切片 | xr.open_dataset没有正确读取集合 | 检查集合ID是否写错,确认engine='ee'带上了 |
| lat方向反了 | GEE输出的纬度顺序不定 | 用sortby('lat'),避免直接用slice切降序数组 |
| 内存崩溃 | 全程不分块、一次性把所有像素拉进内存 | 开chunks参数,比如chunks={'time': 12, 'lat': 20} |
4.2 结果和官方对不上的三个原因
自己算出来的指数和NOAA官方ONI画在一起,相关系数应该很高才对。如果对不上,先别怀疑自己代码,优先检查下面三件事:
第一,基准期不同。NOAA官方序列通常使用1981-2010或1991-2020作为气候基准期。不同基准期算出来的距平,尤其在长期变暖趋势明显的背景噪声下,会有系统偏移。一定要先确认你和官方用的基准年份一致。
第二,数据源不同。OISST、ERA5、MODIS的海温绝对值及其季节循环本身就有细微差异,异常值自然也会差零点零几度。做正式结论前,先固定数据源,别混着用。
第三,区域平均权重。前面提到过,10度纬度范围内余弦权重和算术平均差异很小,但如果你把范围写错(比如把西经120写成东经120),结果会偏差极大。这种低级错误最伤时间,我建议在代码里加一个断言,计算区域平均前打印lat和lon的范围,一眼就能看出来。
4.3 性能与内存调优经验
ERA5月平均整个全球产品有几十个变量、上百万个网格点,一股脑读进来,内存再大也扛不住。我试过几个方案,最稳的做法是在xr.open_dataset时给xee传chunks参数,让dask按块懒加载:
ds = xr.open_dataset( 'ee://ECMWF/ERA5/MONTHLY', engine='ee', crs='EPSG:4326', scale=0.25, chunks={'time': 12, 'lat': 20, 'lon': 20} )这样dask会按照分块策略逐个调度请求,不会一次性把所有影像都下载到本地。做完区域平均后,dask还支持持久化到内存,后续多次绘图和分组计算都直接在内存里跑,速度能快上几倍。
还有一个容易被忽略的点:访问GEE影像集合本身有配额和吞吐限制。如果你一口气请求40年日尺度OISST,很可能会被限流或是长时间卡住。我的经验是,先只test三年数据跑通全流程,确认结果没问题,再放开到完整时间段。这样既能快速验证思路,又不会把月底的配额一次性耗光。
我个人在实际操作中还特别喜欢把完整流程封装成一个函数,输入只有start_year、end_year、dataset_id三个参数,输出一个DataArray。这样反复对比不同数据源、不同基准期,几乎不用改代码,只需要被替换的数据集名不同而已。数据的复用和版本管理也会清晰很多。
最后分享一个小技巧:算完指数之后,你完全可以顺手把Niño 4、Niño 3、Niño 1+2这几个区域一起跑出来,代码只需要改一下矩形范围,拿到手的就是一套完整的ENSO监测指标。别一次性堆需求,先把Niño 3.4跑顺,其他区域就是复制粘贴的事。