diff --git a/src/gasflux/qiya.py b/src/gasflux/qiya.py index ba5fed1..790d8d5 100644 --- a/src/gasflux/qiya.py +++ b/src/gasflux/qiya.py @@ -1,11 +1,158 @@ import requests import time -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry +import logging +from functools import lru_cache + +logger = logging.getLogger(__name__) + +# 全局 Session 复用 TCP 连接,避免 SSL 握手失败 +_session = None + + +def _get_session(): + """获取或创建持久 HTTP Session(复用连接)。""" + global _session + if _session is None: + _session = requests.Session() + # 设置适配器,增大连接池 + from requests.adapters import HTTPAdapter + adapter = HTTPAdapter(pool_connections=5, pool_maxsize=10, max_retries=0) + _session.mount("https://", adapter) + _session.mount("http://", adapter) + return _session + + +def _exponential_backoff_request(url, params, max_retries=4, base_timeout=45): + """ + 带指数退避的 HTTP GET 请求。 + + Args: + url: 请求 URL + params: 查询参数 + max_retries: 最大重试次数(含首次尝试) + base_timeout: 基础超时时间(秒) + + Returns: + requests.Response 对象 + + Raises: + requests.RequestException: 所有重试失败后抛出 + """ + session = _get_session() + last_exception = None + + for attempt in range(max_retries): + try: + # 每次重试增加超时时间(45, 60, 75, 90) + timeout = base_timeout + attempt * 15 + response = session.get(url, params=params, timeout=timeout) + response.raise_for_status() + return response + + except (requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.SSLError) as e: + last_exception = e + if attempt < max_retries - 1: + wait = 2 ** attempt # 1s, 2s, 4s, 8s + logger.warning( + f"Open-Meteo 请求失败 (第{attempt+1}次): {type(e).__name__}. " + f"{wait}秒后重试... URL: {url} params: {params}" + ) + time.sleep(wait) + else: + logger.error( + f"Open-Meteo 请求最终失败 ({max_retries}次尝试): {type(e).__name__}: {e}" + ) + + except requests.exceptions.HTTPError as e: + # HTTP 错误不重试(如 404, 400 等) + logger.error(f"Open-Meteo HTTP错误: {e}") + raise + + raise last_exception + + +# 缓存:相同 (lat, lon, date, time) 的请求结果,避免重复 API 调用 +# maxsize=256 足够缓存一次飞行的所有档位 +@lru_cache(maxsize=256) +def _get_pressure_cached(lat: float, lon: float, altitude: float, date: str, time: str) -> float | None: + """ + 带缓存的单次气压查询(内部函数)。 + 坐标四舍五入到小数点后 4 位(~11m精度)以提高缓存命中率。 + """ + # 坐标取 4 位小数作为缓存键(同一档位内坐标几乎相同) + cache_lat = round(lat, 4) + cache_lon = round(lon, 4) + cache_alt = round(altitude, 1) # 高度取1位小数 + return _get_pressure_impl(cache_lat, cache_lon, cache_alt, date, time) + + +def _get_pressure_impl(lat: float, lon: float, altitude: float, date: str, time: str) -> float | None: + """实际的 Open-Meteo API 调用(无缓存)。""" + url = "https://archive-api.open-meteo.com/v1/archive" + + params = { + "latitude": lat, + "longitude": lon, + "start_date": date, + "end_date": date, + "hourly": ["pressure_msl", "surface_pressure"], + "timezone": "auto", + } + + try: + logger.info(f"正在获取位置 ({lat:.6f}, {lon:.6f}) 在 {date} {time} 的气压数据...") + response = _exponential_backoff_request(url, params) + + data = response.json() + + if "error" in data: + logger.error(f"Open-Meteo API错误: {data['error']}") + return None + + if data and "hourly" in data: + times = data["hourly"]["time"] + pressures = data["hourly"]["surface_pressure"] + + if not times or not pressures: + logger.warning("未找到气压数据(times 或 pressures 为空)") + return None + + target_time = f"{date}T{time}" + if target_time in times: + idx = times.index(target_time) + pressure = pressures[idx] + logger.info(f"成功获取气压: {pressure} hPa") + return pressure + else: + logger.warning(f"在数据中未找到时间: {target_time},可用范围: {times[0]} 到 {times[-1]}") + # 回退:取最近的小时 + try: + closest = min(times, key=lambda t: abs( + (int(t.split("T")[1].split(":")[0]) if "T" in t else 0) - + (int(time.split(":")[0])) + )) + idx = times.index(closest) + pressure = pressures[idx] + logger.info(f"使用最近时间 {closest} 的气压: {pressure} hPa") + return pressure + except Exception: + return None + else: + logger.warning("API响应中没有hourly数据") + return None + + except requests.exceptions.HTTPError: + return None + except Exception as e: + logger.error(f"获取气压时发生未知错误: {type(e).__name__}: {e}") + return None + def get_pressure_at_location(lat, lon, altitude, date, time, max_retries=3, timeout=30): """ - 获取指定位置、时间、高度的气压 + 获取指定位置、时间、高度的气压(公共接口,保持向后兼容)。 Args: lat: 纬度 @@ -13,8 +160,8 @@ def get_pressure_at_location(lat, lon, altitude, date, time, max_retries=3, time altitude: 海拔高度 (米) date: 日期 (格式: YYYY-MM-DD 或 YYYY/MM/DD) time: 时间 (格式: HH:MM 或 HH:MM:SS) - max_retries: 最大重试次数 - timeout: 请求超时时间(秒) + max_retries: 最大重试次数(已废弃,由内部指数退避处理) + timeout: 请求超时时间(秒)(已废弃,由内部控制) Returns: float: 气压值 (hPa),获取失败返回 None @@ -22,130 +169,42 @@ def get_pressure_at_location(lat, lon, altitude, date, time, max_retries=3, time # 标准化日期格式为 YYYY-MM-DD def normalize_date(d): - """将各种日期格式标准化为 YYYY-MM-DD""" if not d: from datetime import datetime return datetime.now().strftime("%Y-%m-%d") - - # 处理斜杠分隔符 if "/" in d: d = d.replace("/", "-") - parts = d.split("-") if len(parts) == 3: - year = parts[0] - month = parts[1].zfill(2) # 确保月份是两位数 - day = parts[2].zfill(2) # 确保日期是两位数 - return f"{year}-{month}-{day}" - else: - # 如果格式不正确,返回今天的日期 - from datetime import datetime - return datetime.now().strftime("%Y-%m-%d") + return f"{parts[0]}-{parts[1].zfill(2)}-{parts[2].zfill(2)}" + from datetime import datetime + return datetime.now().strftime("%Y-%m-%d") date = normalize_date(date) # 标准化时间格式为 HH:MM def normalize_time(t): - """将各种时间格式标准化为 HH:MM""" if not t or ":" not in t: - return "12:00" # 默认中午12点 - + return "12:00" parts = t.split(":") if len(parts) >= 2: - hour = parts[0].zfill(2) # 确保小时是两位数 - minute = parts[1].zfill(2) # 确保分钟是两位数 - return f"{hour}:{minute}" - elif len(parts) == 1: - hour = parts[0].zfill(2) - return f"{hour}:00" - else: - return "12:00" + return f"{parts[0].zfill(2)}:{parts[1].zfill(2)}" + return "12:00" time = normalize_time(time) - url = "https://archive-api.open-meteo.com/v1/archive" - - params = { - "latitude": lat, - "longitude": lon, - "start_date": date, # 格式: YYYY-MM-DD - "end_date": date, - "hourly": ["pressure_msl", "surface_pressure"], - "timezone": "auto" - } - - # 创建带有重试机制的会话 - session = requests.Session() - retry_strategy = Retry( - total=max_retries, - status_forcelist=[429, 500, 502, 503, 504], - backoff_factor=1 - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - session.mount("http://", adapter) - session.mount("https://", adapter) - try: - print(f"正在获取位置 ({lat:.6f}, {lon:.6f}) 在 {date} {time} 的气压数据...") - response = session.get(url, params=params, timeout=timeout) - - # 检查响应状态 - response.raise_for_status() - - data = response.json() - - # 检查API错误 - if "error" in data: - print(f"API错误: {data['error']}") - return None - - # 解析气压数据 - if data and "hourly" in data: - times = data["hourly"]["time"] - pressures = data["hourly"]["surface_pressure"] # 地表气压 - - if not times or not pressures: - print("未找到气压数据") - return None - - # 根据时间找到对应气压 - target_time = f"{date}T{time}" - if target_time in times: - idx = times.index(target_time) - pressure = pressures[idx] - print(f"成功获取气压: {pressure} hPa") - return pressure - else: - print(f"在数据中未找到时间: {target_time}") - print(f"可用时间范围: {times[0]} 到 {times[-1]}") - return None - else: - print("API响应中没有hourly数据") - return None - - except requests.exceptions.ConnectionError as e: - print(f"网络连接错误: {e}") - print("请检查网络连接或稍后重试") - return None - except requests.exceptions.Timeout as e: - print(f"请求超时: {e}") - print(f"已重试 {max_retries} 次,请检查网络连接") - return None - except requests.exceptions.HTTPError as e: - print(f"HTTP错误: {e}") - return None - except ValueError as e: - print(f"数据解析错误: {e}") - return None + return _get_pressure_cached( + float(lat), float(lon), float(altitude), date, time + ) except Exception as e: - print(f"未知错误: {e}") + logger.error(f"get_pressure_at_location 失败: {type(e).__name__}: {e}") return None - finally: - session.close() + def batch_get_pressure(data_list): """ - 批量获取多个位置的气压数据 + 批量获取多个位置的气压数据。 Args: data_list: 包含 (lat, lon, altitude, date, time) 元组的列表 @@ -154,37 +213,36 @@ def batch_get_pressure(data_list): list: 气压值列表 """ results = [] - for i, (lat, lon, alt, date, time) in enumerate(data_list): + for i, (lat, lon, alt, date, time_val) in enumerate(data_list): print(f"\n处理第 {i+1} 个位置...") - pressure = get_pressure_at_location(lat, lon, alt, date, time) + pressure = get_pressure_at_location(lat, lon, alt, date, time_val) results.append(pressure) if pressure is not None: print(f"位置 {i+1}: {pressure} hPa") else: print(f"位置 {i+1}: 获取失败") - # 添加短暂延迟,避免请求过于频繁 + # 短暂延迟避免请求过于频繁 if i < len(data_list) - 1: - time.sleep(0.5) + time.sleep(0.3) return results + # 使用示例 if __name__ == "__main__": print("=== 气压数据获取工具 ===\n") - # 单个位置示例 print("1. 单个位置查询:") pressure = get_pressure_at_location( - lat=40.3491370, # 纽约纬度 - lon=115.7855289, # 纽约经度 (西经) - altitude=435.789, # 海拔10米 + lat=40.3491370, + lon=115.7855289, + altitude=435.789, date="2016-02-12", - time="08:00" # HH:MM格式 + time="08:00" ) if pressure is not None: print(f"纽约当前气压: {pressure} hPa") else: print("获取纽约气压数据失败") -