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小时得到北京时间。

相关推荐
RestCloud14 小时前
企业数据脱敏方案:静态脱敏、动态脱敏与 API 输出脱敏的选型
数据安全·数据处理·数据传输·ipaas·数据脱敏·api治理·api管理
xiaominlaopodaren14 小时前
three.js最小地图运行时(十):接入离线逻辑瓦片剖面
javascript·gis·three.js
三维地图技术社区1 天前
一张实景三维地图是怎么来的?从无人机航测到倾斜摄影建模的完整技术链路
gis·实景三维·三维可视化大屏·bim·无人机航测·倾斜摄影建模·空三解算
xiaominlaopodaren2 天前
three.js最小地图运行时(九):TileKey 线框与 cursor 坐标探针
javascript·gis·three.js
xiaominlaopodaren3 天前
three.js最小地图运行时(八): 瓦片调试网格
javascript·gis·three.js
久数君4 天前
2026年GIS入门软件怎么选?ArcGIS、QGIS、GISBox等工具详解
gis·qgis·arcgis pro·超图·gisbox·global mapper·地图数据处理工具
xiaominlaopodaren4 天前
three.js最小地图运行时(七): MapRuntime 与地图交互
javascript·gis·three.js
xiaominlaopodaren6 天前
three.js最小地图运行时(六): 地图运行时
javascript·gis·three.js
一个有理想的摸鱼选手8 天前
(四)路书Agnet-综合天气距离交通节奏等多因素来编排旅行路线
前端·后端·gis