☰
Python气象站点转逐日GeoTIFF:质控、IDW插值与作物模型对接
2026/11/5 5:53:17 网站建设 项目流程

简介:BestiaPop 是面向作物模型使用者与农业气象数据处理者的 Python 工具包,重点解决站点或网格化气候数据自动下载、处理及转换为模型输入格式的衔接问题,尤其适合需要将 SILO、NASA POWER 等源数据接入 APSIM、DSSAT 的读者。压缩包共 46 个文件、约 8.2MB,以 15 个 py 源码、4 个 ipynb 示例笔记、rst 文档及图片、样式、示例 CSV 与已生成气象文件为主,目录涵盖 connectors、producers、sample-data、docs 等模块,便于理解数据抽取、产品生成与 MET、WTH 文件生成流程。已有 585 人学习,具备一定 Python 基础者可据此减少手工整理气候数据的工作。包内 Jupyter Notebook 与示例数据可用于跑通气候数据获取、栅格化处理及作物模型输入准备,帮助复现从原始数据到模型可用文件的完整链路。

1. 站点 CSV 到逐日栅格:bestiapop 卡住的那段重复劳动

在 Jupyter Notebook 里搭区域尺度的作物模拟流程,最耗时间的往往不是模型调参,而是准备驱动数据:几百个气象站的逐日观测躺在一张 CSV 里,模型要的却是覆盖整片区域的逐日 GeoTIFF。这段转换写起来不难,难在它重复——一年 365 天,每天都要走一遍质控、插值、补空、写文件,任何一个站点缺测都可能让当天的栅格缺一块。

bestiapop 就是冲这件事来的 Python 包:读站点表、做站网质控、把逐日要素插值到 DEM 网格、直接吐出作物模型能吃的栅格栈。它的定位是帮你处理“已有的站点观测 → 模型能用的格点数据”这段转换,并不负责下载气象数据。适合已经在做区域模拟、被逐日栅格化反复折磨的人,也适合刚接手一份站点表、还在纠结先质控还是先插值的新手。

2. 环境与输入:在 Jupyter 里把三类文件喂进管道

2.1 依赖顺序比版本号更要命

这套包的底层是栅格读写 + 表格处理 + 数值加速三件套,rasterio负责 GeoTIFF 的读写和坐标变换,pandas管站点表,numba把逐格点插值的热循环压到可接受的时间。真正容易出问题的环节是安装顺序:先pip install一堆东西、最后才发现rasterio导入报 GDAL 相关的错,这个顺序基本一定出错。

我一般先在独立环境里把带地理能力的底层装好,再装包本身:

# 单独建环境,别往 base 里塞 GDAL,后面很难擦干净 conda create -n bestiapop python=3.10 -y conda activate bestiapop # 先底层:gdal / rasterio / geopandas 走 conda-forge,二进制是编好的 conda install -c conda-forge gdal rasterio geopandas -y # 再装业务包,让它复用环境里已有的底层 pip install bestiapop

参数和环境里的关键点:Python 版本我锁在 3.10,因为numba对新版本 Python 的支持总慢半拍,装到 3.12 上经常出现“装得上、编译不过”的情况;conda-forge这个 channel 必须显式带上,默认 channel 的 GDAL 和rasterio版本经常对不上。装完别急着跑业务代码,先在 Notebook 里做一次导入自检:

import rasterio, geopandas, numpy, pandas, numba print("rasterio", rasterio.__version__) print("gdal", rasterio.__gdal_version__) import bestiapop as bp # 不同版本的入口函数名改过好几次,先把公开名字列出来,别照抄别人的代码 print([n for n in dir(bp) if not n.startswith("_")])

dir()这一步看起来笨,实际能省掉一半的“函数不存在”报错。我见过太多人直接复制一份旧 Notebook,卡在AttributeError上半天,其实只是入口函数从一个模块挪到了另一个模块。

提示:如果你所在的环境不允许联网装包,先用conda list确认rasterio和gdal是不是同一批装进来的,混装是坐标变换出错的高发原因。

2.2 三类输入文件:字段缺一个都跑不通

管道要的东西不多,但每一类的字段规范必须对死。第一类是站点逐日观测表,长表结构,一行一个“站—日”,必备列是站点标识、经度、纬度、海拔、日期、要素值。要素值可以多列并行(降水、最高温、最低温),但日期列一定要解析成 datetime,留着字符串格式的日期,后面按天分组时会按字典序排,2019-1-10 会排在 2019-1-2 前面。

第二类是 DEM 栅格,它同时干两件事:定义输出网格的分辨率和范围,以及提供高程订正用的格点海拔。第三类是时间范围配置,一般就是一个起始日加结束日。这三样凑齐,管道才知道“插到哪、插多细、插多少天”。

我在正式跑之前一定会先做一次输入体检,代码不长,但能把大半的返工挡在前面:

from pathlib import Path import pandas as pd import rasterio import numpy as np work = Path("/data/climate_run") obs = pd.read_csv(work / "stations_daily.csv", parse_dates=["date"]) # 1) 站点表体检:重复、缺测率、坐标范围 print("行数", len(obs), "站点数", obs["sid"].nunique()) print("日期范围", obs["date"].min(), obs["date"].max()) print("坐标范围", obs["lon"].min(), obs["lon"].max(), obs["lat"].min(), obs["lat"].max()) print("重复键", obs.duplicated(["sid", "date"]).sum()) # 2) DEM 体检:分辨率、坐标系、是否含 nodata with rasterio.open(work / "dem.tif") as src: print("分辨率", src.res, "CRS", src.crs, "nodata", src.nodata) dem = src.read(1).astype("float32") print("高程范围", np.nanmin(dem[dem > -1000]), np.nanmax(dem))

这段的输出要重点看两个数:obs.duplicated(["sid","date"]).sum()必须是 0,不为 0 说明有站点在同一天重复上报,插值时会把这个站点的权重放大一倍;src.crs如果是投影坐标系(单位是米),而站点表里是经纬度,那就得先重投影,不能直接算距离。后面第 5 章会把这两个坑单独拎出来讲。

注意:DEM 里的 nodata 值(常见是 -9999 或 -32768)一定要在参与高程订正前剔除,否则山区边缘会插出一片荒谬的低温。

2.3 最小可跑:先只栅格化一天

真正能节省时间的习惯,是不碰全时段,先挑一天跑通。挑一天有讲究:别挑风和日丽的晴天,挑一个降水过程明显、站点缺测又不太多的日子,这样插值结果的形态对不对,你一眼就能看出来。

一天的最小流程就四步:取当天有效站点、读数、插值、写文件。下面这段把插值内核摊开写了,bestiapop内部做的也是这件事(只是加了质控和 numba 加速),摊开写的好处是参数怎么影响结果你能直接看到:

import numpy as np def idw_2d(lon_s, lat_s, val_s, lon_g, lat_g, power=2.0, radius_km=150.0, min_n=3): """ lon_s / lat_s / val_s : 站点经度、纬度、要素值(一维,已剔除缺测) lon_g / lat_g : 网格经纬度(二维) power : 距离衰减幂次,2.0 是气象插值的常用起点 radius_km : 搜索半径;1 度纬度约 111 km,按站点密度调 min_n : 半径内最少站点数,不足直接留 nodata,不要让它变 NaN """ out = np.full(lon_g.shape, np.nan, dtype="float32") for i in range(lon_g.shape[0]): for j in range(lon_g.shape[1]): dy = (lat_s - lat_g[i, j]) * 111.0 dx = (lon_s - lon_g[i, j]) * 111.0 * np.cos(np.deg2rad(lat_g[i, j])) d = np.hypot(dx, dy) m = d <= radius_km if m.sum() < min_n: # 站点稀疏区:宁可留空,也别硬插 continue w = 1.0 / np.maximum(d[m], 1e-6) ** power out[i, j] = float(np.sum(w * val_s[m]) / np.sum(w)) return out

参数说明直接关系到结果质量:power调大,等值线会更贴着观测站,山区容易出“牛眼”;调小又会过度平滑,把降雨中心抹掉,2.0 是折中起点,实测偏差大再往 1.5 或 2.5 试。radius_km不是越小越精细,站点密度低的区域半径太小会整片留空,我一般先用“平均站间距 × 2.5”估一个初值。min_n是保护参数,别设成 1,单个站点撑起来的格点没有任何可信度。

拿到数组之后写 GeoTIFF,关键是 profile 必须继承 DEM 的 transform 和 crs,不要自己拼:

import rasterio def write_tif(path, arr, template_path="dem.tif", nodata=-9999.0): with rasterio.open(template_path) as src: profile = src.profile.copy() profile.update(dtype="float32", count=1, nodata=nodata, compress="deflate") a = np.where(np.isnan(arr), nodata, arr).astype("float32") with rasterio.open(path, "w", **profile) as dst: dst.write(a, 1)

profile.copy()这一步别省,直接拿src.profile改会污染原对象;compress="deflate"对逐日栅格是必开的,否则一年 365 个文件轻松吃掉几十 G。

3. 质控与插值:为什么原样丢进 IDW 会出问题

3.1 站点序列里真正麻烦的三类脏数据

第一类是孤立异常值,比如某站某天降水 480 mm,周边站都是 5 mm。这类值不处理,插值出来的格点会出现一个假雨团,作物模型在那一格的水分平衡直接崩掉。

第二类是系统性偏移,某个站的温度常年比周边高 2 ℃。这类最阴险,因为它看起来不像错误,插值结果也不会突兀,但会把整片区域的温度场往一个方向拉。常见成因是站点迁移、传感器更换、遮蔽条件改变。

第三类是成段缺测,一个站连续几个月没有数据。单日插值对缺测是容忍的(少一个站而已),真正的问题在时序上——如果你的流程里有“用前后日填补”的步骤,长段缺测会被填成一串平滑的假值,看起来毫无破绽。

在 Notebook 里做初步筛查,我一般按站点画序列、算分位数,而不是整体画直方图:

# 按站点看分布,异常站会自己跳出来 g = obs.groupby("sid")["tmax"] qc = pd.DataFrame({ "n": g.size(), "mean": g.mean(), "p99": g.quantile(0.99), "p01": g.quantile(0.01), }) qc["缺失率"] = 1 - qc["n"] / obs["date"].nunique() # 温度 p99 超过 55 或 p01 低于 -40 的站,先列出来人工看一眼 print(qc[(qc["p99"] > 55) | (qc["p01"] < -40) | (qc["缺失率"] > 0.3)])

3.2 参考站网打分:把“孤立异常”和“真天气”分开

判断一个值是不是异常,不能只看它自己,要看它和邻站的关系。业界常见的做法是给每个站构造一条“参考序列”——用周围若干站在同一日的加权平均,权重按距离取,然后看本站和参考序列的偏差。

def ref_series(day_df, sid, lon, lat, col, k=5): """用最近的 k 个邻站构造该站的当日参考值""" d = np.hypot((day_df["lon"] - lon) * 111.0, (day_df["lat"] - lat) * 111.0 * np.cos(np.deg2rad(lat))) d[day_df["sid"] == sid] = np.inf # 把自己排除掉 idx = np.argsort(d)[:k] w = 1.0 / np.maximum(d.values[idx], 1e-6) v = day_df[col].values[idx] ok = ~np.isnan(v) return float(np.sum(w[ok] * v[ok]) / np.sum(w[ok])) if ok.any() else np.nan

放到全年尺度上,对每个站算偏差序列,再看偏差的标准化分布(偏差除以该站偏差的标准差)。超过 ±4 倍的,标成可疑;如果连续多天都超,那更可能是系统性偏移而不是随机异常——随机异常是零散的,系统性偏移是成串的。这两类的处理方式完全不同:前者单点剔除即可,后者要么整站降权,要么做订正,不能一刀切删掉,删掉就等于放弃了这个站几十年的观测。

bestiapop把这个过程封装成了管道里的一步,实际跑的时候我建议先只跑质控、把可疑点导出来人工过一遍,再决定是剔除还是保留。全自动质控听起来省事,但作物模型对温度偏差比对人眼敏感得多。

3.3 插值参数:幂次、搜索半径与高程订正

插值参数没有通用最优值,但有一套可操作的调法。先定搜索半径:算出研究区站点的最近邻距离中位数,半径取它的 2~3 倍。再定幂次:拿留出站做交叉验证,power从 1.5 扫到 3.0,看 RMSE 落在哪。这个扫描不费多少时间,一天的数据几十秒就能扫完。

温度必须做高程订正,这一步跳过去,山区结果基本不能用。原因是插值出来的温度对应的是“参与插值的站点平均海拔”,而格点海拔是 DEM 给的,山区两者差几百米很常见:

def lapse_correct(t_interp, elev_grid, elev_ref, lapse=6.5): """ t_interp : 插值得到的温度(基准面 = elev_ref) elev_grid: DEM 高程(米) elev_ref : 参与插值的站点平均海拔(米) lapse : 气温直减率,单位 ℃/km,干空气常用 6.5 """ return t_interp + (elev_ref - elev_grid) / 1000.0 * lapse

符号一定要自己推一遍:格点比基准面高,温度应该更低,所以是加上(elev_ref - elev_grid)。我见过把符号写反的脚本,跑出来的结果是“山上更热”,而且因为数值范围看起来还算合理,不画图根本发现不了。

降水的订正比温度麻烦,通常用一个随高程放大的乘性因子,但因子取值区间很窄(1.0~1.3),调过头会造出远超实测的极值。我的做法是降水先不做高程订正,只做距离插值,等验证指标出来再决定要不要加。

4. 批量跑全时段:切片、缓存与断点续跑

4.1 按年切片与任务清单

一天跑通了,全时段反而容易栽跟头,最常见的是内存。十年逐日、几百个站、几百万格点,如果一口气把站点表读进来再groupby,16 G 的机器撑不到一半。

我一般按年切片,先把任务清单落成文件,再逐条消费:

import json from pathlib import Path work = Path("/data/climate_run") years = range(2010, 2020) tasks = [] for y in years: start, end = f"{y}-01-01", f"{y}-12-31" out_dir = work / "out" / str(y) out_dir.mkdir(parents=True, exist_ok=True) tasks.append({"year": y, "start": start, "end": end, "out": str(out_dir)}) (work / "tasks.json").write_text(json.dumps(tasks, ensure_ascii=False, indent=2)) print(len(tasks), "个年度任务")

把任务清单落盘的意义在于可恢复。Jupyter 内核崩一次、机器重启一次,你不需要从头再来,只需要重新读tasks.json。这个习惯我从一次跑到第七年内核挂掉之后就一直保留着。

消费任务时逐日判断输出文件是否已存在,存在就跳过,这就是最廉价的断点续跑:

for t in tasks: sub = obs[(obs["date"] >= t["start"]) & (obs["date"] <= t["end"])] for date, day in sub.groupby("date"): fp = Path(t["out"]) / f"tmax_{date:%Y%m%d}.tif" if fp.exists(): continue # 已经算过,直接跳过 day = day.dropna(subset=["tmax"]) arr = idw_2d(day["lon"].values, day["lat"].values, day["tmax"].values, LON_G, LAT_G, power=2.0, radius_km=150, min_n=3) arr = lapse_correct(arr, DEM, day["elev"].mean()) write_tif(fp, arr)

4.2 numba 首次编译与缓存命中

纯 Python 的双层循环,单日几百万格点要跑十几分钟。numba的@njit能把它压到秒级,但有两个细节必须知道。

第一,首次调用会编译,几十秒到一两分钟的“卡住”是正常的,不是死循环。第二,cache=True要加上,否则每次重启内核都得重新编译,一天几十次就是几十分钟的纯浪费:

from numba import njit import numpy as np @njit(cache=True) def idw_numba(lon_s, lat_s, val_s, lon_g, lat_g, power, radius_km, min_n): out = np.full(lon_g.shape, np.nan, dtype=np.float32) for i in range(lon_g.shape[0]): for j in range(lon_g.shape[1]): sw = 0.0; sv = 0.0; n = 0 coslat = np.cos(np.deg2rad(lat_g[i, j])) for k in range(lon_s.shape[0]): dy = (lat_s[k] - lat_g[i, j]) * 111.0 dx = (lon_s[k] - lon_g[i, j]) * 111.0 * coslat d = np.sqrt(dx * dx + dy * dy) if d <= radius_km: w = 1.0 / max(d, 1e-6) ** power sw += w; sv += w * val_s[k]; n += 1 if n >= min_n: out[i, j] = sv / sw return out

@njit里的数组必须是同类型的连续数组,传pandas的.values一般没问题,但如果你从DataFrame里切片再转,可能拿到非连续视图,numba 会直接报类型错误。保险做法是np.ascontiguousarray(x, dtype=np.float64)显式转一次。另外@njit里不能随便调pandas的方法,热循环里所有输入都得先变成纯 numpy 数组。

4.3 输出命名与作物模型对接

输出文件的命名直接影响下游怎么用。带日期的文件名(tmax_20190701.tif)比output_001.tif好用得多,因为作物模型那侧大多按日期拼路径。

再往前走一步,很多模型不认一堆散装 GeoTIFF,而是要求一个多波段栈或者一个特定格式的天气文件。我一般在栅格化完成之后加一段转换,把逐日文件拼成按要素分组的栈:

import glob import rasterio files = sorted(glob.glob("/data/climate_run/out/2019/tmax_*.tif")) with rasterio.open(files[0]) as src: profile = src.profile.copy() h, w = src.height, src.width profile.update(count=len(files), dtype="float32") with rasterio.open("/data/climate_run/out/tmax_2019_stack.tif", "w", **profile) as dst: for b, f in enumerate(files, start=1): with rasterio.open(f) as s: dst.write(s.read(1), b) dst.set_band_description(b, f[-12:-4]) # 波段描述写日期,下游好查

set_band_description这行别省。半年后回来找“第 173 个波段是哪天”,没有波段描述就只能靠文件名排序硬数。

5. 避坑与排查:五个让我返工的坑

5.1 数据与坐标侧的三个坑

坑一:栅格整片空洞,值全是 nodata。

现象是打开输出图,研究区一半是透明的。原因不是插值写错了,而是搜索半径内站点数不足,min_n保护逻辑把整片区域判成了不可插值,而这个信息在日志里没有体现。解决办法是先统计每日有效站点数和最近邻距离分布,把radius_km按区域自适应调整——西部稀疏区用 250 km、东部用 120 km,比全局一个值靠谱得多。同时在循环里加一行统计,把“当天 nodata 占比超过 30%”的日期打出来。

坑二:同一份数据跑两次,结果不一样。

现象是昨天跑出的图和今天跑出的图对不上。原因通常有两个:站点表没有稳定排序,groupby之后的顺序不稳定,导致高海拔站和低海拔站的相对权重在边界格点上抖动;或者并行任务同时往同一个路径写,后写的覆盖先写的。解决办法是分组前sort_values(["date", "sid"])加drop_duplicates(["sid", "date"]),写文件时先写.tmp再os.replace()原子替换。

坑三:温度栅格整体偏低 2~3 ℃。

现象是所有格点整片偏移,而不是局部异常。原因是高程订正用了站点海拔均值,但那个均值是按“站点个数”算的,山区的站少、平原的站多,均值被拉低,订正量就整体偏了。解决办法是先把 DEM 重采样到输出网格,用格点高程的均值作为基准面,或者干脆按每个格点最近的 k 个站的加权高程做局部基准。这个问题最坑的地方是数值看起来“还算合理”,不跟实测对比根本发现不了。

5.2 运行与输出侧的两个坑

坑四:跑着跑着内存爆掉,内核直接重启。

现象是跑到第三四年,Jupyter 弹“内核已死”。原因是每一轮循环里都新建了DataFrame切片,旧的没有释放,加上numba编译缓存和rasterio的文件句柄累积,内存只涨不降。解决办法是把年度切片改成读取时就限定列(usecols),循环体内部用del显式释放大对象并定期gc.collect(),写文件用with上下文确保句柄关闭。更彻底的做法是把逐日任务拆成独立脚本、用命令行批量调,Notebook 只负责交互调试。

坑五:输出的 TIFF 在 GIS 软件里位置偏了半个格点。

现象是栅格和底图对不齐,偏移量恰好是半个像元。原因是transform用了左上角点而不是像元中心,或者站点坐标是经纬度、DEM 是投影坐标,两套坐标直接被拿来算欧氏距离。解决办法是先统一 CRS:用pyproj把站点经纬度转到 DEM 的投影坐标下再算距离,输出时profile直接从 DEM 继承,不手写transform。判断有没有踩这个坑,最快的办法是把一个已知站点的坐标打到输出栅格上,看落点是不是偏了半格。

6. 验证与进阶:三个指标加一个三日回放

栅格跑完了不等于能用。我在交付任何一批气候栅格之前,都会做两件事:留出站验证和目检回放。

6.1 留出站验证与三个指标

做法很简单:从站网里随机抽 10% 的站不参与插值,用剩下的站生成栅格,再把栅格值在这些留出站的位置上采出来,和实测比。抽样要按高程分层,否则留出的全是平原站,山区误差完全看不出来。

import numpy as np def skill(obs, sim): m = ~np.isnan(obs) & ~np.isnan(sim) obs, sim = obs[m], sim[m] bias = float(np.mean(sim - obs)) mae = float(np.mean(np.abs(sim - obs))) rmse = float(np.sqrt(np.mean((sim - obs) ** 2))) return {"n": int(m.sum()), "bias": round(bias, 2), "mae": round(mae, 2), "rmse": round(rmse, 2)}

三个指标里我最看重bias。RMSE 大但 bias 接近 0,说明是随机误差,作物模型还能容忍;bias 系统性偏 2 ℃,模型积温会整体跑偏,出苗期、成熟期全错。温度我一般要求 |bias| < 1 ℃、RMSE < 2.5 ℃;降水对偏倚更敏感,bias 控制在观测均值的 10% 以内。

要素可接受 bias可接受 RMSE备注
最高温±1.0 ℃≤ 2.5 ℃山区放宽到 3.0 ℃
最低温±1.0 ℃≤ 2.5 ℃逆温日误差偏大属正常
降水±10% 均值按气候区单独定干旱区相对误差天然偏大

6.2 三日回放:用眼睛做最后一道检查

指标只能告诉你“平均意义上对不对”,看不出空间形态错没错。我固定挑三天做目检:一天大范围降水、一天晴空、一天有锋面过境。把这三天的栅格和当天的天气图、站点实测值放在一起看。

看什么?看降水带的走向是不是和站点分布一致,看山区是不是出现了不该有的“牛眼”,看湖泊、海岸线附近有没有出现插值伪影。有一次我的温度栅格指标全部合格,但目检发现某条河谷整条偏冷 4 ℃——原因是那条河谷里的三个站被质控误删了,指标算的是留出站,而那些站恰好不在河谷里。这个错,纯靠数字是发现不了的。

三日回放还有一个附带好处:能顺便检查输出文件本身有没有问题。波段数对不对、nodata 值有没有写进去、日期描述有没有错位,打开图一看就知道。

从那以后我每次批量生成气候栅格,都强制走一遍“留出站指标 + 三日回放”,哪怕时间紧也至少跑一天的回放。栅格数据的问题从来不会自己暴露,它只会安静地躺在那里,等你在作物模型的结果里发现异常,再回头一层层查,那时候成本已经是十倍了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询