ERA5‑Land逐小时降水数据下载+QGIS后台QgsTask批量处理完整指南(水文洪涝仿真,河南省案例)

一、数据集简介

ERA5‑Land是ECMWF哥白尼气候数据中心再分析陆面数据集。

  • 分辨率:0.1°×0.1°;时间分辨率:逐小时
  • 变量:total_precipitation总降水,原始单位:米(m),业务分析必须 ×1000 转为毫米mm
  • 时间基准:UTC世界时,国内使用需要+8小时转为北京时间
  • 用途:区域暴雨分析、降雨事件识别、洪涝水文模型驱动;⚠️属于模式再分析产品,不等同地面站点实测数据,后期建议用实测站点做校验

获取两种途径:①CDS网页表单下载;②cdsapi Python API脚本下载。

二、前期环境准备

2.1 CDS账号准备

  1. 注册Copernicus CDS账号 https://cds.climate.copernicus.eu/
  2. 进入个人Profile,复制UID与API Key。
  3. Windows用户目录 C:\Users\你的用户名 创建文件 .cdsapirc禁止.txt后缀,写入:
ini 复制代码
url: https://cds.climate.copernicus.eu/api/v2
key: UID:你的API密钥
  1. 打开 OSGeo4W Shell(QGIS自带终端,不要普通cmd),安装依赖库:
bash 复制代码
python -m pip install cdsapi netCDF4 xarray -i https://pypi.tuna.tsinghua.edu.cn/simple --timeout 120

注意:不要升级QGIS自带pip,升级会破坏QGIS内置环境

2.2 重要环境约定

  1. 网络下载脚本,仅在OSGeo4W Shell运行,禁止放到QgsTask后台任务! QgsTask子线程不适合阻塞式网络IO,会卡死界面、出现SSL异常。
  2. QgsTask后台处理nc文件,路径必须使用纯英文目录,禁止中文文件夹 ;Windows QgsTask子线程存在编码bug:资源管理器可见中文文件,子线程IO报No such file or directory,但是主线程OSGeo4W Shell可以正常读取文件。

✅示例路径:C:/D/project/era5_data

❌错误路径:C:/D/project/重点研发

三、CDS网页端下载(河南区域示例)

数据集:ERA5‑Land hourly data from 1950 to present

参数设置(参考页面截图)

  1. Variable → Wind, Pressure and Precipitation,只勾选 Total precipitation(仅降水,减少数据体积)
  2. Year:按需勾选,示例2023、2024、2025
  3. Month:July(7月,河南主汛期)
  4. Day:Select all(01‑31全部日期)
  5. Time:Select all(00:00‑23:00全部小时)
  6. Geographical area:选择Area selection顺序【北,西,南,东】
    • North=37.0;West=110.0;South=31.0;East=117.5(河南省范围)
  7. Data format:NetCDF4 (Experimental)
  8. Download format:Unarchived (not zipped if single file)

关键点:选择此项直接输出nc文件,不会生成zip压缩包,避免手动解压出错 。如果选Zip,下载得到压缩包,解压时Windows容易出现双重后缀.nc.nc坑。

  1. Terms of use:状态必须为Accepted(每个数据集要单独接受许可,否则API报403 Forbidden)
  2. Submit form提交任务,服务器排队完成浏览器直接下载.nc文件。

注意:CDS服务器在海外,任务排队、下载速度不稳定,单月河南数据4‑7MB。

四、cdsapi脚本下载(OSGeo4W Shell运行)

保存为download_era5.py,放在纯英文目录。

python 复制代码
import cdsapi
import os

save_root = r"C:/D/project/era5_data"
os.makedirs(save_root, exist_ok=True)
# 河南边界 [北,西,南,东]
henan_area = [37.0, 110.0, 31.0, 117.5]
year_month_list = [
    (2023,7),
    (2024,7),
    (2025,7),
]

c = cdsapi.Client(quiet=False, progress=True)

for year, month in year_month_list:
    if month in [1,3,5,7,8,10,12]:
        days = [str(d).zfill(2) for d in range(1,32)]
    elif month in [4,6,9,11]:
        days = [str(d).zfill(2) for d in range(1,31)]
    else:
        days = [str(d).zfill(2) for d in range(1,29)]

    out_nc = os.path.join(save_root, f"ERA5_Land_Henan_{year}{month:02d}.nc")
    print(f"开始提交任务 {year}‑{month:02d}")
    c.retrieve(
        'reanalysis‑era5‑land',
        {
            'variable': ['total_precipitation'],
            'year': str(year),
            'month': f"{month:02d}",
            'day': days,
            'time': [f"{h:02d}:00" for h in range(24)],
            'area': henan_area,
            'format': 'netcdf'
        },
        out_nc
    )
print("全部下载完成")

运行命令OSGeo4W Shell:

bash 复制代码
python C:/D/project/era5_data/download_era5.py

异常提示:脚本中断会留下zip文件,没有生成nc;需要删除zip,重新运行该月份下载任务。

五、QGIS QgsTask后台批量处理(信号槽跨线程日志,识别降雨事件)

功能:读取nc,单位转换m→mm;游程编码识别连续降雨事件;输出月度汇总csv、降雨事件明细csv;后台运行,不阻塞QGIS主界面UI。

核心要点:

  1. run()子线程禁止直接调用QgsMessageLog,使用pyqtSignal信号‑槽把日志投递主线程显示日志面板。
  2. 显式指定engine="netcdf4"读取nc,不依赖xarray自动猜测IO后端。
  3. 增加文件存在性、文件大小校验,捕获异常,同时输出本地txt日志兜底。
  4. 降雨判定阈值:0.1 mm/h,大于阈值视为有效降雨,连续小时合并一场降雨事件。

完整脚本复制到QGIS【处理】‑【Python控制台编辑器】运行:

python 复制代码
from qgis.core import QgsTask, QgsMessageLog, Qgis, QgsApplication
from qgis.utils import iface
from PyQt5.QtCore import QObject, pyqtSignal
import xarray as xr
import os
import numpy as np
import csv
from datetime import datetime

MESSAGE_CATEGORY = "ERA5降水后处理"

class TaskSignals(QObject):
    log_signal = pyqtSignal(str, int)

def detect_rain_events(series, threshold):
    mask = np.where(series > threshold, 1, 0)
    events = []
    if np.sum(mask) == 0:
        return events
    diff = np.diff(np.concatenate(([0], mask, [0])))
    run_starts = np.nonzero(diff == 1)[0]
    run_ends = np.nonzero(diff == -1)[0]
    for s_idx, e_idx in zip(run_starts, run_ends):
        dur_h = int(e_idx - s_idx)
        seg = series[s_idx:e_idx]
        total_rain = float(np.sum(seg))
        avg_int = float(np.mean(seg))
        max_int = float(np.max(seg))
        events.append({
            "start_hour_idx": int(s_idx),
            "end_hour_idx": int(e_idx - 1),
            "duration_h": dur_h,
            "total_rain_mm": round(total_rain, 3),
            "avg_intensity_mmh": round(avg_int, 3),
            "max_intensity_mmh": round(max_int, 3)
        })
    return events

class RainPostProcessTask(QgsTask):
    def __init__(self, description, data_dir, nc_file_list, rain_threshold=0.1):
        super().__init__(description, QgsTask.CanCancel)
        self.data_dir = data_dir
        self.nc_file_list = nc_file_list
        self.rain_threshold = rain_threshold
        self.results = []
        self.event_details = []
        self.exception = None
        self.log_file_path = os.path.join(self.data_dir, "era5_task_log.txt")
        self.signals = TaskSignals()
        self.signals.log_signal.connect(self._slot_receive_log)

    def _slot_receive_log(self, text, level):
        QgsMessageLog.logMessage(text, MESSAGE_CATEGORY, level)

    def _emit_log(self, text, level=Qgis.Info):
        time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        line = f"[{time_str}] {text}"
        self.signals.log_signal.emit(line, level)
        with open(self.log_file_path, "a", encoding="utf-8") as f:
            f.write(line + "\n")

    def run(self):
        self._emit_log("开始后台处理ERA5-Land nc降水文件")
        total_files = len(self.nc_file_list)
        try:
            for file_idx, fname in enumerate(self.nc_file_list):
                if self.isCanceled():
                    self._emit_log("任务被用户手动取消", Qgis.Warning)
                    return False
                progress = int((file_idx / total_files) * 100)
                self.setProgress(progress)
                self._emit_log(f"正在处理:{fname}")

                fpath = os.path.join(self.data_dir, fname)
                with xr.open_dataset(fpath) as ds:
                    # ---- 处理 ERA5-Land 变量 'tp' ----
                    precip = ds["tp"]                         # 单位:米
                    # 处理可能的实验版本维度
                    if "expver" in precip.dims:
                        precip = precip.sel(expver=1)
                    # 处理可能的集合成员维度(一般长度为1)
                    if "number" in precip.dims:
                        precip = precip.isel(number=0)
                    # 挤压长度为1的维度
                    precip = precip.squeeze(drop=True)

                    # 统一时间维度名:如果没有 'time' 但有 'valid_time',则重命名
                    if "time" not in precip.dims and "valid_time" in precip.dims:
                        precip = precip.rename({"valid_time": "time"})

                    # 转换为毫米并确保非负
                    precip_mm = precip * 1000.0
                    precip_mm = precip_mm.clip(min=0)

                    # ---- 统计指标 ----
                    time_cnt = precip_mm.sizes["time"]
                    lat_cnt = precip_mm.sizes["latitude"]
                    lon_cnt = precip_mm.sizes["longitude"]
                    month_mean_total = float(precip_mm.mean())

                    rain_hour_count = (precip_mm > self.rain_threshold).sum(dim="time")
                    avg_rain_hours = float(rain_hour_count.mean())

                    precip_valid = precip_mm.where(precip_mm > self.rain_threshold)
                    avg_rain_intensity = float(precip_valid.mean()) if not np.isnan(precip_valid.mean()) else 0.0

                    row_summary = {
                        "file": fname,
                        "time_hours": time_cnt,
                        "lat": lat_cnt,
                        "lon": lon_cnt,
                        "month_avg_precip_mm": round(month_mean_total, 2),
                        "avg_rain_hour": round(avg_rain_hours, 2),
                        "avg_rain_int_mmh": round(avg_rain_intensity, 2)
                    }
                    self.results.append(row_summary)
                    self._emit_log(f"{fname} 月度汇总统计 {row_summary}")

                    # ---- 逐格点识别降雨事件 ----
                    self._emit_log(f"{fname}: 开始逐格点识别降雨事件")
                    lat_values = precip_mm["latitude"].values
                    lon_values = precip_mm["longitude"].values
                    for lat_i, lat_val in enumerate(lat_values):
                        for lon_i, lon_val in enumerate(lon_values):
                            if self.isCanceled():
                                self._emit_log("任务被用户手动取消", Qgis.Warning)
                                return False
                            ts_1d = precip_mm[:, lat_i, lon_i].values
                            ev_list = detect_rain_events(ts_1d, self.rain_threshold)
                            for ev in ev_list:
                                ev_rec = {
                                    "nc_file": fname,
                                    "lat": round(float(lat_val), 4),
                                    "lon": round(float(lon_val), 4),
                                    **ev
                                }
                                self.event_details.append(ev_rec)
                    self._emit_log(f"{fname} 完成,当前累计降雨事件:{len(self.event_details)}")

            # ---- 输出汇总CSV ----
            csv_sum_path = os.path.join(self.data_dir, "rain_stat_result.csv")
            if self.results:
                with open(csv_sum_path, "w", encoding="utf-8-sig", newline="") as f:
                    writer = csv.DictWriter(f, fieldnames=self.results[0].keys())
                    writer.writeheader()
                    writer.writerows(self.results)
                self._emit_log(f"月度汇总csv输出完成:{csv_sum_path}")
            else:
                self._emit_log("无汇总数据可输出,跳过rain_stat_result.csv", Qgis.Warning)

            # ---- 输出事件明细CSV ----
            csv_event_path = os.path.join(self.data_dir, "rain_event_detail.csv")
            if self.event_details:
                with open(csv_event_path, "w", encoding="utf-8-sig", newline="") as f:
                    writer = csv.DictWriter(f, fieldnames=self.event_details[0].keys())
                    writer.writeheader()
                    writer.writerows(self.event_details)
                self._emit_log(f"降雨事件明细csv输出完成:{csv_event_path}")
            else:
                self._emit_log("无降雨事件明细,跳过rain_event_detail.csv", Qgis.Warning)

            self.setProgress(100)
            return True

        except Exception as e:
            import traceback
            tb_msg = traceback.format_exc()
            self.exception = e
            self._emit_log(f"任务异常:{str(e)}\n{tb_msg}", Qgis.Critical)
            return False

    def finished(self, success):
        if success:
            iface.messageBar().pushMessage(
                "完成",
                "ERA5降水后处理任务执行完毕,输出rain_stat_result.csv、rain_event_detail.csv",
                level=Qgis.Success, duration=10
            )
        else:
            if self.exception is not None:
                iface.messageBar().pushMessage(
                    "错误", f"后台任务失败:{str(self.exception)}",
                    level=Qgis.Critical, duration=12
                )
            else:
                iface.messageBar().pushMessage(
                    "警告", "任务被取消或未正常完成",
                    level=Qgis.Warning, duration=8
                )

# ====================== 执行区 ======================
data_directory = r"C:/D/project/zdyf"
nc_file_list = [
    "ERA5_Land_Henan_202307.nc",
    "ERA5_Land_Henan_202407.nc",
    "ERA5_Land_Henan_202507.nc"
]

task = RainPostProcessTask(
    description="ERA5汛期降水统计后台任务",
    data_dir=data_directory,
    nc_file_list=nc_file_list,
    rain_threshold=0.1
)

QgsApplication.taskManager().addTask(task)
QgsMessageLog.logMessage("任务已提交,等待执行...", "TaskStarter", Qgis.Info)

5.1 输出文件说明

运行完成后在C:/D/project/era5_data生成3个产物:

  1. era5_task_log.txt:完整运行日志,排错用。
  2. rain_stat_result.csv:月度格点汇总统计。
  3. rain_event_detail.csv逐格点每场降雨事件明细(核心结果,用于洪涝仿真)
rain_event_detail.csv字段释义
字段 说明
nc_file 来源nc文件名,标记月份
lat 网格中心点纬度°N
lon 网格中心点经度°E,可直接导入QGIS做点图层制图
start_hour_idx 小时数组索引,不是真实时间!0代表nc第一个时间步
end_hour_idx 降雨事件结束小时索引
duration_h 降雨持续时长,单位小时h
total_rain_mm 本场事件累计总降雨量mm
avg_intensity_mmh 本场平均降雨强度 mm/h
max_intensity_mmh 本场最大小时降雨强度 mm/h(洪涝致灾关键指标)

示例记录解读

复制代码
ERA5_Land_Henan_202307.nc,37.0,110.0,38,72,35,182.601,5.217,7.588

2023‑07,(37.0°N,110.0°E)网格,一场连续35小时降雨事件,累计182.601mm,最大小时雨强7.588mm/h,属于高洪涝风险持续性暴雨过程。

注意:start_hour_idx只是数组下标;需要结合nc里面ds.time.values[start_hour_idx]获取UTC时间,业务分析需要+8h转为北京时间。

六、常见坑与排错清单

1、CDS网页/API返回403 Forbidden

报错required licences not accepted

原因:该数据集许可协议未手动接受。打开数据集下载页面,找到Terms of use点击接受,等待1‑2分钟服务器同步后再运行脚本。

2、cdsapi下载得到zip包,没有nc

网页下载格式务必选择:Unarchived (not zipped if single file);API中断会残留zip文件,删除zip,重新下载对应月份。Windows打开文件扩展名显示,避免重命名产生xxx.nc.nc双重后缀。

3、QgsTask后台:明明文件存在,报No such file or directory

Windows QgsTask子线程中文路径编码bug

解决:全部nc数据移动到纯英文目录,后台IO禁止中文文件夹、中文文件名。OSGeo4W Shell可以正常读中文路径,不代表QgsTask子线程可以读取。

4、xarray报找不到IO后端did not find a match in any of xarray's currently installed IO backends

  1. 代码显式指定xr.open_dataset(fpath, engine="netcdf4"),不使用自动探测。
  2. OSGeo4W Shell执行确认import netCDF4无报错,异常就重装netCDF4库。
  3. 判断nc文件大小,几KB代表下载损坏,删除重下。

5、单位千万不要搞错

ERA5‑Land total_precipitation原始单位米m ,必须*1000转为毫米mm,否则雨量结果差1000倍。

6、时区问题

ERA5‑Land时间是UTC世界时;我国业务分析,时间字段要手动+8小时得到北京时间。

相关推荐
StarRocks_labs2 天前
当大模型调用进入执行引擎:StarRocks AI Function 全新能力解析
数据库·starrocks·sql·ai·pipeline·数据处理·join
GISHUB3 天前
基于QGIS 3.44开发Python插件完整教程
python·qgis
奔跑的呱呱牛3 天前
腾讯地图瓦片接入方案:一个 Bun 代理,让 Mapbox / Leaflet / OpenLayers 通用
gis·openlayers·mapbox·leaflet
GISBox3 天前
数字孪生项目必备:RVT模型转3DTiles格式的高效解决方案
gis·cesium·格式转换·服务发布·gisbox·rvt切片·3dtiles格式
Patrick在香港4 天前
Python 抓香港政府公报 RSS:中英两个 feed 的 guid 零重合,lastBuildDate 停在 2016 年
python·数据处理·数据抓取·香港·rss
charlee444 天前
《GIS基础原理与技术实践》配套教学课件
gis·开源项目·地理信息系统·教学课件·gis基础原理与技术实践
敲敲敲敲暴你脑袋5 天前
地图瓦片批量改色来啦!
node.js·gis·数据可视化
2601_962300816 天前
机器学习贴士:使用Python编写MapReduce
hadoop·python·机器学习·mapreduce·数据处理
GIS好难学7 天前
测绘工程有哪些转型方向?
gis
luckystar513~7 天前
Navara是WebGIS的第三条路吗?
gis·cesium·mapbox·地图引擎·地理可视化