前阵子接到一个需求:把一套全国范围、1km分辨率、逐日尺度的土壤湿度数据集,处理成逐月和逐年尺度。刚接到这个活儿时我觉得这还不简单?数据是现成的,用Xarray打开,resample(time='ME').mean()一下不就完事了。真正动手才发现,从NetCDF的内部结构、时间轴的日历设定,到月份天数不均、无效值掩码、缺测日期,再到千万级网格点的计算资源调度,几乎每一步都会让结果悄悄出错。这篇文章就把我这次完整的处理过程、踩过的坑和最后采用的方案记录下来,给同样要处理日尺度栅格时间序列的朋友做个参照。
这一次的处理环境是Python 3.10 + Xarray + Dask + NetCDF4,操作系统是Linux服务器,内存256GB、40核。下面按我实际处理的顺序来写,每个环节都有代码和说明。
1. 开工前先把数据集结构摸透:维度、时间轴和空间范围
1.1 打开NetCDF文件,别急着resample
很多人拿到netCDF文件就直接ds = xr.open_dataset(path)然后开始算,我建议第一步先慢一点,把文件的结构完整看一遍。土壤湿度这种大区域日尺度数据,往往不止一个变量,有的文件里除了soil_moisture还带着quality_flag、land_cover之类的辅助字段,还有lat、lon、time三个维度的坐标信息。如果不管三七二十一全读进来再聚合,后面的输出体积和计算量都会白白翻倍。
我习惯先跑一段只打印结构、不加载数据的代码:
import xarray as xr fp = 'soil_moisture_daily.nc' ds = xr.open_dataset(fp) print(ds) print('数据变量:', list(ds.data_vars.keys())) print('坐标维度:', dict(ds.sizes))xr.open_dataset默认是惰性加载的,只有当你真正访问数据时才会读进内存,所以这一步非常轻量。打印出来的结构信息里,我最关心这几件事:变量的名字是不是我要处理的soil_moisture;维度名是time、lat、lon还是像x、y这种投影坐标;时间维的长度是多少,空间维的长宽各是多少。1km全国尺度,经纬度网格通常会有一个上万乘上万的长宽组合,如果数据是分省或者分带存储的,可能一个文件里只有某个子区域,那就还得做拼接。
另外要注意坐标变量的类型。有的数据集里lat、lon不是维度而是数据变量,这种情况需要ds = ds.set_coords(['lat', 'lon'])把它声明成坐标,否则后续sel、isel都会不顺手。数据变量里如果带着quality_flag这种质量标识,我会看一眼它的取值说明,后面做无效值剔除会用到。
1.2 时间轴的坑:单位、日历和缺失日期
时间轴是这次处理里最大的坑,没有之一。NetCDF里的时间存储方式不是我们平时看到的“2021-01-01”,而是一个数值,配合units和calendar两个属性来解释。常见的单位有hours since 1900-01-01、days since 2000-01-01,如果单位弄错了,整个时间轴会整体偏移几十上百年,聚合出来的月份完全对不上。
我喜欢把时间轴完整打印出来看一遍:
time = ds['time'] print(time.dtype) print(time.encoding.get('units')) print(time.encoding.get('calendar')) print(time.values[:10])如果时间值看起来是整数天数,那说明数据集时间分辨率确实是逐日的,这个可以放心。如果出现了小数,比如22405.5这种,就得警惕数据是不是逐日某一时刻的快照。土壤湿度如果是模拟输出,时间标签经常会落在当天的某个整点;如果是遥感反演产品,可能标记的是卫星过境时刻。不管哪种情况,处理成逐月、逐年前都要先确认一天内只有一个时间戳,如果有多个,直接resample会把同一天多个时刻都平均进去,得先做日合成。
还要看一下时间轴是否完整连续。用一行代码就能查出来:
import pandas as pd time_index = pd.to_datetime(ds['time'].values) diff_days = pd.Series(time_index).diff().dropna().dt.days print('时间差值的分布:', diff_days.value_counts())正常逐日数据,diff的取值只有1天一种。一旦出现了2天、3天甚至更大的间隔,说明数据存在缺失日期。这个缺失会影响什么,我在后面第4章专门讲,这里只要先把问题发现出来。我处理的这份数据就是典型的days since 2000-01-01单位,calendar='standard',时间轴完整,但这个发现过程不能省,因为不同批次数据的时间定义经常不一致。
另外,还要看时间是否带时区。NetCDF里一般不会显式存时区,但有的数据集默认是世界时(UTC),有的默认是北京时间。这个差别直接关系到日边界的划分——北京时间比UTC早8小时,如果不做转换,同一个UTC日期会被划分到错误的北京日期里,逐月的月均值边界就会偏差几个小时到一天。这个细节在日尺度上影响不大,但在严谨的月、年统计中必须确认清楚。如果数据集文档里只写了“daily”,我会额外看看是不是从UTC时间更新的日标签,必要时加8小时再重新取日期。
2. 日转月:resample的便利和两个必须确认的口径
2.1 用resample做月度均值,怎么写最稳
把日尺度数据聚合到月尺度,最直接的工具就是Xarray的resample方法。它的底层调用Pandas的时间重采样逻辑,能自动按日历月把时间标签分组,然后对组内所有时间切片做统计。土壤湿度属于状态变量,物理意义是某个时刻的体积含水量,所以聚合方式用平均值,而不是像降水、蒸散那样的累计值。这一点先确认一下:如果做的是通量变量,比如日降水、日蒸散量,逐日到逐月应该是sum()而不是mean(),这是两类完全不同变量的统计口径,千万别搞混。
对于状态变量的月平均,一段最稳的代码是:
var_name = 'soil_moisture' monthly = ds[var_name].resample(time='ME').mean(dim='time', keep_attrs=True)这里有两个地方要解释一下。'ME'是Pandas 2.2以后推荐的月结束频率别名,以前大家写'M'现在会触发FutureWarning,在Xarray里直接报错也不奇怪,干脆用新写法。dim='time'是明确告诉它沿时间维聚合,虽然resample已经指定了时间维,但写上更保险,能避免有些版本里对多维数据的行为差异。keep_attrs=True是保留原始变量的单位、长名称等元数据,这个细节看似小事,实际输出NetCDF后再拿去做绘图、做对比时,属性信息缺失会很麻烦。
2.2 月内天数不均和闰年的影响
用resample(time='ME').mean()处理逐日数据,默认的权重逻辑是:把一个月内的每一天值直接取平均。这就意味着30天月份和31天月份的日值权重不同,2月更特殊。这种“按日等权”对土壤湿度月均值来说是最合理的口径——每个月有几天实测值就平均几天,它代表的是该月日平均状态的简单平均。如果你想要的是“每月的日均值”,那么这就是正确结果。
但还有一个容易忽略的边界:部分数据集用的是calendar='360_day'或calendar='noleap',这类日历里每个月固定30天、没有闰年,多见于气候模式输出。如果单纯用Pandas默认的标准日历去resample,空的时间标签会导致分组错位或者某些月没有数据。遇到这种数据集,要在打开时显式指定:
ds = xr.open_dataset(fp, decode_times=True, use_cftime=True)use_cftime=True可以用cftime库正确解码非标准日历的时间轴。虽然我们日常能接触到的陆面数据产品大多走标准日历,但谁也不能保证下一份数据不是模式输出,这个判断步骤放在第1章的“时间轴检查”里做最合适。
2.3 一天边界的时区问题
月聚合中另一个很隐蔽的问题是时间标签的时区。如果数据集的时间戳是UTC时间,但我们的业务习惯是北京时间(东八区),那么直接按UTC日期分组就会导致每个北京日期的数据实际上从前一天UTC 16点就开始了。对于日尺度数据来说,这个偏移大多数情况下只是让“该日”的定义差了8小时,但如果数据同时有多个时次(比如每天8次),那8小时足够让几个时次落错到邻日,进而污染月均值的边界。
更稳妥的做法是,在做月度聚合前先把时间索引转成目标时区,再取日期标签:
time_bj = pd.to_datetime(ds['time'].values, utc=True).tz_convert('Asia/Shanghai') ds['time'] = time_bj这里需要确认数据集文档里对时间标签的定义。我处理的这份数据明确是北京时,就没做转换,但这个检查不能跳过。没有时区标注的数据,我通常默认按UTC日边界处理,并在输出文件的属性里标注清楚time_zone='UTC',避免后续使用者误读。这种元数据说明习惯,能省掉下游无数沟通成本。
3. 日转年:直接聚合日尺度,还是先算月再求年?
3.1 两种路径的数学关系和实际差异
日尺度到年尺度,直观上有两条路:直接对日值做年均,或者先求月均再用月均求年均。两条路在数学上不完全等价,因为每月的天数不同。直接对日值平均,等价于每月的权重是实际天数;先算月均再对12个月均做平均,则相当于把每个月的权重变成1/12。在土壤湿度这种变化相对平缓的变量上,两者差异通常很小,可能只有千分之一左右,但在严格的统计口径里,我强烈建议直接对日值聚合到年,一步到位。这样最符合“日观测值的简单平均”这个物理意义,也避免了两步聚合引入的潜在权重偏差。
还有一个统计上的考虑:分两步聚合会让年均在时间上偏向“均等的月份贡献”,如果你只关心气候态的月际差异,月均再年均也无妨;但你做的是真实年份序列,不同年份里2月、7月的实际天数权重就应该如实反映年平均状态。所以我的结论是:年尺度一定从日值直接聚合。一步到位。
3.2 缺测月会让年均值“看起来偏小”
直接对日值求年均,还会遇到一个真实的坑:缺测。如果某一年里1月有10天数据缺失,mean()默认的skipna=True会把这10天忽略,只对剩余的355天求平均。如果缺失分布在夏季(土壤湿度偏高的月份),年均可能偏高;如果缺失在冬季,年均可能偏低,而且我们根本察觉不到。更糟糕的情况是整月缺测,比如模型输出停机、文件传输失败,某个月整月没有记录,这时按日平均的年均值会把那个月完全晾在一边,年均值照样算出来,但样本量实际少了一个月,掩盖了缺失。
这个问题的处理核心是:聚合前先定义好“最小有效样本量”。Xarray里可以用min_count参数控制:
annual = ds[var_name].resample(time='YE').mean( dim='time', skipna=True, min_count=330 )min_count=330的意思是,只有当一年内有效天数达到330天以上时才输出年均值,否则输出NaN。这样既保留了年份间的不平衡(闰年366天、平年365天),又避免了缺测月份对年均的扭曲。这个阈值要根据数据质量和业务需求定,如果数据本身常年缺测较多,阈值就得放宽;如果对年均完整性要求苛刻,甚至可以设为skipna=False,任何一天缺测就整年NaN。实际操作中我倾向于把阈值设在300~340之间。
3.3 直接用groupby('time.year')的写法
除了resample(time='YE'),还有另一个常用写法是groupby('time.year').mean()。两者都能得到年均值,但有一个重要区别:resample('YE')要求时间轴连续且完整,它生成的年度区间是按年结束点对齐的;而groupby('time.year')是把每一年所有时间戳归到对应的年份数字上再聚合,不要求时间连续。对于有缺失时间的年份,groupby更宽容,它只聚合实际存在的时间戳,配合min_count同样可控。
我最终的年度处理写法是:
annual = ds[var_name].groupby('time.year').mean(dim='time', min_count=300, keep_attrs=True) annual = annual.rename({'year': 'time'})这里用rename({'year': 'time'})把分组后的year维度改回time,目的是让输出的NetCDF时间轴符合通用约定,后续做趋势分析、做时序绘图时不用额外处理。如果你不 rename,输出的维度名就是year,在Xarray里做时间序列操作时要手动适配,所以这一步我每次都做。
4. 无效值处理:填充值、掩码和min_count参数
4.1 先识别数据里的填充值和掩码属性
土壤湿度的原始NetCDF通常不会一是干净的0-1浮点数组,水体、冰川、裸岩、城市区域往往都赋了特殊值,有的是-9999.0,有的是NaN,有的会用一个单独的land_cover掩码变量把非陆地区域标成0。如果这些填充值没有被识别,直接参与月、年聚合,会把一片区域的土壤湿度均值拉成负值或者异常值,后面做分析时根本没法解释。
所以我在正式聚合前会先花几分钟检查变量的_FillValue和missing_value属性,并看一下数据的值域分布情况。一个常用的组合检查是:
import numpy as np sm = ds[var_name] print('attrs:', sm.attrs) print('缺测属性 _FillValue:', sm.encoding.get('_FillValue')) valid = sm.where(sm > -1000).values.flatten() valid = valid[~np.isnan(valid)] print('范围:', np.nanpercentile(valid, 1), np.nanpercentile(valid, 99))如果打印出来的值域范围明显不符合土壤湿度的物理意义(比如最小值是-9999、最大值是几万),那基本可以确定填充值没被正确处理。Xarray在open_dataset时默认会读取_FillValue并把它们转成NaN,但如果文件里用的是missing_value这个旧属性或者根本没有标注,就需要手动替换,这一步要主动做。
4.2 空间无效像元的聚合保护
对于粒度更细的质量控制,我用where把无效区域统一变成NaN,设置保留有效值:
sm_clean = ds[var_name].where( (ds[var_name] >= 0) & (ds[var_name] <= 1) )土壤体积含水量的理想取值范围是0到1(某些饱和土壤可能略高,但绝大多数产品会控制在0到1之间)。直接把小于0、大于1的值全部屏蔽,等于用物理常识做了一次整体质检。这一步能自动剔除掉水体本身的异常反射值、传感器饱和值等。我更倾向保留这个物理范围,而不是只针对单个_FillValue,因为它能顺带干掉许多数据噪声。
也可以用质量标识变量做掩码,比如上面提到的quality_flag。如果每个时刻都有对应的优质量标识,我只保留标记为“good”的像元参与聚合:
mask = ds['quality_flag'] == 0 # 假设0表示质量好 sm_qc = ds[var_name].where(mask)掩码处理之后再聚合,实际聚合时skipna=True会自动忽略所有NaN像元,所以聚合代码不用再改。需要注意的是:海上像元和裸岩像元如果大面积是永久NaN,月度聚合得到的也是对有限有效日期的平均,这些区域后续做均值空间分布图时看起来是“空洞”,属于正常现象,不用慌。
4.3 时间维缺测的两种处理策略
时间维度的缺测和空间无效值不一样,处理策略分两种。第一种是“补全时间轴再聚合”。如果数据偶尔缺一两天,我会先把时间轴补完整,缺失的时刻用插值或直接用NaN填充,然后再resample。这样做的理由是:月度均值需要基于真实天数,缺一天就按NaN跳过的做法,会悄悄改变每月的有效样本数,让各月均值的可靠性不一致。补全时间轴后,每个月的天数都对齐真实日历,配合min_count控制,统计口径就干净了。
第二种是“不补全,靠skipna兜底”。如果缺失的日期散乱且较多(比如超过10%),补全插值反而会引入虚假信息。这种情况下我会直接按原始时间轴聚合,但要明确该用skipna=True并配合min_count,同时把每年的有效天数单独存成一个变量输出,方便后续使用的人了解数据的有效覆盖程度。我这次处理的数据整体完整,所以采用了第二种策略来兜底,同时额外输出有效天数:
valid_count = sm_clean.notnull().astype('int32').resample(time='ME').sum(dim='time')这个有效天数变量本身就是一个很好的数据质量诊断产品,单独存一份,后续做分析时随时能查“某年某月到底覆盖了多少天”。
5. 千万级网格点的性能:chunk、延迟计算与压缩落盘
5.1 先估算体量再定chunk策略
1km分辨率的全国范围网格,如果用经纬度等距网格,空间维度通常是好几千万个有效网格点。假如网格是8000行×12000列,那就是9600万个像元;如果时间维有3650天(十年逐日),一天的浮点数据大约是9600万×4字节约384MB,整个文件的数据量就非常可观了。一次性全部读进内存做重采样,即使256GB内存也很难稳定跑完,更不用说还有多个变量、重叠计算和中间结果。
所以正确处理方式是延迟计算+分块。Xarray背后接Dask,把数组按块存储,只有在最终需要输出结果时才真正驱动计算,中间过程并不占用整块内存。这里的关键是chunks参数怎么定。
我建议的时间维chunk大小是30或90,空间维度可以整体作为一块或者按行分几块。对于重采样操作来说,时间维是聚合维度,块的大小会影响聚合时的中间结果数量,如果时间维上chunk太小(比如1天),聚合365天需要反复调度大量小任务,反而慢;太大(比如整年一块)又会一次性加载过多数据,内存容易爆。实测下来,时间维chunk=90,空间维度每块大概1000×1200,整体比较均衡。
ds = xr.open_dataset(fp, chunks={'time': 90, 'lat': 1000, 'lon': 1200})5.2 Dask调度配置与resample时的资源占用
分块之后,聚合计算会自动被Dask调度到多核上。Dask默认的调度器是threads,对于以IO和数值运算为主的操作,线程调度通常够用。但如果任务里有大量NumPy计算,用多进程调度(processes)能利用多核的独立计算能力,不过进程间传递大数组的开销也大,不一定更快。我这次用线程调度,配合dask.distributed.Client设置本地集群,处理效果比较稳定:
from dask.distributed import Client client = Client(n_workers=4, threads_per_worker=8, memory_limit='48GB')这里的配置要和服务器的实际资源匹配。核心数40,我用4个进程、每个进程8线程,已经能完整压满CPU;再增加worker数,收益递减,反而会引入调度开销。memory_limit设成48GB,是给每个worker一个软上限,防止某个aggregation任务突然吃掉整机内存。
有趣的是,resample这个操作对Dask的chunk策略很敏感。如果时间维的chunk没有跨月对齐,比如chunk大小=90天而每个月是30或31天,Dask在聚合时可能要做不必要的跨块通信。解决办法是用ds.chunk({'time': 365})先按整年分块,或者干脆在聚合前用ds.chunk({'time': -1})把时间维合成一个大块,空间维保持分块。但time维度如果合成一块,那一整年的数据在聚合瞬间会全部载入内存,体量不小。我的折中方案是时间维chunk=365,一年一个块,这样月度聚合需要跨块的地方不多,年度聚合则完全不需要跨块,整体计算效率最高。
5.3 输出时用编码压缩控制文件大小
处理完之后,输出月尺度文件时如果不做编码设置,一个9600万网格×12个月的文件动辄几十GB,后续存储、传输和读取都费劲。NetCDF4支持zlib压缩,压缩率对土壤湿度这种连续空间场通常很好,同时还能保留浮点精度,关键参数是complevel和zlib:
monthly.to_netcdf( 'soil_moisture_monthly.nc', encoding={ 'soil_moisture': { 'zlib': True, 'complevel': 4, 'dtype': 'float32' } } )complevel我一般设4,这个级别是压缩率和计算速度的平衡点,再往上压缩率提升不明显,但压缩耗时可能翻倍。dtype='float32'也很关键:如果原始数据是float64,不转成float32,文件体积直接大一倍。对1km土壤湿度这种精度的数据,float32完全够用,理论上有效精度约7位有效数字,远高于测量精度。这个输出优化步骤看似不起眼,但在文件体量几十GB的场景下非常实用。
6. 验证聚合结果:不是“跑完就完事”
6.1 单点时间序列抽查
计算完成,不等于任务完成。我在提交结果给下游之前,一定会做一轮抽查验证,最基础也是最有效的方法就是单点时间序列检查。随便挑几个代表性像元,把日值、月值、年值三组数据画在一起,检查每个月的月均是否大致落在当月日值序列的中间位置,年均是否落在全年日值的中心地带。
比如用sel选一个像元:
point = ds[var_name].sel(lat=40.0, lon=116.0, method='nearest') point_daily = point.values point_monthly = monthly.sel(lat=40.0, lon=116.0, method='nearest').values如果日均值和月均值曲线对不上,或者某个月的均值突然特别高或特别低,大概率是那段时间存在未处理的填充值或者质量标识骤变。这个检查要在不同区域多选几个点,最好覆盖湿润区、干旱区、高海拔区各一个,因为不同下垫面的有效像元覆盖情况完全不同。我这次抽查了三个区域,均值和值域都表现正常。
6.2 边界值检验和值域合理性
除了点位抽查,还要做整体值域检查。聚合后的月均值、年均值理论上仍然应该在0到1之间(或者至少是原始数据的有效范围内)。这个是批量验证的简单方法:
import numpy as np for varname, arr in [('monthly', monthly), ('annual', annual)]: print(varname, 'min:', float(np.nanmin(arr.values))) print(varname, 'max:', float(np.nanmax(arr.values))) print(varname, 'mean:', float(np.nanmean(arr.values)))如果打印出来的最小值是负数,说明原始数据里有漏网的填充值;如果最大值明显超过1,可能是水体的异常饱和或单位没统一。这时就需要回头检查第4章的掩码设置,而不是急着交付结果。还有一个小技巧:输出两个时间相邻月份的空间图,看是否存在明显的“接缝”式跳变。虽然月与月之间土壤湿度本来就有季节变化,但如果两个月均值在空间分布上出现完全细碎的噪声,而不是渐变,说明聚合过程中可能混入了缺测日期或无效像元。
6.3 聚合前后的统计量对比
还可以做一个整体统计量的对照:把日尺度数据不做任何掩码、直接所有有效值平均,和月/年聚合结果的有效值平均对比。两者应该非常接近,差异不会超过年度内季节波动的范围。这个对比用脚本就能完成,推荐先做日平均作为基准:
daily_allmean = ds[var_name].mean(dim='time').values monthly_allmean = monthly.mean(dim='time').values annual_allmean = annual.mean(dim='time').values print('日平均整体均值:', np.nanmean(daily_allmean)) print('月平均整体均值:', np.nanmean(monthly_allmean)) print('年平均整体均值:', np.nanmean(annual_allmean))如果这三个值差异很大,比如超过0.01(即1%),我就要复盘是否某一步的skipna设置出了问题,或者某个时间的缺测太多。正常情况下,日尺度聚合到月、年,整体均值应该是高度一致的,只在有效覆盖范围不同的情况下略有一丁点差异。
验证通过后,再写时间属性和变量元数据,把min_count阈值的设置、有效天数变量的含义写进NetCDF的attrs,就可以放心交付了。这次处理还算顺利,但中间也花了不少时间去排查时间轴的日历、无效值的隐藏位置和chunk的调度,每一个坑单独拿出来都不算大问题,连在一起就会让整个流程变得很难复现。做这类长时序栅格数据处理,最大的心得就是:别急着跑大计算,先花半小时把数据结构、时间轴、值域、缺测规则全摸透,后面可能给你省下几天的返工时间。