1. 为什么我们需要从eta坐标“翻译”到指定高度?

如果你用过WRF模式,肯定对那一堆NetCDF文件又爱又恨。爱的是数据真全,从地面到高空,什么物理量都有;恨的是,当你兴冲冲地想看看“离地100米的风速怎么样”时,打开数据一看,傻眼了——数据是按一种叫“eta坐标”的垂直层存储的,根本不是我们熟悉的“离地XX米”。

这感觉就像你拿到一张藏宝图,但上面的标记用的是某种古老的密码,你得先破译密码,才能知道宝藏具体在哪个位置。eta坐标就是WRF的“垂直密码”。它是个无量纲数,范围从地面处的1到模式顶部的0。它的设计非常聪明,能很好地适应复杂地形。想象一下,在青藏高原和东部平原交界的地方,如果都用固定的海拔高度层,那在高原地区,很多底层网格可能就直接插到地底下去了。eta坐标通过跟随地形起伏,确保了每一层都是实实在在的大气,不会出现“网格一半是岩石一半是空气”的尴尬情况。

但问题来了,我们做分析、写论文、给业务部门汇报的时候,总不能说“请看eta=0.95这一层的风场”吧?大家关心的是“100米高的风有多大”、“500米处的温度如何”。这就是我们后处理要解决的核心问题:把模式在eta坐标上算好的“宝藏”(气象要素),准确地“翻译”并“搬运”到我们指定的高度层上,最后画出一目了然的图。

这个过程,我把它叫做“三维寻宝”。你需要三步:第一,理解藏宝图的编码规则(eta坐标);第二,计算出每一个藏宝点(网格点)离地面的实际高度;第三,用工具把你想要的、在特定高度的宝藏给“挖”出来(插值)。听起来有点绕,但别怕,跟着我一步步操作,你会发现用wrf-python这个神器,整个过程可以变得非常优雅。我踩过不少坑,比如自己写插值函数结果边界处理不好,或者高度计算有偏差导致结果诡异,这些经验都会分享给你,保证你绕过这些弯路。

2. 彻底搞懂eta坐标:WRF的垂直层“密码”

要翻译,先得懂密码本。eta坐标的公式在很多教科书和官方文档里都能找到:eta = (p - p_top) / (p_sfc - p_top)。这里p是气压,p_top是模式顶层气压,p_sfc是地表气压。当地表气压高(比如在低海拔的盆地),eta值的变化范围就大;在地表气压低的高原,eta值的变化范围就相对压缩。这就保证了无论地面高低,eta=1始终代表地面,eta=0始终代表模式顶。

但只看公式还是有点抽象。我更喜欢用一个“弹簧床”的类比来理解。你把eta坐标想象成一张铺在地形上的、有弹性的弹簧床垫。地面起伏,床垫也跟着起伏。床垫上画了很多层线(eta层),这些层线之间的距离(垂直间隔)不是固定的,它会根据地形和大气状态拉伸或压缩。在山区,靠近地面的层线会被压缩得密集一些;在平原,层线分布就相对均匀。这样,每一层线都代表一个“等eta面”,它本身是倾斜的、跟随地形的。

那么,我们如何知道弹簧床垫上某个点(某个网格的某个eta层)离真实地面有多高呢?这就是关键。WRF的输出文件里,有两个变量直接相关:PH和PHB。它们分别是扰动地形势和基础地形势,单位是m^2/s^2。注意,它们是定义在层与层之间的界面上的(在WRF里叫“stag”格点)。所以,要得到某一层中间的高度,我们需要先把相邻界面的PH+PHB的值平均到层中心,然后再把它从能量单位转换成高度单位。

这里就是第一个容易踩坑的地方。很多朋友直接拿(PH+PHB)/9.81就当作几何高度了,然后发现画出来的高度场怪怪的。他们忘了减去地形高度HGT!(PH+PHB)/9.81得到的是海拔几何高度,而我们关心的是离地高度。所以正确的公式是: height_agl = (PH + PHB) / 9.81 - HGT 这个height_agl才是我们寻宝地图上,每个藏宝点离地面的真实垂直距离。幸运的是,wrf-python库已经把这个计算过程封装好了,我们直接用getvar(ncfile, “height_agl”)就能拿到一个三维数组,非常方便。不过,了解背后的公式,能帮你在结果出现异常时,快速定位问题所在。

3. 实战第一步:准备环境与读取数据

工欲善其事,必先利其器。咱们这个“寻宝”任务,主要靠wrf-python这把瑞士军刀。我强烈建议你使用conda来管理环境,能避免很多依赖库版本冲突的破事。

# 创建一个新的虚拟环境,Python版本建议3.8-3.10,比较稳定
conda create -n wrf_viz python=3.9
conda activate wrf_viz

# 安装核心工具包,用conda-forge频道最省心
conda install -c conda-forge wrf-python netcdf4 numpy matplotlib cartopy

wrf-python是UCAR官方维护的,专门用于WRF后处理和可视化的库,它的插值函数经过大量测试,比自己手写的要稳健得多。netcdf4用来读数据,numpy是基础,matplotlib画图,cartopy则是画专业地图投影的神器,如果你需要画地理底图的话。

数据准备方面,拿出你的WRF输出文件,通常是wrfout_d0*这种格式。我建议你先用一个时间点的文件来测试整个流程,比如wrfout_d01_2023-04-08_00:00:00。测试通了,再写循环去处理多个时次。

让我们打开文件,看看里面有什么宝贝:

from netCDF4 import Dataset
from wrf import getvar

# 替换成你的文件路径
file_path = './wrfout_d01_2023-04-08_00:00:00'
ncfile = Dataset(file_path, 'r') # ‘r’代表只读模式

# 先看看文件里有哪些变量,做到心中有数
print(ncfile.variables.keys())

# 读取我们最关心的几个基础变量
# 地形高度 (HGT), 这是二维场
terrain = getvar(ncfile, “HGT”)
print(f“地形高度场形状: {terrain.shape}”)
print(f“地形范围: {terrain.min():.1f} 米 到 {terrain.max():.1f} 米”)

# 气压 (p), 这是三维场
pressure = getvar(ncfile, “p”)
print(f“气压场形状: {pressure.shape}”) # 应该是 (垂直层数, 南北格点, 东西格点)

# 离地高度 (height_agl),直接用wrf-python的诊断函数,这是三维场
height_agl = getvar(ncfile, “height_agl”)
print(f“离地高度场形状: {height_agl.shape}”)
print(f“第一层离地高度范围: {height_agl[0,:,:].min():.1f} 到 {height_agl[0,:,:].max():.1f} 米”)

运行这段代码,你就能对数据的维度有个直观认识。通常,height_agl的第一个维度就是垂直层,对应着eta层。你会发现,即使在同一个eta层(比如第0层),不同格点上的离地高度也是不一样的,在山区高,在平原低,这正是eta坐标跟随地形的体现。

4. 核心魔法:将变量插值到任意指定高度

数据读进来了,高度也知道了,现在就是最关键的“挖宝”环节——插值。我们的目标是:已知变量(如气压、温度、风速)在每一个网格点上的、一系列离散高度(height_agl)上的值,求该变量在同一个网格点、指定高度(例如100米)上的值。

这本质上是一个一维垂直插值问题,但要对水平场上每一个点都做一遍。听起来要写循环?别!wrf-python的interpz3d函数就是干这个的,它用起来非常直观。

假设我想得到离地100米、200米和500米这三个高度上的气压场,代码如下:

import numpy as np
from wrf import interpz3d

# 定义我们想要的目标高度列表,单位是米
target_heights = [100.0, 200.0, 500.0]

# 使用 interpz3d 进行插值
# 参数顺序是:要插值的三维场, 对应的高度场(三维), 目标高度(一维数组)
pressure_at_heights = interpz3d(pressure, height_agl, target_heights)

print(f“插值后的压力场形状: {pressure_at_heights.shape}”)
# 输出会是 (3, 南北格点, 东西格点), 因为我们要了3个高度

看,就这么简单!interpz3d内部对每个水平网格点进行了高效的插值计算。它默认使用的是线性插值,这对于气象要素在垂直方向上的连续变化来说,精度在大多数情况下已经足够。如果你追求更高精度,可以考虑样条插值,但通常线性插值更稳定,不易在数据梯度大的地方产生振荡。

这里有一个超级重要的细节:你定义的目标高度,必须在每个网格点实际的高度范围之内。比如,某个高山格点,其第一层(最底层)的height_agl可能就有50米。那么对于这个点,你想插值到10米的高度就是不可能的,因为数据没有延伸到那么低(模式最底层就是地面)。interpz3d函数对于这种“外插”情况,默认会进行外推,但这可能带来很大的误差。我个人的习惯是,在插值前先做个检查:

# 检查目标高度是否在合理范围内
min_height_per_grid = height_agl.min(axis=0) # 每个格点上的最小离地高度(通常是第一层)
max_height_per_grid = height_agl.max(axis=0) # 每个格点上的最大离地高度(通常是顶层)

# 假设我们只想在能内插的格点进行计算,可以设置一个掩膜
valid_mask_100m = (min_height_per_grid <= 100) & (100 <= max_height_per_grid)
print(f“能够进行100米高度内插的格点比例: {np.mean(valid_mask_100m)*100:.2f}%”)

如果这个比例不是100%,说明有些地方(特别是高山区域)的100米高度可能已经低于模式的第一层,这时候你就要决定是接受外推的结果,还是将这些格点标记为缺省值。在实际分析中,我通常选择后者,因为外推的风速、温度可能物理上不真实。

5. 风场的特殊处理:获取指定高度的U/V风速

风场是气象分析中的重中之重,但处理起来比标量场(如气压、温度)多一步。WRF输出文件里的风场U和V,是定义在交错网格上的。简单说,U表示东西方向的风,但它存储的位置是在网格的西-东界面上,而不是网格中心。V同理,存储在南-北界面上。如果我们直接拿交错网格上的U、V去插值,会跟定义在网格中心的高度场height_agl对不上,导致插值出错。

所以,我们需要先把U和V从交错网格插值到质量网格(即网格中心)。wrf-python提供了destagger函数来做这件事:

from wrf import getvar, destagger

# 读取原始的交错网格风场
u_stag = getvar(ncfile, “U”) # 形状可能是 (垂直层, 南北格点, 东西格点+1)
v_stag = getvar(ncfile, “V”) # 形状可能是 (垂直层, 南北格点+1, 东西格点)

# 将U场从东西向的交错网格插值到质量网格中心
# destagger 的第二个参数是交错的方向:0代表X方向(东西),1代表Y方向(南北),2代表Z方向(垂直)
u_center = destagger(u_stag, 0, meta=True)

# 将V场从南北向的交错网格插值到质量网格中心
v_center = destagger(v_stag, 1, meta=True)

# 现在,u_center和v_center的形状就和pressure、height_agl一模一样了
print(f“U场中心化后形状: {u_center.shape}”)
print(f“V场中心化后形状: {v_center.shape}”)

搞定这一步,后面的操作就和气压场一样了。你可以分别对u_center和v_center进行垂直插值,得到指定高度上的U、V分量:

# 定义目标高度
target_h = [100.0] # 这次我们只要100米高度

# 插值得到100米高度的U、V分量
u_100m = interpz3d(u_center, height_agl, target_h)
v_100m = interpz3d(v_center, height_agl, target_h)

# 如果你还需要风速和风向,可以很方便地计算
# 风速
wspd_100m = np.sqrt(u_100m**2 + v_100m**2)
# 风向(气象角,0度代表北风,90度代表东风)
wdir_100m = np.degrees(np.arctan2(u_100m, v_100m)) + 180

这里计算风向的公式np.arctan2(u, v)是气象上的标准做法,arctan2函数能正确处理四个象限,算出来的角度是以数学上的正东方向为0度,逆时针旋转。+180是为了转换成气象上以正北为0度,顺时针旋转的惯例。你可以用metpy库里的calc.wind_components或calc.wind_direction函数来验算,结果应该是一致的。

6. 一键成图:绘制精美的高度层空间分布图

数据都处理好了,不画出来看看岂不是锦衣夜行?用matplotlib和cartopy画图,能让你的成果瞬间专业起来。我们以绘制100米高度的风速场为例。

首先,我们需要从WRF文件中获取经纬度信息。wrf-python的getvar也能很方便地获取:

from wrf import getvar, to_np, latlon_coords
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy.feature as cfeature

# 获取经纬度坐标
lats, lons = latlon_coords(pressure) # 用任何一个从getvar获取的二维或三维变量都可以
lat = to_np(lats) # 转换为numpy数组
lon = to_np(lons)

# 假设我们已经有了 wspd_100m, 并且它是二维数组 (去掉多余的维度)
if wspd_100m.ndim == 3 and wspd_100m.shape[0] == 1:
    wspd_100m_2d = wspd_100m[0, :, :]
else:
    wspd_100m_2d = wspd_100m

# 创建地图和绘图
fig = plt.figure(figsize=(12, 8))
# 创建使用Lambert投影的坐标系,这个需要和你的WRF模拟域设置匹配
# 如果你的namelist里用的是‘lambert’,这里就用LambertConformal
# 获取投影参数(需要从文件属性中读取)
proj_params = getvar(ncfile, “projection”)
# 通常,我们可以直接从变量属性中获取用于cartopy的投影
# 一个更通用的方法是使用wrf-python的get_cartopy函数
from wrf import get_cartopy
cart_proj = get_cartopy(pressure)

ax = plt.axes(projection=cart_proj)

# 设置地图范围(通常取经纬度的最小最大值)
ax.set_extent([lon.min(), lon.max(), lat.min(), lat.max()], crs=ccrs.PlateCarree())

# 添加地理特征,让图更美观
ax.add_feature(cfeature.COASTLINE.with_scale(‘50m’), linewidth=0.8)
ax.add_feature(cfeature.BORDERS.with_scale(‘50m’), linestyle=‘:’, linewidth=0.5)
ax.add_feature(cfeature.LAKES.with_scale(‘50m’), alpha=0.5)
ax.add_feature(cfeature.RIVERS.with_scale(‘50m’))

# 用等高线填充图绘制风速
# 注意:绘图数据的坐标系是PlateCarree(经纬度),而地图投影是cart_proj
contourf = ax.contourf(lon, lat, wspd_100m_2d, levels=20, transform=ccrs.PlateCarree(),
                       cmap=‘YlGnBu’, alpha=0.9)

# 添加等值线
contour = ax.contour(lon, lat, wspd_100m_2d, levels=10, colors=‘k’, linewidths=0.5, transform=ccrs.PlateCarree())
ax.clabel(contour, inline=True, fontsize=8, fmt=‘%.1f’)

# 添加色标
plt.colorbar(contourf, ax=ax, orientation=‘horizontal’, pad=0.05, label=‘Wind Speed at 100m AGL (m/s)’)

# 添加标题
ax.set_title(‘WRF Simulated Wind Speed at 100m AGL\n Time: 2023-04-08 00:00:00’, fontsize=14)

# 添加网格线
gl = ax.gridlines(draw_labels=True, dms=True, x_inline=False, y_inline=False, linestyle=‘--’, alpha=0.5)
gl.top_labels = False # 隐藏顶部标签
gl.right_labels = False # 隐藏右侧标签

plt.tight_layout()
plt.savefig(‘windspeed_100m.png’, dpi=300, bbox_inches=‘tight’)
plt.show()

这段代码生成的就是一张带有地理边界、海岸线、等值线和色标的专业风速分布图。get_cartopy函数会自动从WRF文件头信息中提取投影参数,并创建一个cartopy能识别的投影对象,这比自己手动去拼参数省事太多了,也准确得多。

如果你想画风矢量图(箭头),可以用ax.quiver,但要注意,在高分辨率网格上,箭头会太密集,需要适当做稀疏化处理:

# 每间隔10个点取一个风矢量
stride = 10
ax.quiver(lon[::stride, ::stride], lat[::stride, ::stride],
          u_100m_2d[::stride, ::stride], v_100m_2d[::stride, ::stride],
          transform=ccrs.PlateCarree(), scale=50, color=‘red’, width=0.002)

7. 效率提升与批量处理技巧

处理单个时次的文件没问题了,但WRF输出往往是很多个时次。手动一个个改时间太麻烦。我们需要写个循环,实现批量自动化处理。这里的关键是组织好代码结构和管理好内存。

我推荐将核心处理逻辑写成一个函数,输入是文件路径和目标高度列表,输出是处理好的数据或者直接保存图片:

import os
from glob import glob

def process_wrf_file(filepath, target_heights=[100., 200., 500.]):
    “”“处理单个WRF输出文件,提取指定高度气象场”“”
    print(f“正在处理文件: {os.path.basename(filepath)}”)
    try:
        ncfile = Dataset(filepath, ‘r’)
        # 1. 获取基础场
        height_agl = getvar(ncfile, “height_agl”)
        lats, lons = latlon_coords(height_agl)
        lat = to_np(lats)
        lon = to_np(lons)

        # 2. 获取并处理风场
        u_stag = getvar(ncfile, “U”)
        v_stag = getvar(ncfile, “V”)
        u_center = destagger(u_stag, 0)
        v_center = destagger(v_stag, 1)

        # 3. 插值到目标高度
        results = {}
        for h in target_heights:
            u_at_h = interpz3d(u_center, height_agl, [h])[0]
            v_at_h = interpz3d(v_center, height_agl, [h])[0]
            wspd_at_h = np.sqrt(u_at_h**2 + v_at_h**2)
            results[f‘{int(h)}m’] = {‘U’: u_at_h, ‘V’: v_at_h, ‘WSPD’: wspd_at_h}

        ncfile.close()
        return results, lat, lon

    except Exception as e:
        print(f“处理文件 {filepath} 时出错: {e}”)
        return None, None, None

# 批量处理
wrf_files = sorted(glob(‘./wrfout_d01_*’)) # 获取所有d01域的文件并按时间排序
all_results = []

for wrf_file in wrf_files:
    data, lat, lon = process_wrf_file(wrf_file, [50., 100., 200.])
    if data is not None:
        all_results.append(data)
        # 这里可以添加绘图和保存图片的代码
        print(f“{wrf_file} 处理完成。”)

内存管理小贴士:WRF文件通常很大,尤其是高分辨率、多时次的数据。在循环中,一定要确保在读取下一个文件前,关闭当前文件(ncfile.close()),并考虑及时将处理好的数据保存到磁盘(比如用np.savez),而不是全部堆在内存列表all_results里。对于超大的数据,你可能需要用到dask库进行分块懒加载计算。

另一个提升效率的技巧是并行处理。如果有多台机器或者多核CPU,可以用multiprocessing库来并行处理多个文件。不过,WRF后处理通常是I/O密集型(读文件)和计算密集型(插值)混合的任务,并行化之前最好先测试一下单文件的处理时间,如果读文件占大头,并行效果可能没那么明显。

8. 避坑指南:我踩过的那些“雷”

最后,分享几个我在实际项目中踩过的坑,希望能帮你节省大量调试时间。

坑一:高度计算偏差导致插值结果全为NaN。 这是我早期自己写高度计算公式时遇到的。问题出在单位混淆。WRF输出的PH和PHB单位是m^2/s^2,除以重力加速度9.81 m/s^2后,单位才是米。但我当时用的g=9.8,并且忘了减去地形高度HGT,导致计算出的height_agl在山区是负值。用负的高度去插值,interpz3d函数在很多点上都返回了NaN。教训:始终使用wrf-python内置的getvar(ncfile, “height_agl”),这是最稳妥的。

坑二:风场插值后出现诡异的条纹或斑点。 这个问题困扰了我半天。后来发现,是因为我没有对U和V风场进行去交错(destagger)处理,直接拿交错网格的风和网格中心的高度去插值。数据位置不匹配,插值算法就“乱”了,结果自然很奇怪。教训:处理风场前,脑子里先过一遍“这是定义在交错网格上的吗?”,如果是,先用destagger搬到质量网格上来。

坑三:画出来的图投影扭曲,或者经纬度标签对不上。 早期我用basemap(已弃用)画WRF图,需要手动输入一堆TRUELAT1、STAND_LON等投影参数,很容易输错。后来换到cartopy,配合wrf-python的get_cartopy,问题迎刃而解。教训:拥抱cartopy和wrf-python的生态,它们之间的兼容性越来越好,能自动处理大多数常见的WRF投影(Lambert, Mercator, Polar等)。

坑四:批量处理时内存爆炸。 有一次我试图一次性将10个时次、每个时次20个变量的三维场全部读进内存,结果程序被系统杀掉了。教训:养成“用多少,读多少”的习惯。批量处理时,设计好流水线:读一个文件 -> 处理需要的变量 -> 保存结果(或绘图)-> 关闭文件 -> 处理下一个。对于真的需要处理超大数组的情况,xarray配合dask是更好的选择,它可以实现惰性计算和分块处理。

坑五:忽略模式最底层高度。 在山区,模式最底层(第一层eta层)的离地高度可能就有几十甚至上百米。如果你要插值到10米高度,对于这些格点,实际上是在做外推。外推的风速可能异常大或小。教训:插值前,用height_agl.min(axis=0)检查一下目标高度是否在所有格点都可内插。如果不可内插的格点很多,要么调整目标高度,要么在图中用特殊颜色(如灰色)标记出这些外推区域,并在图注中说明,这样分析结果才严谨。

更多推荐