
1. CHIRPS 这套降水数据凭什么单独拿出来讲第一次接触 CHIRPS 的人多半是被它的名字劝退的——Climate Hazards Group InfraRed Precipitation with Station data一长串英文拼下来看起来像是某个课题组内部自用的数据产品。但真正在做降雨时序分析的人绕一圈最后往往还是会回到它身上。原因很简单它把卫星红外估算和地面站点观测做了融合校正既解决了纯站点数据在山区、边境、荒漠这些地方稀疏到没法插值的问题又比纯卫星产品在量级上更贴地。GEE 平台把这份数据挂成了公共资产意味着你不需要下载几十 GB 的 NetCDF、不需要自己搭 FTP 定时任务、不需要写裁剪拼接脚本几行代码就能拉出一条二十多年的降水序列。这篇内容想解决的问题很具体在 GEE 里把 CHIRPS 用对、用好。不是简单地贴两行ee.ImageCollection(UCSB-CHG/CHIRPS/DAILY)就完事而是从数据集本身的边界条件讲起说清楚它适合干什么、不适合干什么然后一路走到日数据聚合、区域统计、趋势分析、导出成表最后把我在实际项目里踩过的几个坑摊开讲。适合的读者大概是这几类做过一点 GEE 基础操作但没系统用过降水产品的、要从降雨角度做区域气候特征描述的、需要批量统计多行政区多年降水指标的。纯新手也能跟下来因为关键的 API 我都会解释它在干什么而不是只丢代码。1.1 0.05°、1981 年至今、50°S–50°N 这三个数字意味着什么打开 CHIRPS 的元数据最该先记住的是三组数字它们直接决定了你的分析能不能做、结论能下到什么程度。第一个是空间分辨率 0.05°换算成距离大概在赤道附近 5.5 公里左右。这个尺度意味着什么意味着它能刻画一个县域内部的降水梯度但刻画不了一条山谷两侧的差异。如果你要做的是小流域坡面上的降水分布5 公里格网明显不够用得考虑降尺度或者换更高分辨率的产品但如果你做的是省域、流域尺度、行政区的年降水统计这个分辨率是相当舒服的——既不至于粗到把地形差异抹平又不至于细到让单像元噪声主导结果。第二个是时间起点 1981 年。这是它能做长期趋势分析的基础。三十年以上的序列做线性趋势、做年代际对比、算气候态样本量是够的。但要注意早期年份的站点输入密度和后期差别很大尤其是在站点稀少的区域八十年代数据的可靠性会打折扣。我一般建议做趋势分析时把起始年份往后挪到 1991 年甚至 2001 年代价是序列变短收益是前后一致性更好。第三个是覆盖范围 50°S 到 50°N。这条经常被忽略然后以极其隐蔽的方式坑人。如果你的研究区有一角伸到了这个纬度带之外——比如北方某个高纬区域——那一角的返回值会是空的而不是报错。空值混进平均值里结果会被悄悄拉高或拉低你还以为是自己算法写错了。1.2 和另外几套常见降水产品摆在一起比GEE 的资产目录里能拿来做降水的产品不止一套选型的时候把它们放在一张表里看会清晰很多。产品空间分辨率时间尺度时间跨度典型适用场景CHIRPS0.05°日 / 五日 / 月1981 年至今中长期区域降水序列、干旱指数、趋势分析再分析类产品0.25°–0.5°小时 / 日数十年需要气温、辐射、风速等多要素联合分析卫星反演类产品0.1°小时 / 日近二十余年短时强降水、暴雨事件识别站点插值类产品0.5°–1°月数十年至百年超大尺度气候态比对这张表里最能说明问题的是第一行和最后一行的对比。站点插值类产品的优势是时间跨度长、量级准劣势是空间分辨率粗在站点稀疏区基本靠外推山区误差感人。CHIRPS 的价值就在于它卡在中间分辨率够细时间够长而且因为它本身就吸收了站点信息做校正在量级上比我见过的一些纯卫星产品靠谱得多。选型的判断逻辑我总结成一句话如果你关心的是某个区域某段时间下了多少雨选 CHIRPS 大概率不会错如果你关心的是这场暴雨几点开始几点结束那它帮不了你日尺度已经是它的最细粒度五日合成版本更是只适合做旬月尺度的分析。1.3 什么时候不该用它说完优点得说边界不然用起来容易出问题。第一种不该用的情况是研究区纬度超出覆盖带。前面提过超出去的部分返回空值而且不报错。判断方法很简单加载之后先看一眼image.geometry()的边界或者干脆算一次全域的reduceRegion看返回是不是 null。第二种是需要小时级精度的事件分析。CHIRPS 的日值本质上是当日累计估算它不告诉你降雨的时段分布。做城市内涝、山洪预警这类工作日尺度完全不够。第三种是对绝对量级要求极高的水量平衡核算。CHIRPS 是融合产品在站点密集区表现很好但在站点稀疏的高海拔区域它的偏差可能达到百分之二三十。做水库入库径流估算这种对绝对量敏感的工作最好拿实测站数据做一次偏差校正再用别直接拿原始值算。提示任何融合类降水产品在山区都存在系统性偏差通常是低估强降水、高估小雨日数。做趋势分析时这种偏差相对稳定影响不大做绝对量统计时就必须正视它。2. 在 GEE 资产目录里把 CHIRPS 找对、筛对、读对数据找错入口后面全白搭。GEE 上 CHIRPS 相关资产不止一个命名规则乍看相似实际含义差很远。这一段把加载环节拆开讲包括资产 ID 的辨认、元数据里必看的字段、过滤条件的写法以及那个让无数人翻车的单位问题。2.1 资产 ID 与不同时间聚合版本的差别GEE 数据目录里能搜到的 CHIRPS 资产主要分两类日尺度和五日合成尺度。日尺度资产的每张影像代表一天的降水波段名为precipitation五日合成资产每张影像覆盖连续五天值也是这五天的累计。这两个版本绝不只是粒度不同这么简单它们在聚合逻辑上有本质区别。日尺度做月累计你需要自己把当月每一天加起来五日合成做月累计你得处理月末那几天凑不满一个五日块的边界问题。我个人的偏好是一律用日尺度因为聚合的完全控制权在自己手里不会因为产品内置的分块方式产生人为断层。判断自己加载的是哪个版本最简单的办法是看集合里影像的数量级。跑一句var chg ee.ImageCollection(UCSB-CHG/CHIRPS/DAILY); print(影像总数, chg.size());日尺度从 1981 年至今应该在万级数量五日合成大概是它的五分之一。数量对不上说明你加载的不是你以为的那个版本。顺便说一句资产 ID 的大小写和路径层级是敏感的别凭记忆手敲。最稳妥的方式是在 GEE 代码编辑器的搜索框里输CHIRPS从结果列表里点进去让它自动生成加载语句。2.2 元数据里那几个必须看的字段加载完别急着往下写先print一下集合和第一张影像的元数据。我通常固定看这几项system:time_start确认时间戳解析正确这是后面所有时间聚合的根基。precipitation波段的存在性有些版本可能有多个波段select()时写错名字会静默返回空。image.geometry()确认覆盖范围顺手确认你的研究区是不是在覆盖带内。波段属性里的单位和缩放说明这是下一小节的重点。一个具体的检查动作是这样的var first ee.ImageCollection(UCSB-CHG/CHIRPS/DAILY) .filterDate(2020-01-01, 2020-01-05) .first(); print(波段, first.bandNames()); print(时间, ee.Date(first.get(system:time_start))); print(几何, first.geometry().bounds());这三行输出看起来平淡无奇但它能一次性排除掉三类最常见的低级错误波段名拼错、时间过滤条件写错格式、研究区超出覆盖范围。2.3 时间过滤与空间过滤的推荐写法过滤顺序影响性能虽然 GEE 的惰性求值会做一定优化但把空间过滤放在时间过滤之前仍然是更好的习惯——先缩小影像集合后面的计算量级直接降下来。时间过滤的坑在于日期格式。filterDate接受的是字符串格式必须是YYYY-MM-DD写成2020/01/01或者20200101都不会报错而是返回空集合。空集合往下走sum()得到的是 null最终结果全空你排查半天代码逻辑问题其实出在一个斜杠上。空间过滤有两种思路。一种是filterBounds(roi)用几何对象过滤影像另一种是直接用clip(roi)裁剪。前者的作用是减少参与计算的影像数量后者是把结果限制在区域内。两个都该用顺序是先 filterBounds 再 clip。只写 clip 不写 filterBounds计算资源会浪费在全域影像上只写 filterBounds 不写 clip统计时会把你区域外的像元也算进去。var roi ee.Geometry.Rectangle([100, 25, 110, 35]); var monthly ee.ImageCollection(UCSB-CHG/CHIRPS/DAILY) .filterBounds(roi) .filterDate(2001-01-01, 2021-01-01) .select(precipitation);2.4 单位这件事错了就是一个数量级这是我在实际项目里见过最多的错误没有之一。CHIRPS 日尺度产品给的值是日降水强度单位是毫米每天。也就是说一张影像上那个数字的含义是这天的降水折合下来平均每天多少毫米。做日分析时直接用它没问题。但一旦你要做月累计就必须乘以当月天数而不是简单地把三十张日影像加起来。很多人的写法是// 错误示范直接求和得到的是日强度之和不是月降水总量 var wrongMonthly dailyColl.sum();为什么错因为日强度相加物理意义上是把每天的平均强度叠加了三十次量纲对不上。正确的做法是给每一天乘上一个系数。如果当月是 30 天系数就是 30如果是 31 天就是 31。CHIRPS 的日值本质上是当日总量除以 1 天所以把日值乘以 1 天的天数得到当日总量再累加得到月总量——但因为它本身就是 mm/day 且每日一张所以更简单的理解是每日影像的值就是当日降水量单位 mm直接 sum 就得到月总量。这里容易绕晕我把话说透对日尺度 CHIRPS一张影像等于一天值是当天降水毫米数sum()三十张就是当月毫米数直接加是对的。真正的陷阱在五日合成版本——那上面一张影像代表五天若你把它当成日值去乘天数结果直接放大五倍反之若你把它当月度累计去做月度求和又会小一个数量级。所以第二条纪律是先确认资产的时间尺度再确认单位最后才写聚合代码。我一般会在脚本开头用注释把这三件事写死避免过段时间自己都忘了加载的是哪个版本。提示拿一个已知月降水量的站点数据做交叉验证是判断单位是否搞错最快的办法。算出来的值如果是实测的三十倍或五倍基本可以断定是尺度换算问题。3. 日数据到月、年、季节序列的聚合路径数据加载对了接下来是把它变成能用的形态。降水分析里九成的工作都发生在这一步把日数据揉成月、揉成季、揉成年再在区域上做统计。这中间有几个选择点的差异比想象中大下面逐个拆。3.1 按月求和的两种写法与结果差异第一种写法是按固定时间区间循环生成。用ee.List.sequence生成月份索引每个月构造一次起止日期过滤、求和、设置时间戳最后拼成新集合。这种写法逻辑直白可控性强缺点是代码长且容易在跨年边界上出错。var months ee.List.sequence(0, 239); // 2001-01 起 240 个月 var monthlyColl ee.ImageCollection(months.map(function(n){ var start ee.Date(2001-01-01).advance(n, month); var end start.advance(1, month); var s dailyColl.filterDate(start, end); return s.sum() .set(system:time_start, start.millis()) .set(month, start.get(month)) .set(year, start.get(year)); }));第二种写法是用calendarRange或按年月分组。思路是先给每张日影像打上年月标签再用ee.Join或者分组合并。这种写法代码更紧凑但对分组边界的处理需要额外小心尤其是在月末天数不一致、闰年二月这些情况下稍有疏忽就会漏掉几天。我实际用下来更倾向第一种。原因不在美观而在于可追溯性每个月独立计算你可以随时把某个月的起止日期打印出来核对出问题时能精确定位到是哪个月的过滤条件写歪了。分组写法一旦出现缺月排查成本高得多。不管哪种写法有一条必须坚持每张月合成影像都带上system:time_start。后续做时间序列图、做趋势分析全都依赖这个属性。缺了它集合就退化成无序的影像堆时间维度的信息彻底丢失。3.2 气候态与距平的构建顺序算距平是降水分析的常规动作但顺序搞反会得到完全错误的结果。正确的链路是先算逐年或逐月序列 → 再算多年均值 → 最后做减法。看起来天经地义实际操作里的坑在于多年均值到底是在哪个维度上算的。如果你的目标是每年降水相对常年偏多偏少多少那均值应该在年尺度上算得到的是一个影像代表多年平均年降水然后拿每年的年降水影像减它。如果你的目标是每年每个月的降水相对该月常年偏多偏少多少那均值必须在月尺度上按月份分组算——一月有一个一月的气候态七月有一个七月的气候态把十二个月混在一起求平均是没有物理意义的。后一种做法的实现方式是把月度集合按month属性过滤出十二个子集分别求均值再合并var clim ee.ImageCollection(ee.List.sequence(1, 12).map(function(m){ return monthlyColl.filter(ee.Filter.eq(month, m)).mean() .set(month, m); }));得到十二张气候态影像后再和月度序列做匹配减法。GEE 里没有直接的按属性对齐相减算子常见做法是把月度集合 map 一遍每次取出对应月份的均值影像var anomaly monthlyColl.map(function(img){ var m img.get(month); var c clim.filter(ee.Filter.eq(month, m)).first(); return img.subtract(c) .set(system:time_start, img.get(system:time_start)) .set(month, m); });这段代码跑起来没问题但要注意filter().first()在集合里频繁调用会拖慢速度。集合规模大的时候改成先把气候态合成一张多波段影像再select对应波段效率会明显好一些。3.3 区域统计reduceRegion 还是 reduceRegions这两个 API 名字只差一个字母用途完全不同混用会导致结果形态完全不对。reduceRegion作用在单个几何对象上返回一个字典。适合我要算这一个流域的多年平均降水这种单点查询。参数里geometry、reducer、scale、maxPixels是必调的三个半maxPixels不设大一点区域一大就报超限。reduceRegions作用在要素集合上返回一个带属性的要素集合。适合我要一次算出三十个县的年降水这种批量场景。它的效率优势非常明显因为 GEE 会并行处理各个要素。var stats monthlyColl.map(function(img){ return img.reduceRegions({ collection: counties, reducer: ee.Reducer.mean(), scale: 5566, tileScale: 4 }).map(function(f){ return f.set(date, img.get(system:time_start)); }); });这里有两个细节值得说。scale我填的是 5566 米这是 0.05° 在赤道附近对应的实际距离用它可以让重采样尽量贴近原始格网避免因为尺度设得太大而丢失空间信息。tileScale是为了缓解内存压力值越大切片越细代价是速度变慢。区域边界复杂、要素数量多的时候把这个值调到 4 或 8 往往能让原本跑挂的任务跑通。3.4 导出表格时 scale 与 crs 的取舍统计做完要落地成表Export.table.toDrive是标配。这里最该想清楚的是scale到底该填多少。填得太小比如 1000 米计算量暴涨导出任务容易超时失败填得太大比如 30000 米结果会被过度平滑区域内部的降水梯度信息基本丢光。我的经验值是原始分辨率的 1 到 2 倍之间也就是 5000 到 11000 米这个区间具体取值看区域大小和边界复杂度。还有一个容易被忽略的点是crs。不指定的话默认用 EPSG:4326这对降水统计来说通常够用。但如果你的边界数据本身是投影坐标系比如某些本地平面坐标不显式指定会导致重采样时出现细微的错位在面积很小的行政区上可能表现为数值异常。稳妥的做法是统一显式写上crs: EPSG:4326让所有环节的坐标系保持一致。提示导出前先在代码编辑器里print前几条结果看一眼数值范围。年降水量级应该在几百到两三千毫米之间极端干旱区和热带雨林区除外。如果打印出来是个位数或者五位数八成是单位或尺度出了问题别急着导。4. 空间分布、趋势与干湿分级的分析链路前面都是准备工作真正出结论在这一段。降水的空间分析无非三类问题哪里多哪里少、在变多还是变少、哪些年份属于异常。三类问题对应三种截然不同的计算方法混着做很容易得出站不住脚的结论。4.1 年降水趋势线性回归与 Sen 斜率趋势分析最直观的方法是逐像元做一元线性回归把年份当自变量、年降水量当因变量取斜率作为趋势强度。GEE 里有现成的 reducer 可以直接用。思路是把年降水集合转成每张影像多带一个年份波段的形式然后用linearFitvar yearly annualColl.map(function(img){ var y ee.Date(img.get(system:time_start)).get(year); return img.addBands(ee.Image.constant(y).rename(year).toFloat()); }); var trend yearly.select([year, precipitation]) .reduce(ee.Reducer.linearFit()); // 输出两个波段scale斜率单位 mm/年、offset截距linearFit输出的scale波段就是斜率单位是毫米每年。正数表示降水在增加负数表示在减少。这个方法的优点是计算快、结果直观缺点是对异常值极其敏感。序列里只要有一两年是极端丰水年或极端枯水年斜率就可能被带偏。所以我通常会再算一遍Sen 斜率。它是所有点对斜率的中位数抗异常值能力强得多。GEE 提供了ee.Reducer.sensSlope()用法和 linearFit 类似var sen yearly.select(precipitation).reduce(ee.Reducer.sensSlope()); // 同样输出 slope 和 offset两个结果对比着看很有价值。如果线性回归斜率很显著但 Sen 斜率接近零说明趋势主要由少数几年贡献稳健性存疑如果两者方向和量级接近结论就比较扎实。我在写分析报告时一般会把这个对比作为一段单独的说明因为审阅的人往往会问你的趋势是不是被某一年带出来的。4.2 显著性判断GEE 内算 p 值还是导出到本地斜率算出来不等于趋势成立。年降水量本身波动就大三十年序列算出个 2 mm/年的斜率很可能纯粹是噪声。GEE 里做显著性检验有两条路。第一条是在线算——用linearRegression拿到残差再手动构造 t 统计量var linReg yearly.select([year, precipitation]) .reduce(ee.Reducer.linearRegression(1, 1)); // 需要自己取 residuals、算标准误、算 t 值、再转 p 值这条路能走通但代码冗长而且 GEE 没有内置的 t 分布累积函数得自己用不完全贝塔函数近似实现起来容易出错。第二条路是把逐像元的年降水序列导出在本地做检验。具体做法是把研究区降采样成若干个样本点每个点导出三十年的年降水值形成一张点 × 年的宽表然后在本地用统计工具算 Mann-Kendall 检验或者 Sen 斜率的置信区间。这条路工作量大一些但结果的可信度和可解释性都更好而且能顺带做序列的其他诊断比如突变点检测。我的选择取决于项目性质快速摸底用第一条路正式成果用第二条路。给内部看的初判图斜率加上一个粗略的显著性掩膜就够了要写进正式报告、要被别人拿去引用的结果老老实实做完整的检验。还有一点必须提醒逐像元做显著性检验会带来多重比较问题。几万个像元各做一次检验即使全都没有真实趋势按 5% 的显著性水平也会有一千多个像元显著。这不是数据的问题是统计方法的问题。处理办法要么是做多重检验校正要么是在解读时明确说明这一点别把零星显著的像元当成整体趋势的证据。4.3 距平百分比与干湿年识别绝对距平毫米和相对距平百分比传达的信息不一样两个都该算。绝对距平告诉你多了多少毫米适合做水量平衡相关的讨论相对距平告诉你多了几成适合做干湿等级划分。计算方法很直接var pctAnomaly annualColl.map(function(img){ return img.subtract(climMean) .divide(climMean) .multiply(100) .set(system:time_start, img.get(system:time_start)); });得到百分比距平后按阈值划等级。常见的划分方式是距平在 -10% 到 10% 之间算正常-25% 到 -10% 算偏干低于 -25% 算干旱对称的另一侧算偏湿和湿润。这套阈值不是金标准不同气候区差异很大干旱区对 10% 的变化可能毫无感觉湿润区 10% 已经是明显异常。所以我一般会先画一条区域平均的距平时间曲线看看实际波动幅度再定阈值而不是套用一个固定标准。识别干湿年还有一个更严谨的做法是算标准化降水指数SPI。它需要对降水序列拟合一个概率分布通常是 Gamma 分布再转成标准正态分位数。GEE 里做这个比较别扭因为分布拟合需要逐像元迭代计算量大。更实际的做法是把月度序列导出到本地用成熟的时间序列库算 SPI再导回 GEE 或者直接在本地出图。4.4 出图时的配色、图例与边界叠加图好看不好看某种程度上决定了结论能不能被接受这不是虚的。降水分布图的配色有一个基本原则用单调渐变色别用彩虹色。彩虹色在视觉上会制造出不存在的边界红色和黄色之间的过渡区看起来像是有一个突变但实际上数值是连续变化的。我一般用浅黄到深蓝的渐变符合直觉——蓝色代表水越深雨越多。距平图的配色规则不一样必须以零为中心做双向渐变。负值往红或褐走正值往蓝或绿走中间白色在零附近。这样一眼就能看出哪里偏干哪里偏湿。如果用单向渐变正值和负值的视觉区分度会完全丧失。图例的处理也有讲究。自动生成的图例往往刻度不合理最好手动指定min、max和palette的断点。降水的max建议设成研究区实际最大值的 1.2 倍左右太低会把大片区域压成同一个颜色太高又会显得整体偏淡。最后是边界叠加。用ee.Image.paint或者把区域边界作为FeatureCollection加进去线宽别超过 2 像素颜色用深灰或黑色透明度调一点。边界太粗太黑会盖住下面的降水信息看起来像是只有边界没有数据。5. 几个真实踩过的坑和排查链路这一段是整篇最想写的部分。前面讲的都是应该怎么做这里讲做的时候会发生什么。坑之所以是坑是因为它们大多不报错只是安静地给你一个错误的答案。5.1 统计结果出现几万毫米的负值第一次遇到这个问题是在算某个区域的年均降水导出的表格里有一行显示 -9999。我当时以为是累加逻辑写错了查了半天代码最后发现是数据本身带的标记值。CHIRPS 原始产品里用 -9999 表示缺测虽然 GEE 上的资产在多数情况下已经做过处理但在某些版本、某些边缘时段这个值仍然会漏出来。它一旦进入mean()或者sum()会把整片区域的结果拉成负数而且因为它绝对值极大会彻底主导统计结果。排查链路是这样的先打印区域内的最小值。跑一句img.reduceRegion({reducer: ee.Reducer.min(), ...})如果看到 -9999 或者接近这个量级的负数基本就锁定了。修复方式是在统计前加一道掩膜var clean img.updateMask(img.gt(-100));阈值取 -100 是个经验值因为真实降水不会是负数任何大于 -100 的负值都极其可疑统一掩掉最省事。掩膜之后再算统计结果就正常了。这个坑的隐蔽之处在于它不会让脚本报错只会让结果错。如果没有做合理性检查比如年降水量应该在 0 到 5000 毫米之间很可能就这么错着用下去了。5.2 边界跨出覆盖范围后的空结果第二次踩坑是在处理一个北部区域的边界。脚本跑完没报错但导出的属性表里降水字段全是 null。排查的第一步是确认时间过滤对不对——打印集合的size()发现有正常数量的影像说明时间没问题。第二步是打印roi.intersects(first.geometry())返回 false。到这里原因就清楚了研究区完全落在了 50°N 以北压根不在 CHIRPS 的覆盖带内。如果是部分重叠问题会更隐蔽——重叠那部分有值不重叠那部分返回 null最后reduceRegion算出来的均值只反映重叠区域而你以为是整个区域的平均。这种情况我的处理方式是先算一次覆盖面积占比占比低于某个阈值比如 90%就直接放弃这个数据集改用其他产品因为残缺的统计结果比没有结果更危险。5.3 导出任务跑到一半失败导出失败最常见的原因是maxPixels超限或者内存溢出。任务面板里会显示错误信息通常包含 User memory limit exceeded 或者 Computed value too large。前者说明单个计算任务的数据量太大解决办法是提高tileScale或者把时间范围拆成几段分别导出再合并。后者通常是输出分辨率设得太细——比如把三十年的日数据按 30 米分辨率导出那确实撑不住。还有一个不那么直观的原因导出的几何对象太复杂。行政区边界如果有几万个顶点reduceRegions在逐个要素处理时会非常吃力。解决办法是先做一次简化用geometry.simplify(100)把顶点数降到合理范围。精度损失在降水统计这个场景里完全可以接受因为一个像元就是五公里多边界简化个一百米根本不影响结果。5.4 不同 scale 下同一区域结果对不上这个坑最让人困惑因为两次计算用的是同一份数据、同一个区域、同一段时间唯一的差别是scale参数结果却差了百分之十几。原因在于重采样的方式。GEE 在做区域统计时会按你指定的scale把像元重新聚合。scale设得比原始分辨率大时一个统计单元里会包含多个原始像元取的是面积加权平均设得比原始分辨率小时会做插值重采样可能产生原始数据里不存在的值。两种情况下得到的结果本来就不该完全一致。我的处理原则是同一个项目里所有统计的scale必须统一并且尽量贴近原始分辨率。如果需要和其他来源的数据对比先确认对方的尺度再决定是否要重采样对齐。另外如果同一区域两次计算结果差异超过 5%值得检查一下是不是某次漏了clip或者filterBounds把区域外的像元算进来了。6. 把一次性脚本沉淀成可复用工具写到这里单次分析的链路已经完整了。但实际项目里很少只分析一个区域一段时间更多情况是几十个行政区、好几个时段、反复调整参数。这时候把逻辑封装起来收益非常明显。6.1 参数化降水统计函数的结构一个好的封装应该把变化的部分抽成参数把不变的部分固定下来。降水统计里变化的部分无非是区域、时间范围、聚合粒度月/年/季、统计量均值/总和/最大。function precipStats(roi, startDate, endDate, granularity, reducer){ var daily ee.ImageCollection(UCSB-CHG/CHIRPS/DAILY) .filterBounds(roi) .filterDate(startDate, endDate) .select(precipitation) .map(function(img){ return img.updateMask(img.gt(-100)).clip(roi); }); var step granularity month ? 1 : 12; var count granularity month ? ee.Date(endDate).difference(ee.Date(startDate), month) : ee.Date(endDate).difference(ee.Date(startDate), year); // 按步长聚合返回带 time_start 的集合 // ... }封装的时候有个细节值得坚持函数内部一定要保留掩膜和裁剪。这两步是数据清洗的底线不该交给调用方去记得。我见过太多因为复用时漏了updateMask而导致结果异常的案例。6.2 多区域批处理与任务队列管理区域多起来之后导出任务的排队管理就成了问题。GEE 对同时运行的任务数量有上限一次性提交几十个任务后面的一直在排队稍微出点错就全乱。我的做法是分批次提交每批十个左右等前一批跑完再提交下一批。代码上可以用一个简单的循环加延迟或者干脆手动分几次运行。听上去原始但实际比写复杂的队列管理逻辑更可靠。另一个技巧是先把结果合并成一个大的 FeatureCollection 再一次性导出而不是每个区域导一个文件。这样任务数量少后续在本地合并表格的工作量也小。代价是单个任务的数据量大需要用tileScale来压住内存。6.3 结果自检抽样比对与合理性校验最后说自检这是最容易被跳过、也最不该跳过的一步。我固定会做的三项检查一是量级检查年降水总量的空间均值是否落在合理区间二是极值检查最大值和最小值是否出现明显不可能的数值比如负值或者超过五千毫米的日值三是抽样比对挑三五个区域把 GEE 算出来的结果和公开的统计资料或者实测站数据对一下偏差在可接受范围内才敢往下走。第三项里抽样点的选择有讲究。不要只挑数据质量最好的平原站那等于自欺欺人。刻意挑一两个山区站、一两个边界附近的站这些才是问题最容易暴露的地方。我在一次项目里就是因为只比对了平原站结果交付后才发现山区区域的系统性低估返工重做了整条链路。提示把自检的检查项写成固定的脚本片段存下来每次新项目直接复用。检查这件事靠自觉往往靠不住靠工具才靠得住。最后分享一个我自己的习惯每次做完一个区域的完整分析我会把整条链路加载 → 掩膜 → 聚合 → 统计 → 导出保存成一个模板脚本参数全部提到开头下次换区域只改开头那几行。这么做的直接好处是下次遇到问题的时候你能确定问题不出在链路本身而是出在参数或者数据上排查范围一下子就缩小了。踩过几次因为复用旧脚本却忘了改时间范围导致的错误之后我对这套模板的依赖越来越重也确实省下了大量重复劳动。