处理气象数据常用的插值方法¶
注:
这篇文章与 气象数据的各种插值问题 内容大致相同,是它的重构版。相比旧版,修改了 IDW 插值的代码,删掉了一些与主题关联不大的段落。 这个版本的 IDW 插值函数无须安装第三方库,仅依赖基础的 Scipy 库即可使用。 实际工作时,有时不太方便因为要使用一个功能,而在已有的 Python 中环境继续安装其他扩展库,一是 conda 环境中安装的库越多, 再新增库就越慢;二是有可能会连带修改其他库的版本,导致业务中有些代码不能使用。 所以针对单一功能以提取出仅依赖基础库就能使用的版本为宜。
气象业务当中,比较常见的数据形式有 站点数据 和 网格数据 。
站点数据¶
站点数据 就是个二维数据表,一般是 CSV、TXT、或者 Excel 等格式,这里我们用一个 CSV 数据( UPA_obs.csv [1] )举例。
用 Pandas 读取:
1import pandas as pd
2
3df = pd.read_csv("UPA_obs.csv", sep=",", encoding="utf-8")
4df = df[["latitude", "longitude", "temperature"]]
5df = df.dropna(subset=["latitude", "longitude"], how="any")
6df.index = range(len(df))
7df["temperature"] = df["temperature"].astype(float)
8df = df.rename(columns={"latitude": "lat", "longitude": "lon", "temperature": "value"})
结构就像这样:
lat lon value
0 51.466667 -90.200000 -43.5
1 51.466667 -90.200000 -53.9
2 53.533333 -114.083333 -28.3
3 53.533333 -114.083333 -53.5
4 53.750000 -73.666667 -41.7
.. ... ... ...
177 34.733333 -120.583333 -42.3
178 40.900000 -117.800000 -16.8
179 40.900000 -117.800000 -44.2
180 46.466667 -84.366667 -39.1
181 46.466667 -84.366667 -48.1
[182 rows x 3 columns]
其中 lat
表示纬度, lon
表示经度, value
表示站点观测值。
把这个数据在地图上以打点的形式画出来,点的颜色对应数值大小:

可以看到,这个数据内容是北美的气温数据,每个点的颜色代表了温度的大小,温度单位是摄氏度(℃)。 我们把温度的显示范围设定在了 -60 ~ -30 ℃ 之间,以便让各个颜色区域都有值。
画图脚本如下:
1# -*- coding: utf-8 -*-
2
3import numpy as np
4import pandas as pd
5import cartopy.crs as ccrs
6import cartopy.feature as cfeature
7import matplotlib as mpl
8import matplotlib.pyplot as plt
9from cartopy.mpl.ticker import LongitudeFormatter, LatitudeFormatter
10
11mpl.rcParams['font.sans-serif'] = ['Microsoft YaHei']
12mpl.rcParams['axes.unicode_minus'] = False
13
14# 读取数据
15df = pd.read_csv("UPA_obs.csv", sep=",", encoding="utf-8")
16df = df[["latitude", "longitude", "temperature"]]
17df = df.dropna(subset=["latitude", "longitude"], how="any")
18df.index = range(len(df))
19df["temperature"] = df["temperature"].astype(float)
20df = df.rename(columns={"latitude": "lat", "longitude": "lon", "temperature": "value"})
21# 计算经纬度范围
22lon_min, lon_max, lat_min, lat_max = df.lon.min(), df.lon.max(), df.lat.min(), df.lat.max()
23lon_buffer = abs(lon_max - lon_min) * 0.1
24lat_buffer = abs(lat_max - lat_min) * 0.1
25map_extent = [lon_min - lon_buffer, lon_max + lon_buffer, lat_min - lat_buffer, lat_max + lat_buffer]
26
27# 绘图
28proj = ccrs.PlateCarree()
29fig = plt.figure(figsize=(16, 9))
30ax = fig.add_subplot(111, projection=proj)
31
32cmap = mpl.colormaps.get_cmap('jet')
33img = ax.scatter(df.lon, df.lat, s=200, c=df.value, marker='.', cmap=cmap, transform=proj, vmax=-30, vmin=-60)
34
35# 设置绘图经纬度区域
36ax.set_extent(map_extent, crs=proj) # type: ignore
37
38# 添加海岸线、国界
39ax.add_feature(cfeature.COASTLINE.with_scale('50m'), edgecolor='black', linewidth=0.5)
40ax.add_feature(cfeature.BORDERS.with_scale('50m'), linestyle='-', edgecolor='black', linewidth=0.5)
41
42# 添加经纬度坐标
43ax.set_yticks(np.arange(map_extent[2], map_extent[3], 10).round(2), crs=proj)
44ax.set_xticks(np.arange(map_extent[0], map_extent[1], 10).round(2), crs=proj)
45lon_formatter = LongitudeFormatter(degree_symbol='', dateline_direction_label=True)
46lat_formatter = LatitudeFormatter(degree_symbol='')
47ax.yaxis.set_major_formatter(lat_formatter)
48ax.xaxis.set_major_formatter(lon_formatter)
49
50# 获取画布位置大小参数
51l, b, w, h = ax.get_position().bounds
52
53# 添加色带
54cax = plt.axes([l, b - h * 0.06 - 0, w, h * 0.02]) # type: ignore
55cb = fig.colorbar(img, cax=cax, ticks=None, orientation='horizontal', extend="both")
56cb.ax.tick_params(labelsize=15)
57cb.set_label(r"$^{\circ}$C", size=20)
58
59# 标题
60title = '站点数据原始绘图'
61ax.set_title(title, fontsize=18, loc="left")
62
63# 保存图片
64fig.savefig('站点数据原始绘图.png', dpi=200, bbox_inches='tight')
65plt.cla()
66plt.close(fig)
67plt.close("all")
网格数据¶
网格数据 通常用 Xarray 库读取,这里我们使用 GFS_test.nc [2]:
1with xr.open_dataset("GFS_test.nc") as ds:
2 ds = ds.assign_coords(lon=((ds.lon + 180) % 360 - 180)) # 将经度范围从[0, 360]转换为[-180, 180]
3 dr = ds["Temperature_isobaric"]
4 dr = dr.isel(time=0) # first time step
5 dr = dr.sel(isobaric3=85000) # 850 hPa
6 dr = dr - 273.15 # K to °C
结构就像这样:
<xarray.DataArray 'Temperature_isobaric' (lat: 46, lon: 101)>
[4646 values with dtype=float32]
Coordinates:
time datetime64[ns] 2010-10-26T12:00:00
isobaric3 float32 8.5e+04
* lat (lat) float32 65.0 64.0 63.0 62.0 61.0 ... 23.0 22.0 21.0 20.0
* lon (lon) float32 -150.0 -149.0 -148.0 -147.0 ... -52.0 -51.0 -50.0
Attributes:
long_name: Temperature @ Isobaric surface
units: K
其中, lat
和 lon
是这个数据的两个维度,即纬度和经度, Coordinates
展示了这各个维度对应的坐标信息,
Attributes
是一些属性信息,是个 Dict
。可以看到这个气温数据的单位是开尔文(K),将它减去 273.15 就是摄氏度的值。
把这个数据用网格填色的方式画在地图上:

画图脚本如下:
1# -*- coding: utf-8 -*-
2
3import numpy as np
4import pandas as pd
5import xarray as xr
6import cartopy.crs as ccrs
7import cartopy.feature as cfeature
8import matplotlib as mpl
9import matplotlib.pyplot as plt
10from cartopy.mpl.ticker import LongitudeFormatter, LatitudeFormatter
11
12mpl.rcParams['font.sans-serif'] = ['Microsoft YaHei']
13mpl.rcParams['axes.unicode_minus'] = False
14
15# 读取数据
16with xr.open_dataset("GFS_test.nc") as ds:
17 ds = ds.assign_coords(lon=((ds.lon + 180) % 360 - 180)) # 将经度范围从[0, 360]转换为[-180, 180]
18 dr = ds["Temperature_isobaric"]
19 dr = dr.isel(time=0) # first time step
20 dr = dr.sel(isobaric3=85000) # 850 hPa
21 dr = dr - 273.15 # K to °C
22
23# 计算经纬度范围
24lon_min, lon_max, lat_min, lat_max = dr.lon.min(), dr.lon.max(), dr.lat.min(), dr.lat.max()
25lon_buffer = abs(lon_max - lon_min) * 0.0
26lat_buffer = abs(lat_max - lat_min) * 0.0
27map_extent = [lon_min - lon_buffer, lon_max + lon_buffer, lat_min - lat_buffer, lat_max + lat_buffer]
28
29# 绘图
30proj = ccrs.PlateCarree()
31fig = plt.figure(figsize=(16, 9))
32ax = fig.add_subplot(111, projection=proj)
33
34cmap = mpl.colormaps.get_cmap('jet')
35LON, LAT = np.meshgrid(dr.lon, dr.lat)
36img = ax.pcolormesh(LON, LAT, dr, cmap=cmap, transform=proj, alpha=1)
37
38# 设置绘图经纬度区域
39ax.set_extent(map_extent, crs=proj) # type: ignore
40
41# 添加海岸线、国界
42ax.add_feature(cfeature.COASTLINE.with_scale('50m'), edgecolor='black', linewidth=0.5)
43ax.add_feature(cfeature.BORDERS.with_scale('50m'), linestyle='-', edgecolor='black', linewidth=0.5)
44
45# 添加经纬度坐标
46ax.set_yticks(np.arange(map_extent[2], map_extent[3], 10).round(2), crs=proj)
47ax.set_xticks(np.arange(map_extent[0], map_extent[1], 10).round(2), crs=proj)
48lon_formatter = LongitudeFormatter(degree_symbol='', dateline_direction_label=True)
49lat_formatter = LatitudeFormatter(degree_symbol='')
50ax.yaxis.set_major_formatter(lat_formatter)
51ax.xaxis.set_major_formatter(lon_formatter)
52
53# 获取画布位置大小参数
54l, b, w, h = ax.get_position().bounds
55
56# 添加色带
57cax = plt.axes([l, b - h * 0.085, w, h * 0.03]) # type: ignore
58cb = fig.colorbar(img, cax=cax, ticks=None, orientation='horizontal', extend="both")
59cb.ax.tick_params(labelsize=15)
60cb.set_label(r"$^{\circ}$C", size=20)
61
62# 标题
63title = '网格数据原始绘图'
64ax.set_title(title, fontsize=18, loc="left")
65
66# 保存图片
67fig.savefig('网格数据原始绘图.png', dpi=200, bbox_inches='tight')
68plt.cla()
69plt.close(fig)
70plt.close("all")
在实际使用过程中,经常会有这样的需求:
将网格数据插值到另一个分辨率不同的网格坐标上
将网格数据插值到站点数据的坐标上
将站点数据插值到网格数据的坐标上
…
下面逐一说明。
将网格数据插值到另一个坐标、分辨率不同的网格坐标上¶
我们上面用到的 GFS_test.nc 的经纬度坐标 shape 为 (lon: 101, lat: 46)
,
lat 的数据范围是 [65, 20],lon 的数据范围是 [-150, -50], 分辨率是 1 度。
现在我们要将这个数据插值到一个分辨率是 0.1 度的网格坐标上,范围不变。
1with xr.open_dataset("GFS_test.nc") as ds:
2 ds = ds.assign_coords(lon=((ds.lon + 180) % 360 - 180)) # 将经度范围从[0, 360]转换为[-180, 180]
3 dr = ds["Temperature_isobaric"]
4 dr = dr.isel(time=0) # first time step
5 dr = dr.sel(isobaric3=85000) # 850 hPa
6 dr = dr - 273.15 # K to °C
7
8# 分辨率为 0.1 度的网格
9new_lat = np.arange(dr.lat.min(), dr.lat.max()+0.1, 0.1)
10new_lon = np.arange(dr.lon.min(), dr.lon.max()+0.1, 0.1)
11dr = dr.interp(lat=new_lat, lon=new_lon) # 插值
12dr.to_netcdf("GFS_test_850hPa_Temperature_0.1deg.nc")
插值后的数据预览如下:
<xarray.DataArray 'Temperature_isobaric' (lat: 451, lon: 1001)>
Coordinates:
time datetime64[ns] 2010-10-26T12:00:00
isobaric3 float32 8.5e+04
* lat (lat) float64 20.0 20.1 20.2 20.3 20.4 ... 64.7 64.8 64.9 65.0
* lon (lon) float64 -150.0 -149.9 -149.8 -149.7 ... -50.2 -50.1 -50.0
可以看到数据的分辨率已经变成 0.1 度了,那么我们把这个插值后的数据画在地图上:

和之前的图相比,插值后的数据明显更密集,没有了原来的网格状,而是更加平滑的分布。

将网格数据插值到站点数据上¶
我们可以用 Scipy 的插值函数方法一次性得到所有的插值结果:
1import xarray as xr
2import numpy as np
3import pandas as pd
4import scipy.interpolate as interpolate
5
6
7def grid_to_station(station_df: pd.DataFrame, grid_dr: xr.DataArray):
8 """三维格点转插值到二维站点, 快速
9
10 Args:
11 station_df(pd.DataFrame): 站点数据
12 grid_dr: 格点数据
13
14 Returns:
15 (pd.DataFrame): 插值后的数据
16
17 """
18 grid_dr_lat, grid_dr_lon = np.meshgrid(grid_dr.lat, grid_dr.lon)
19 grid_dr_points = np.transpose(np.array([grid_dr_lat.flatten(), grid_dr_lon.flatten()]))
20 grid_dr_values = np.array(grid_dr.values).transpose().reshape(-1, 1)
21 xi = (station_df.lat, station_df.lon)
22 nc_interp_values = interpolate.griddata(
23 grid_dr_points, grid_dr_values, xi, fill_value=np.nan, method='linear').squeeze()
24 nc_interp_values = pd.Series(nc_interp_values)
25 df_nc_interp = pd.DataFrame({"lat":station_df.lat, "lon":station_df.lon, "value":nc_interp_values})
26 return df_nc_interp
27
28
29df = pd.read_csv("UPA_obs.csv", sep=",", encoding="utf-8")
30df = df[["latitude", "longitude", "temperature"]]
31df = df.dropna(subset=["latitude", "longitude"], how="any")
32df.index = range(len(df))
33df["temperature"] = df["temperature"].astype(float)
34df = df.rename(columns={"latitude": "lat", "longitude": "lon", "temperature": "value"})
35
36
37
38with xr.open_dataset("GFS_test.nc") as ds:
39 ds = ds.assign_coords(lon=((ds.lon + 180) % 360 - 180)) # 将经度范围从[0, 360]转换为[-180, 180]
40 dr = ds["Temperature_isobaric"]
41 dr = dr.isel(time=0) # first time step
42 dr = dr.sel(isobaric3=85000) # 850 hPa
43 dr = dr - 273.15 # K to °C
44
45
46df_interp = grid_to_station(df, dr)
47df_interp.to_csv("grid_to_station.csv", index=False)
将插值后的数据打点画图:

可以看到,点的位置和 UPA_obs.csv
中的点位置是相同的,但数值是数据 GFS_test.nc
中的值。
将站点数据插值到网格数据上(反距离权重插值)¶
还会有一种需求,是将站点数据插值到指定精度的网格上。
原本为 np.nan
的数据要运用反距离权重插值预测出对应的值。
反距离权重插值的原理,简单讲就是被预测的值由它附近的点决定,与它距离越近的点权重越大,对它的影响就越大。
这里整理了国家气象信息中心开发的 meteva 库中提供的代码:
1import math
2import pandas as pd
3import numpy as np
4import xarray as xr
5from scipy.spatial import cKDTree
6
7
8# 地球半径
9ER = 6371.229
10
11
12def lon_lat_to_cartesian(lon, lat, R=1):
13 """
14 经度纬度信息转换为直角坐标系
15
16 calculates lon, lat coordinates of a point on a sphere with
17 radius R
18 """
19 lon_r = np.radians(lon)
20 lat_r = np.radians(lat)
21 xyz = np.zeros((len(lon), 3))
22 xyz[:, 0] = R * np.cos(lat_r) * np.cos(lon_r)
23 xyz[:, 1] = R * np.cos(lat_r) * np.sin(lon_r)
24 xyz[:, 2] = R * np.sin(lat_r)
25 return xyz
26
27
28def grid_data(lon_min, lon_len, lon_res, lat_min, lat_len, lat_res, data=None):
29 """
30 返回一个DataArray,其维度信息和grid描述一致,数组里面的值为0.
31 """
32 # 通过起始经纬度和格距计算经纬度格点数
33 lon = np.arange(lon_len) * lon_res + lon_min
34 lat = np.arange(lat_len) * lat_res + lat_min
35 if data is None:
36 data = np.zeros((lat_len, lon_len))
37 else:
38 data = data.reshape(lat_len, lon_len)
39
40 return xr.DataArray(
41 data,
42 coords={
43 'lat': lat, 'lon': lon},
44 dims=['lat', 'lon']
45 )
46
47
48def grd_info(vmin, vmax, vres):
49 vlen = 1 + (vmax - vmin) / vres
50 error = abs(round(vlen) - vlen)/vlen
51 if (error > 0.01):
52 vlen = int(math.ceil(vlen))
53 else:
54 vlen = int(round(vlen))
55 return (vmin, vlen, vres)
56
57
58def interp_sg_idw(sta, lon_info, lat_info, effectR=1000, nearNum=8, decrease=2):
59 """站点到格点IDW插值
60
61 Args:
62 sta(pd.Dataframe): 站点数据, lat, lon, data
63 lon_info(tuple): 目标网格的经度信息, (起始经度, 结束经度, 分辨率)
64 lat_info(tuple): 目标网格的纬度信息, (起始纬度, 结束纬度, 分辨率)
65 effectR(float): 最大的插值半径, 单位km
66 nearNum(int): 插值选择的临近站点的个数, nearNum =1时即为临近点插值
67 decrease(int): 站点权重随距离幂次衰减,即 站点权重 = 1 /(距离 ** decrease), 其中decrease是幂函数的指数部分参数
68
69 Returns:
70 (xr.DataArray): 插值后的网格
71 """
72 lon_min, lon_len, lon_res = grd_info(*lon_info)
73 lat_min, lat_len, lat_res = grd_info(*lat_info)
74 xyz_sta = lon_lat_to_cartesian(sta['lon'].values, sta['lat'].values, R=ER)
75 lon = np.arange(lon_len) * lon_res + lon_min
76 lat = np.arange(lat_len) * lat_res + lat_min
77 grid_lon, grid_lat = np.meshgrid(lon, lat)
78 xyz_grid = lon_lat_to_cartesian(grid_lon.flatten(), grid_lat.flatten(), R=ER)
79 tree = cKDTree(xyz_sta)
80 # d,inds 分别是站点到格点的距离和id
81 if nearNum > len(sta.index):
82 nearNum = len(sta.index)
83 d, inds = tree.query(xyz_grid, k=nearNum)
84 if nearNum > 1:
85 d += 1e-6
86 w = 1.0 / d ** decrease
87 input_dat = sta.values[:,-1]
88 dat = np.sum(w * input_dat[inds], axis=1) / np.sum(w, axis=1)
89 bg = grid_data(lon_min, lon_len, lon_res, lat_min, lat_len, lat_res)
90 bg_dat = bg.values.flatten()
91 dat = np.where(d[:, 0] > effectR, bg_dat, dat)
92 else:
93 input_dat = sta.iloc[:,-1].values
94 dat = input_dat[inds]
95 bg = grid_data(lon_min, lon_len, lon_res, lat_min, lat_len, lat_res)
96 bg_dat = bg.values.flatten()
97 dat = np.where(d[:] > effectR, bg_dat, dat)
98 dat = dat.astype(np.float32)
99 grd = grid_data(lon_min, lon_len, lon_res, lat_min, lat_len, lat_res, dat)
100 return grd
101
102
103# 站点数据
104df = pd.read_csv("UPA_obs.csv", sep=",", encoding="utf-8")
105df = df[["latitude", "longitude", "temperature"]]
106df = df.dropna(subset=["latitude", "longitude"], how="any")
107df.index = range(len(df))
108df["temperature"] = df["temperature"].astype(float)
109df = df.rename(columns={"latitude": "lat", "longitude": "lon", "temperature": "value"})
110lon_min, lon_max, lat_min, lat_max = df.lon.min(), df.lon.max(), df.lat.min(), df.lat.max()
111lon_buffer = abs(lon_max - lon_min) * 0.1
112lat_buffer = abs(lat_max - lat_min) * 0.1
113nc_interp = interp_sg_idw(
114 df,
115 (df.lon.min()-lon_buffer, df.lon.max()+lon_buffer, 0.1),
116 (df.lat.min()-lat_buffer, df.lat.max()+lat_buffer, 0.1),
117 effectR=10000
118)
119xr.Dataset({"data": nc_interp}).to_netcdf("station_to_grid.nc")
插值后的数据填色绘图如下:

站点数据和插值后的对比图如下:

从这个对比图可以看到原站点数值和插值后的数值之间的对应情况。
注:
从颜色分布来看,我注意到这个插值结果的数值似乎有些偏大,不知道是这个插值算法就该如此还是代码有问题,待后续研究。