Python自动化批量下载与处理CHIRPS气象数据实战指南

📅 2026/7/29 16:58:52 👁️ 阅读次数 📝 编程学习
Python自动化批量下载与处理CHIRPS气象数据实战指南

1. 项目概述与核心价值

最近在做一个农业气象分析的项目,需要用到长时间序列的全球降水数据。CHIRPS(Climate Hazards Group InfraRed Precipitation with Station data)数据因其高分辨率(0.05°)和长时序(1981年至今)而成为我的首选。但官网手动下载几十甚至上百个压缩包,再逐个解压、裁剪到研究区,这个流程繁琐到让人头皮发麻。用Python把整个过程自动化,就成了一个非常实际且高效的需求。这不仅仅是写个下载脚本那么简单,它涉及到网络请求的稳定性处理、大文件的分块下载、特定压缩格式的解压、以及基于地理坐标的栅格数据批量处理,是一个典型的数据工程与地理信息科学(GIS)结合的实战场景。无论你是从事气候变化研究、农业模型构建,还是水资源评估,这套自动化流程都能帮你把宝贵的时间从重复劳动中解放出来,聚焦在真正的数据分析上。

2. 技术栈选型与思路拆解

面对“批量下载CHIRPS气象数据并完成解压裁剪”这个目标,我们需要一个清晰的技术实现路径。整个流程可以拆解为四个核心环节:数据发现与链接获取、稳定批量下载、特定格式解压、以及空间裁剪。每个环节都有多种技术方案,我的选型基于稳定性、效率以及可维护性。

2.1 核心工具库选择

网络请求与下载:requeststqdmrequests库是Python中进行HTTP通信的事实标准,其API简洁且功能强大。对于批量下载,尤其是大文件,直接使用requests.get()stream=True参数进行流式下载是必须的,这样可以避免将整个文件加载到内存中。配合tqdm库,我们可以为每个下载任务添加一个美观的进度条,实时监控下载速度和预计剩余时间,这对于处理动辄几百MB的GeoTIFF文件至关重要。

压缩文件处理:zipfilegzipCHIRPS数据通常提供两种压缩格式:.zip.gz。对于.zip文件,Python标准库中的zipfile是唯一且最佳选择,它可以方便地解压单个或多个文件。对于.gz文件(一种使用gzip压缩的TAR包,常见后缀为.tar.gz.tgz),我们需要结合tarfilegzip库。这里有一个关键点:tarfile库本身支持直接打开.tar.gz文件并自动处理gzip解压,因此我们通常直接使用tarfile.open(mode='r:gz'),而无需显式调用gzip库。

栅格数据处理:rasteriogeopandas这是整个流程的技术核心。rasterio是专门为读写栅格数据(如GeoTIFF)而设计的库,其API设计深受GDAL影响但更加Pythonic。我们将用它来打开下载的CHIRPS栅格文件、读取空间参考信息、执行裁剪操作以及写入新的裁剪后文件。geopandas则用于处理我们的研究区矢量边界(通常是Shapefile或GeoJSON格式),它可以方便地读取矢量数据、进行坐标参考系统(CRS)的统一,并将几何对象转换为rasterio可用的掩膜形状。rasteriomask函数是实现裁剪的关键。

2.2 流程架构设计

整个自动化脚本的骨架设计如下,它遵循“获取-处理-输出”的线性流程,但每个模块内部都有完善的错误处理和日志记录。

  1. 输入与配置:用户提供研究区的矢量边界文件路径、目标时间范围(起始年-月)、以及数据保存的根目录。
  2. 链接生成与列表构建:根据CHIRPS数据服务器的公开URL命名规则,程序化生成指定时间范围内所有数据文件的下载链接列表。CHIRPS的URL通常有固定模式,例如:https://data.chc.ucsb.edu/products/CHIRPS-2.0/global_daily/tifs/p05/YYYY/chirps-v2.0.YYYY.MM.DD.tif.gz
  3. 稳定批量下载
    • 遍历链接列表。
    • 对每个链接,检查本地是否已存在该文件(避免重复下载)。
    • 使用requests进行流式下载,并利用tqdm显示进度。
    • 实现重试机制(如使用tenacity库或自定义while循环),应对网络波动。
    • 将文件保存到本地的临时或原始数据目录。
  4. 智能解压处理
    • 遍历下载的压缩文件。
    • 根据文件后缀(.zip.gz)调用相应的解压函数。
    • 将解压后的.tif文件统一存放到一个指定目录(如/unzipped_tifs)。
    • 删除原始的压缩文件以节省空间(可选,建议在确认解压成功后再进行)。
  5. 批量空间裁剪
    • 使用geopandas读取研究区矢量边界,并获取其边界框(bounds)和几何形状(geometry)。
    • 确保矢量边界的CRS与CHIRPS数据(通常是WGS84,EPSG:4326)一致,如果不一致,进行坐标转换。
    • 遍历解压后的所有.tif文件。
    • 对每个文件,用rasterio打开,使用rasterio.mask.mask函数,以上一步获取的几何形状为掩膜,执行裁剪操作。mask函数会返回裁剪后的栅格数组、变换信息以及标签。
    • 将裁剪后的数组写入新的GeoTIFF文件,保存到输出目录(如/clipped_tifs),并继承原始数据的CRS等信息。
  6. 日志与状态管理:在整个过程中,使用Python的logging模块记录关键步骤、成功信息和错误警告,便于后期排查问题。

注意:CHIRPS数据是公开的科研数据,在使用时请遵守其数据使用政策,通常要求注明出处。批量下载时,请务必设置合理的请求间隔(如time.sleep(1)),避免对服务器造成过大压力,体现良好的网络公民素养。

3. 核心模块实现与代码详解

理论讲清楚了,接下来我们进入实战环节,把每个模块的代码掰开揉碎讲明白。我会提供可直接运行的函数代码,并解释关键参数和设计考量。

3.1 数据链接生成模块

CHIRPS数据的URL结构相对规整。我们需要根据用户输入的日期范围,批量生成这些链接。

import requests from datetime import datetime, timedelta import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') def generate_chirps_links(start_date, end_date, base_url_template): """ 生成指定日期范围内的CHIRPS数据下载链接列表。 参数: start_date (datetime): 开始日期。 end_date (datetime): 结束日期。 base_url_template (str): URL模板,使用{year}, {month:02d}, {day:02d}作为占位符。 示例: “https://data.chc.ucsb.edu/products/CHIRPS-2.0/global_daily/tifs/p05/{year}/chirps-v2.0.{year}.{month:02d}.{day:02d}.tif.gz” 返回: list: 下载链接列表。 """ links = [] current_date = start_date delta = timedelta(days=1) while current_date <= end_date: url = base_url_template.format( year=current_date.year, month=current_date.month, day=current_date.day ) links.append(url) current_date += delta logging.debug(f"Generated link for {current_date - delta}: {url}") logging.info(f"Generated {len(links)} links from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}.") return links # 使用示例 if __name__ == '__main__': base_template = "https://data.chc.ucsb.edu/products/CHIRPS-2.0/global_daily/tifs/p05/{year}/chirps-v2.0.{year}.{month:02d}.{day:02d}.tif.gz" start = datetime(2023, 6, 1) end = datetime(2023, 6, 10) url_list = generate_chirps_links(start, end, base_template) print(url_list[:3]) # 打印前三个链接作为示例

关键点解析

  • 日期遍历:使用datetimetimedelta可以安全、准确地遍历每一天,避免手动计算月份和闰年等问题。
  • URL模板:将URL定义为模板字符串,使用str.format()方法进行格式化,代码清晰且易于修改。{month:02d}确保月份总是两位数字,这是CHIRPS文件名所要求的。
  • 日志记录:使用logging模块而非print,可以灵活控制输出级别(如调试时用DEBUG,运行时用INFO),信息也更结构化。

3.2 带重试与进度显示的下载模块

这是最容易出错的环节。网络不稳定、服务器忙、甚至本地磁盘空间不足都可能导致下载失败。一个健壮的下载函数必须包含重试机制和断点续传的考量(虽然这里简化了,但思路很重要)。

import os from pathlib import Path import requests from tqdm import tqdm import time def download_file_with_retry(url, save_path, max_retries=3, chunk_size=8192): """ 下载文件,支持重试和进度显示。 参数: url (str): 文件下载链接。 save_path (str or Path): 本地保存路径。 max_retries (int): 最大重试次数。 chunk_size (int): 下载流块大小,单位字节。 返回: bool: 下载是否成功。 """ Path(save_path).parent.mkdir(parents=True, exist_ok=True) # 确保目录存在 for attempt in range(max_retries): try: # 发起HEAD请求获取文件大小,用于进度条 resp_head = requests.head(url, timeout=10, allow_redirects=True) total_size = int(resp_head.headers.get('content-length', 0)) # 流式下载 response = requests.get(url, stream=True, timeout=30) response.raise_for_status() # 如果状态码不是200,抛出HTTPError # 初始化进度条 progress_bar = tqdm(total=total_size, unit='iB', unit_scale=True, desc=Path(save_path).name, leave=False) with open(save_path, 'wb') as f: for chunk in response.iter_content(chunk_size=chunk_size): if chunk: size = f.write(chunk) progress_bar.update(size) progress_bar.close() # 简单校验:如果知道大小,可以检查本地文件大小 if total_size != 0 and os.path.getsize(save_path) != total_size: logging.warning(f"File size mismatch for {url}. Retrying...") os.remove(save_path) raise IOError("Downloaded file size does not match expected size.") logging.info(f"Successfully downloaded: {save_path}") return True except (requests.exceptions.RequestException, IOError) as e: logging.warning(f"Attempt {attempt + 1} failed for {url}: {e}") if attempt < max_retries - 1: wait_time = 2 ** attempt # 指数退避策略 logging.info(f"Waiting {wait_time} seconds before retry...") time.sleep(wait_time) else: logging.error(f"Failed to download {url} after {max_retries} attempts.") if os.path.exists(save_path): os.remove(save_path) # 删除不完整的文件 return False return False def batch_download(url_list, download_dir): """ 批量下载文件列表。 参数: url_list (list): 下载链接列表。 download_dir (str or Path): 下载文件存储目录。 """ download_dir = Path(download_dir) download_dir.mkdir(parents=True, exist_ok=True) successful_downloads = [] for url in tqdm(url_list, desc="Overall Download Progress"): # 从URL中提取文件名 filename = url.split('/')[-1] save_path = download_dir / filename # 检查文件是否已存在且完整(这里简化处理,仅检查存在性) if save_path.exists(): logging.info(f"File already exists, skipping: {save_path}") successful_downloads.append(save_path) continue if download_file_with_retry(url, save_path): successful_downloads.append(save_path) else: logging.error(f"Skipping {url} due to download failure.") logging.info(f"Batch download finished. {len(successful_downloads)}/{len(url_list)} files succeeded.") return successful_downloads

实操心得

  • chunk_size的选择8192字节(8KB)是一个比较平衡的值。太小会增加循环次数和系统调用开销,太大会占用更多内存。对于网络环境好、文件大的情况,可以尝试增加到32768(32KB)或65536(64KB)。
  • 指数退避:重试等待时间采用指数增长(2 ** attempt),这是处理临时性网络故障的经典策略,避免在服务器恢复前进行“狂轰滥炸”。
  • 文件存在性检查:这里只做了简单的存在性检查。更严谨的做法是记录已下载文件的MD5或文件大小,下次运行时进行比对,实现“断点续传”和“完整性校验”。对于CHIRPS,由于其文件命名包含日期,且数据更新不频繁,简单检查通常足够。
  • tqdmleave参数:设置为False可以让单个文件的进度条在完成后消失,保持整体进度条的整洁。如果你希望查看每个文件的具体下载情况,可以设为True

3.3 多格式解压处理模块

下载下来的文件可能是.zip.gz,我们需要一个能自动识别并处理的解压函数。

import zipfile import tarfile import gzip import shutil from pathlib import Path def extract_file(compressed_path, extract_to_dir): """ 解压文件,支持 .zip 和 .gz/.tar.gz 格式。 参数: compressed_path (str or Path): 压缩文件路径。 extract_to_dir (str or Path): 解压目标目录。 返回: Path or None: 解压出的主要文件路径(通常是.tif),失败则返回None。 """ compressed_path = Path(compressed_path) extract_to_dir = Path(extract_to_dir) extract_to_dir.mkdir(parents=True, exist_ok=True) extracted_file = None try: if compressed_path.suffix == '.zip': with zipfile.ZipFile(compressed_path, 'r') as zip_ref: # 假设zip内只有一个主要文件(如.tif) for file_info in zip_ref.infolist(): if file_info.filename.endswith('.tif'): zip_ref.extract(file_info, extract_to_dir) extracted_file = extract_to_dir / file_info.filename logging.info(f"Extracted {file_info.filename} from ZIP.") break # 找到第一个tif文件就退出 elif compressed_path.suffix in ['.gz', '.tgz']: # 处理 .tar.gz 或 .tgz if '.tar' in compressed_path.suffixes: # 是 .tar.gz with tarfile.open(compressed_path, 'r:gz') as tar_ref: members = tar_ref.getmembers() for member in members: if member.name.endswith('.tif'): tar_ref.extract(member, extract_to_dir) extracted_file = extract_to_dir / member.name logging.info(f"Extracted {member.name} from TAR.GZ.") break else: # 是纯 .gz (非tar包),CHIRPS的.gz通常是tar包,但这里做兼容处理 logging.warning(f"File {compressed_path} is a pure .gz, which is uncommon for CHIRPS. Trying gzip decompression.") output_path = extract_to_dir / compressed_path.stem # 去除 .gz 后缀 with gzip.open(compressed_path, 'rb') as f_in: with open(output_path, 'wb') as f_out: shutil.copyfileobj(f_in, f_out) extracted_file = output_path logging.info(f"Decompressed pure GZIP to {output_path}.") else: logging.error(f"Unsupported compression format for file: {compressed_path}") return None # 验证解压文件是否存在 if extracted_file and extracted_file.exists(): logging.info(f"Extraction successful: {extracted_file}") # 可选:删除原压缩包以节省空间 # compressed_path.unlink() # logging.info(f"Deleted original archive: {compressed_path}") return extracted_file else: logging.error(f"Failed to find extracted .tif file in {compressed_path}") return None except (zipfile.BadZipFile, tarfile.ReadError, OSError) as e: logging.error(f"Failed to extract {compressed_path}: {e}") return None def batch_extract(compressed_file_list, extract_dir): """ 批量解压文件。 参数: compressed_file_list (list): 压缩文件路径列表。 extract_dir (str or Path): 解压目标根目录。 """ extract_dir = Path(extract_dir) extracted_files = [] for comp_file in tqdm(compressed_file_list, desc="Extracting files"): result = extract_file(comp_file, extract_dir) if result: extracted_files.append(result) logging.info(f"Batch extraction finished. {len(extracted_files)} files extracted.") return extracted_files

踩过的坑

  • 压缩包内结构:不是所有压缩包解压后文件都在根目录。有些可能包含一层以日期命名的文件夹。上述代码假设.tif文件直接在压缩包根目录或第一层。更健壮的做法是递归查找所有.tif文件,或者根据CHIRPS的固定命名模式(如chirps-v2.0.YYYY.MM.DD.tif)来定位。
  • .gz.tar.gz:务必区分纯gzip压缩文件(.gz)和先用tar打包再用gzip压缩的文件(.tar.gz.tgz)。CHIRPS数据通常是后者。用tarfile处理.tar.gz是最方便的方式。代码中做了判断,但实际使用中应确认数据格式。
  • 权限与路径:在Windows系统上,解压出的文件可能因为tar包内记录的权限信息而导致权限错误。通常用tar_ref.extract(member, extract_to_dir)没问题,但如果遇到问题,可以尝试使用tar_ref.extractall(extract_to_dir)并设置filter='data'参数(Python 3.12+)或手动处理权限。

3.4 基于矢量边界的批量栅格裁剪模块

这是地理空间处理的核心。我们将使用rasteriomask函数,它需要一个几何图形列表作为输入。

import rasterio from rasterio.mask import mask import geopandas as gpd import numpy as np from pathlib import Path def clip_raster_with_shapefile(raster_path, shapefile_path, output_path, crop=True, all_touched=False): """ 使用矢量面文件裁剪栅格。 参数: raster_path (str or Path): 输入栅格文件路径。 shapefile_path (str or Path): 裁剪用的矢量面文件路径(支持.shp或.geojson等)。 output_path (str or Path): 输出裁剪后栅格文件路径。 crop (bool): 是否将输出栅格的范围裁剪到掩膜几何图形的最小外接矩形。默认为True,可以减小文件体积。 all_touched (bool): 是否裁剪所有被几何图形接触到的像素。默认为False,只裁剪中心点在图形内的像素。 对于低分辨率数据或需要更精确边界时,可设为True。 返回: bool: 裁剪是否成功。 """ try: # 1. 读取矢量边界 gdf = gpd.read_file(shapefile_path) # 确保矢量数据是地理坐标系(WGS84),与CHIRPS数据一致 if gdf.crs is None: logging.warning(f"Shapefile {shapefile_path} has no CRS. Assuming WGS84 (EPSG:4326).") gdf.set_crs('EPSG:4326', inplace=True) elif gdf.crs.to_epsg() != 4326: logging.info(f"Reprojecting shapefile from {gdf.crs} to WGS84 (EPSG:4326).") gdf = gdf.to_crs('EPSG:4326') # 将所有几何图形合并为一个(如果有多边形) if len(gdf) > 1: geometry = gdf.unary_union else: geometry = gdf.geometry.iloc[0] # mask函数需要geojson-like的字典列表 geoms = [geometry.__geo_interface__] # 2. 打开栅格并执行裁剪 with rasterio.open(raster_path) as src: # 执行裁剪操作 out_image, out_transform = mask(src, geoms, crop=crop, all_touched=all_touched, nodata=src.nodata) # 获取裁剪后的元数据 out_meta = src.meta.copy() out_meta.update({ "driver": "GTiff", "height": out_image.shape[1], "width": out_image.shape[2], "transform": out_transform, "nodata": src.nodata }) # 3. 写入输出文件 with rasterio.open(output_path, 'w', **out_meta) as dest: dest.write(out_image) logging.info(f"Successfully clipped: {output_path}") return True except Exception as e: logging.error(f"Failed to clip {raster_path} with {shapefile_path}: {e}") return False def batch_clip(raster_file_list, shapefile_path, output_dir, clip_kwargs=None): """ 批量裁剪栅格文件。 参数: raster_file_list (list): 待裁剪的栅格文件路径列表。 shapefile_path (str or Path): 矢量边界文件路径。 output_dir (str or Path): 输出目录。 clip_kwargs (dict, optional): 传递给clip_raster_with_shapefile函数的其他参数。 返回: list: 成功裁剪的输出文件路径列表。 """ if clip_kwargs is None: clip_kwargs = {} output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) clipped_files = [] for raster_file in tqdm(raster_file_list, desc="Clipping rasters"): raster_path = Path(raster_file) # 构建输出文件名,例如:chirps-v2.0.2023.06.01_clipped.tif output_filename = raster_path.stem + '_clipped.tif' output_path = output_dir / output_filename if output_path.exists(): logging.info(f"Clipped file already exists, skipping: {output_path}") clipped_files.append(output_path) continue if clip_raster_with_shapefile(raster_file, shapefile_path, output_path, **clip_kwargs): clipped_files.append(output_path) logging.info(f"Batch clipping finished. {len(clipped_files)}/{len(raster_file_list)} files processed.") return clipped_files

关键技术细节

  • CRS一致性:这是空间分析中最常见的错误来源。CHIRPS数据的CRS是WGS84地理坐标系(EPSG:4326)。你的研究区矢量边界也必须转换或确认为此坐标系,否则裁剪会错位甚至失败。geopandasto_crs方法可以方便地进行转换。
  • crop参数:设置为True时,rasterio.mask.mask函数不仅将图形外的像素设为nodata,还会将输出图像的范围(边界框)缩小到掩膜几何图形的最小外接矩形。这能显著减小输出文件的大小,通常是推荐的做法。
  • all_touched参数:这个参数决定了像素被保留或丢弃的规则。False(默认)使用“中心点规则”,即像素中心点在图形内才被保留。True使用“全部接触规则”,只要像素被图形接触到(即使中心点在外面)就被保留。对于低分辨率数据或需要精确边界时(如小流域),设为True可能更合适,但会导致边界更“粗糙”。
  • nodata:裁剪后,图形区域外的像素会被赋予nodata值。我们继承了原始栅格的nodata值(通常是-9999nan),确保数据的一致性。在后续分析中,需要使用如numpy.nanrasterio的掩膜数组来处理这些无效值。

4. 完整流程串联与配置管理

将上述模块组合起来,并添加一些工程化的配置管理,就构成了一个完整的自动化脚本。我们使用一个配置文件(如config.yamlconfig.json)来管理所有路径和参数,这样更清晰,也便于复用和分享。

config.yaml示例:

# config.yaml project: name: "CHIRPS_Data_Pipeline" paths: shapefile: "./data/boundary/study_area.shp" # 研究区矢量边界 download_dir: "./data/raw_downloads" # 原始压缩文件下载目录 extract_dir: "./data/unzipped_tifs" # 解压后的TIFF文件目录 output_dir: "./data/clipped_tifs" # 裁剪后的TIFF文件输出目录 log_file: "./logs/pipeline.log" # 日志文件路径 download: base_url_template: "https://data.chc.ucsb.edu/products/CHIRPS-2.0/global_daily/tifs/p05/{year}/chirps-v2.0.{year}.{month:02d}.{day:02d}.tif.gz" start_date: "2023-06-01" end_date: "2023-06-30" max_retries: 5 chunk_size_kb: 8192 # 单位KB clip: crop: true all_touched: false

主程序main.py

# main.py import yaml from datetime import datetime from pathlib import Path import logging from modules.download import generate_chirps_links, batch_download from modules.extract import batch_extract from modules.clip import batch_clip def setup_logging(log_file): """配置日志记录到文件和控制台""" Path(log_file).parent.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_file, encoding='utf-8'), logging.StreamHandler() ] ) def main(config_path='config.yaml'): # 1. 加载配置 with open(config_path, 'r', encoding='utf-8') as f: config = yaml.safe_load(f) paths = config['paths'] dl_cfg = config['download'] clip_cfg = config['clip'] # 2. 设置日志 setup_logging(paths['log_file']) logger = logging.getLogger(__name__) logger.info("="*50) logger.info(f"Starting CHIRPS Data Processing Pipeline: {config['project']['name']}") logger.info("="*50) # 3. 生成下载链接 start_date = datetime.strptime(dl_cfg['start_date'], '%Y-%m-%d') end_date = datetime.strptime(dl_cfg['end_date'], '%Y-%m-%d') url_list = generate_chirps_links(start_date, end_date, dl_cfg['base_url_template']) if not url_list: logger.error("No download links generated. Check date range and URL template.") return # 4. 批量下载 logger.info("Step 1: Batch Downloading...") downloaded_files = batch_download(url_list, paths['download_dir']) if not downloaded_files: logger.error("Download failed or no files downloaded. Exiting.") return # 5. 批量解压 logger.info("Step 2: Batch Extracting...") extracted_files = batch_extract(downloaded_files, paths['extract_dir']) if not extracted_files: logger.error("Extraction failed or no files extracted. Exiting.") return # 6. 批量裁剪 logger.info("Step 3: Batch Clipping...") clipped_files = batch_clip( extracted_files, paths['shapefile'], paths['output_dir'], clip_kwargs={'crop': clip_cfg['crop'], 'all_touched': clip_cfg['all_touched']} ) # 7. 完成 logger.info("="*50) logger.info("Pipeline finished.") logger.info(f" Downloaded: {len(downloaded_files)} files") logger.info(f" Extracted: {len(extracted_files)} files") logger.info(f" Clipped: {len(clipped_files)} files") logger.info(f" Output directory: {paths['output_dir']}") logger.info("="*50) if __name__ == '__main__': main()

这个主程序将各个模块串联起来,并通过配置文件管理所有参数,使得整个流程清晰、可配置、易于维护。你可以通过修改config.yaml文件来处理不同的时间范围、研究区域和输出设置。

5. 常见问题与排查技巧实录

在实际运行中,你几乎一定会遇到各种问题。下面是我在多次运行类似脚本后总结的“排坑指南”。

5.1 网络与下载问题

问题1:下载速度极慢或频繁中断。

  • 排查:首先检查网络连接。其次,CHIRPS数据服务器位于国外,国内直接访问可能不稳定。
  • 解决
    1. 增加重试次数和等待时间:将max_retries提高到5或10,并适当增加指数退避的基数。
    2. 使用会话(Session)requests.Session()可以复用TCP连接,对批量下载同一服务器的文件有性能提升。
    3. 考虑代理:如果条件允许且合规,配置网络代理可能改善连接稳定性。(注意:此处仅作技术可能性探讨,具体实施需符合当地法律法规和网络使用政策)。
    4. 分时段运行:在网络相对空闲的时段(如凌晨)运行脚本。

问题2:HTTP 403 Forbidden 或 404 Not Found 错误。

  • 排查:URL链接错误或服务器上文件不存在/权限变更。
  • 解决
    1. 手动验证URL:在浏览器中打开一个生成的链接,看是否能正常访问或下载。
    2. 检查日期范围:确认你请求的日期在CHIRPS数据发布范围内。CHIRPS数据通常有几天到一周的延迟。
    3. 检查URL模板:CHIRPS的URL结构偶尔会微调。去官网查看最新数据的下载链接,核对模板。

5.2 解压与文件处理问题

问题3:zipfile.BadZipFiletarfile.ReadError错误。

  • 排查:下载的文件不完整或已损坏。
  • 解决
    1. 删除损坏文件并重新下载:在download_file_with_retry函数中,我们已经在最终失败后删除了不完整文件。你可以手动删除报错的那个压缩包,重新运行脚本。
    2. 增加下载完整性校验:更严格的做法是在下载函数中计算下载文件的MD5或SHA256哈希值,与服务器提供的(如果有)或已知的正确值进行比对。

问题4:解压后找不到.tif文件。

  • 排查:压缩包内的文件结构可能与预期不符,或者解压到了子文件夹里。
  • 解决
    1. 修改extract_file函数:将只寻找第一个.tif文件的逻辑改为递归搜索整个解压目录,并返回找到的所有.tif文件列表。
    2. 手动检查:用解压软件手动打开一个出问题的压缩包,观察其内部结构,然后调整代码中的提取路径逻辑。

5.3 空间裁剪与GIS相关问题

问题5:裁剪后的图像全是nodata值,或者范围完全不对。

  • 排查:几乎肯定是坐标参考系统(CRS)不匹配。
  • 解决
    1. 打印CRS信息:在裁剪函数开头,添加代码打印输入栅格和矢量数据的CRS。
      with rasterio.open(raster_path) as src: print(f"Raster CRS: {src.crs}") print(f"Vector CRS: {gdf.crs}")
    2. 强制统一CRS:确保在裁剪前,矢量数据通过gdf.to_crs('EPSG:4326')转换到了WGS84(EPSG:4326)。如果栅格不是4326(CHIRPS是),则需要先将矢量转换到栅格的CRS。
    3. 检查几何图形有效性:有时矢量数据可能存在自相交等无效几何图形。可以用gdf.geometry.is_valid检查,并用gdf.geometry.buffer(0)进行简单修复。

问题6:裁剪过程内存占用过高,甚至导致程序崩溃。

  • 排查:研究区过大或原始CHIRPS数据分辨率高(全球0.05°),一次性读入内存的数组很大。
  • 解决
    1. 使用rasterio.windows:对于非常大的裁剪任务,可以考虑使用rasterio.windows.Window进行分块读取和写入,但这会显著增加代码复杂度。
    2. 升级硬件:增加系统内存是最直接的方法。
    3. 考虑使用专业GIS软件:对于超大规模的批量裁剪,像GDAL命令行工具(gdalwarpgdal_translate配合-cutline)可能更内存高效。你可以用Python的subprocess模块来调用这些命令。

问题7:输出文件体积异常大。

  • 排查:数据类型和压缩选项。
  • 解决:在clip_raster_with_shapefile函数的out_meta更新部分,添加压缩选项。
    out_meta.update({ "driver": "GTiff", "height": out_image.shape[1], "width": out_image.shape[2], "transform": out_transform, "nodata": src.nodata, "compress": "lzw", # 或 "deflate", "packbits" "predictor": 2, # 对于浮点型数据,配合lzw或deflate压缩效果更好 "tiled": True, # 创建分块存储,有利于大数据读取 "blockxsize": 256, "blockysize": 256 })
    "lzw"是一种无损压缩,通常能减少30%-70%的文件大小,且大多数GIS软件都支持读取。

5.4 性能优化建议

当处理长时间序列(如一整年甚至十年)的数据时,效率变得很重要。

  • 并行下载:可以使用concurrent.futures.ThreadPoolExecutor来并发下载多个文件。但务必谨慎:对同一服务器发起过多并发连接是不礼貌的,可能导致你的IP被暂时封锁。建议将最大并发数限制在3-5个。
  • 并行裁剪:裁剪是CPU密集型任务,适合使用multiprocessing.Pool进行多进程并行。每个进程处理一个栅格文件,可以充分利用多核CPU。注意将矢量数据读入每个子进程。
  • 缓存矢量数据:在批量裁剪函数batch_clip中,每次循环都读取一次矢量边界文件是低效的。应该在循环外读取一次,然后将几何对象传递给每个裁剪任务。

最后,记得良好的日志记录是你的最佳帮手。确保日志级别设置为INFO,并输出到文件,这样即使程序在后台运行,你也能随时查看进度和定位错误。这套自动化流程一旦跑通,以后任何需要CHIRPS数据的项目,你只需要修改配置文件中的日期和边界,然后泡杯咖啡,等待结果即可。