2026-06-06 08:38:19 +08:00
|
|
|
|
"""
|
|
|
|
|
|
暴雨灾害链预测接口
|
|
|
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
2026-06-06 13:18:25 +08:00
|
|
|
|
from datetime import datetime
|
2026-06-06 08:38:19 +08:00
|
|
|
|
from typing import List, Dict, Any, Optional
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
|
|
|
2026-06-28 09:26:13 +08:00
|
|
|
|
from app.schemas.api_schemas import (
|
|
|
|
|
|
RainfallPredictRequest, PredictResponse, PredictData,
|
|
|
|
|
|
UpdateMonitoringTimeRequest, DistrictSummaryRequest, DistrictSummaryItem
|
|
|
|
|
|
)
|
2026-06-06 08:38:19 +08:00
|
|
|
|
from app.utils.api_deps import get_rainfall_model, get_prediction_semaphore
|
|
|
|
|
|
from app.repositories.dbn_repository import dbn_repository
|
2026-06-28 09:26:13 +08:00
|
|
|
|
from app.repositories.rainfall_repository import rainfall_repository as rain_repo
|
2026-06-12 16:09:57 +08:00
|
|
|
|
from app.core.rainfall_manager import rainfall_manager
|
2026-06-06 08:38:19 +08:00
|
|
|
|
from app.config.paths import get_logger
|
2026-06-28 09:26:13 +08:00
|
|
|
|
from app.utils.db_helper import db_helper
|
2026-06-12 16:09:57 +08:00
|
|
|
|
from app.utils.time_converter import TimeConverter
|
2026-06-06 08:38:19 +08:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/rainfall", tags=["暴雨灾害链"])
|
|
|
|
|
|
logger = get_logger("api.rainfall")
|
|
|
|
|
|
|
|
|
|
|
|
SOURCE_TYPE_MAP = {1: "隐患点", 2: "风险点"}
|
|
|
|
|
|
LEVEL_MAP = {"低": "低", "中": "中", "较高": "较高", "高": "高"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 15:31:42 +08:00
|
|
|
|
def _build_prediction_map(results: List[Dict[str, Any]]) -> Dict[str, float]:
|
|
|
|
|
|
"""将模型原始结果转换为存储格式: {id_type: 概率百分比}"""
|
|
|
|
|
|
result_map = {}
|
2026-06-06 08:38:19 +08:00
|
|
|
|
for r in results:
|
|
|
|
|
|
probs = r.get("disaster_probabilities", {})
|
|
|
|
|
|
if not probs:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-14 15:31:42 +08:00
|
|
|
|
source_id = r["source_id"]
|
|
|
|
|
|
source_type = r.get("source_type")
|
2026-06-06 08:38:19 +08:00
|
|
|
|
max_hazard = max(probs, key=probs.get)
|
2026-06-14 15:31:42 +08:00
|
|
|
|
key = f"{source_id}_{source_type}"
|
|
|
|
|
|
result_map[key] = round(probs[max_hazard] * 100, 2)
|
|
|
|
|
|
return result_map
|
2026-06-06 08:38:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:50:03 +08:00
|
|
|
|
def _build_prediction_map_with_location(results: List[Dict[str, Any]], threshold: float = 50.0) -> Dict[str, Dict[str, Any]]:
|
2026-06-14 16:41:31 +08:00
|
|
|
|
"""将模型原始结果转换为返回格式: {id_type: {probability, lon, lat}}"""
|
2026-06-14 16:50:03 +08:00
|
|
|
|
from config import settings
|
|
|
|
|
|
threshold = getattr(settings, 'PREDICT_PROBABILITY_THRESHOLD', threshold)
|
2026-06-14 16:41:31 +08:00
|
|
|
|
result_map = {}
|
|
|
|
|
|
for r in results:
|
|
|
|
|
|
probs = r.get("disaster_probabilities", {})
|
|
|
|
|
|
if not probs:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
source_id = r["source_id"]
|
|
|
|
|
|
source_type = r.get("source_type")
|
|
|
|
|
|
max_hazard = max(probs, key=probs.get)
|
2026-06-14 16:50:03 +08:00
|
|
|
|
prob_value = round(probs[max_hazard] * 100, 2)
|
|
|
|
|
|
# 低于阈值不返回
|
|
|
|
|
|
if prob_value < threshold:
|
|
|
|
|
|
continue
|
2026-06-14 16:41:31 +08:00
|
|
|
|
key = f"{source_id}_{source_type}"
|
|
|
|
|
|
result_map[key] = {
|
2026-06-14 16:50:03 +08:00
|
|
|
|
"probability": prob_value,
|
2026-06-14 16:41:31 +08:00
|
|
|
|
"lon": r.get("lon"),
|
|
|
|
|
|
"lat": r.get("lat")
|
|
|
|
|
|
}
|
|
|
|
|
|
return result_map
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 08:38:19 +08:00
|
|
|
|
def _fetch_points(point_ids: Optional[List[int]], region_code: Optional[str]) -> List[Dict[str, Any]]:
|
|
|
|
|
|
"""获取点位列表"""
|
|
|
|
|
|
if point_ids:
|
|
|
|
|
|
return dbn_repository.get_points_by_ids(point_ids)
|
|
|
|
|
|
return dbn_repository.get_all_points(region_code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _predict_sync(point_ids: Optional[List[int]], region_code: Optional[str],
|
2026-06-06 13:18:25 +08:00
|
|
|
|
rainfall: Optional[float], duration: Optional[float],
|
2026-06-14 16:29:01 +08:00
|
|
|
|
operation_type: str, occurred_time: Optional[datetime] = None) -> tuple:
|
2026-06-06 13:18:25 +08:00
|
|
|
|
"""
|
|
|
|
|
|
同步执行暴雨预测(在线程池中运行)
|
|
|
|
|
|
|
2026-06-14 16:29:01 +08:00
|
|
|
|
Args:
|
|
|
|
|
|
occurred_time: 事件发生时间,用于查询降雨数据和DBN推理
|
|
|
|
|
|
|
2026-06-06 13:18:25 +08:00
|
|
|
|
Returns:
|
2026-06-14 16:41:31 +08:00
|
|
|
|
(存储用result_map, 返回用result_map_with_location, 传入的条件, 实际使用的事件时间)
|
2026-06-06 13:18:25 +08:00
|
|
|
|
"""
|
2026-06-06 08:38:19 +08:00
|
|
|
|
points = _fetch_points(point_ids, region_code)
|
|
|
|
|
|
if not points:
|
2026-06-14 16:41:31 +08:00
|
|
|
|
return {}, {}, {}, occurred_time or datetime.now()
|
2026-06-14 16:29:01 +08:00
|
|
|
|
|
2026-06-14 16:56:09 +08:00
|
|
|
|
# 使用传入的时间,如果没有传则使用 rainfall_manager 中的全局查询时间,最后才用当前时间
|
|
|
|
|
|
if occurred_time:
|
|
|
|
|
|
query_time = occurred_time
|
|
|
|
|
|
else:
|
|
|
|
|
|
from app.core.rainfall_manager import rainfall_manager
|
|
|
|
|
|
query_time = rainfall_manager.get_current_query_time() or datetime.now()
|
2026-06-06 08:38:19 +08:00
|
|
|
|
|
|
|
|
|
|
model = get_rainfall_model()
|
2026-06-14 16:29:01 +08:00
|
|
|
|
raw_results = model.predict_multiple_points(points, rainfall=rainfall, duration=duration, query_time=query_time)
|
2026-06-14 16:41:31 +08:00
|
|
|
|
result_map = _build_prediction_map(raw_results) # 用于存储
|
|
|
|
|
|
result_map_with_location = _build_prediction_map_with_location(raw_results) # 用于返回
|
2026-06-06 13:18:25 +08:00
|
|
|
|
|
2026-06-14 16:37:04 +08:00
|
|
|
|
# 存储传入的原始条件(降雨量和持续时间可能每个点不同,所以存储传入值)
|
2026-06-06 13:18:25 +08:00
|
|
|
|
condition = {
|
|
|
|
|
|
"point_ids": point_ids,
|
|
|
|
|
|
"region_code": region_code,
|
2026-06-14 16:37:04 +08:00
|
|
|
|
"rainfall": rainfall,
|
|
|
|
|
|
"duration": duration,
|
2026-06-14 16:29:01 +08:00
|
|
|
|
"occurred_time": query_time.isoformat() if hasattr(query_time, 'isoformat') else str(query_time)
|
2026-06-06 13:18:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:41:31 +08:00
|
|
|
|
return result_map, result_map_with_location, condition, query_time
|
2026-06-06 08:38:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 16:09:57 +08:00
|
|
|
|
@router.post("/update-monitoring-time", summary="更新降雨监测查询时间")
|
|
|
|
|
|
async def update_monitoring_time(req: UpdateMonitoringTimeRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
更新降雨站点监测的查询时间,触发重新计算
|
|
|
|
|
|
|
|
|
|
|
|
- **query_time**: 新的查询时间,格式: YYYY-MM-DD HH:mm:ss
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 将字符串时间解析为 datetime 对象
|
|
|
|
|
|
new_time = TimeConverter.parse_input_time(req.query_time)
|
|
|
|
|
|
|
|
|
|
|
|
# 更新监测时间,触发重新计算
|
|
|
|
|
|
result = rainfall_manager.update_query_time(new_time)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"更新监测时间成功: {result}")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"message": "success",
|
|
|
|
|
|
"data": result
|
|
|
|
|
|
}
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
logger.error(f"时间格式错误: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"时间格式错误: {e}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"更新监测时间失败: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"更新监测时间失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 08:38:19 +08:00
|
|
|
|
@router.post("/predict", response_model=PredictResponse, summary="暴雨灾害链预测")
|
|
|
|
|
|
async def predict_rainfall(req: RainfallPredictRequest):
|
|
|
|
|
|
"""
|
2026-06-28 09:26:13 +08:00
|
|
|
|
批量预测隐患点/风险点的灾害概率。两种模式(二选一):
|
|
|
|
|
|
|
|
|
|
|
|
**自动推演模式**:rainfall、duration、region_code 全部不传
|
|
|
|
|
|
→ 从气象表自动获取各点最近站降雨数据,不限区域
|
|
|
|
|
|
|
|
|
|
|
|
**指定条件模式**:rainfall、duration、region_code 全部传入
|
|
|
|
|
|
→ 按指定降雨条件和区域预测
|
2026-06-06 08:38:19 +08:00
|
|
|
|
|
2026-06-14 14:38:20 +08:00
|
|
|
|
- **disaster_name**: 灾害名称
|
2026-06-28 09:26:13 +08:00
|
|
|
|
- **point_ids**: 点位ID列表(可选)
|
|
|
|
|
|
- **occurred_time**: 事件发生时间(可选,不传则为当前时间)
|
2026-06-06 13:18:25 +08:00
|
|
|
|
- **operation_type**: 操作类型(如 '实时监测', '情景模拟', '应急评估')
|
2026-06-06 08:38:19 +08:00
|
|
|
|
"""
|
|
|
|
|
|
semaphore = get_prediction_semaphore()
|
|
|
|
|
|
|
|
|
|
|
|
async with semaphore:
|
|
|
|
|
|
loop = asyncio.get_event_loop()
|
|
|
|
|
|
try:
|
2026-06-14 16:41:31 +08:00
|
|
|
|
result_map, result_map_with_location, condition, occurred_time = await loop.run_in_executor(
|
2026-06-06 08:38:19 +08:00
|
|
|
|
None, _predict_sync, req.point_ids, req.region_code,
|
2026-06-14 16:29:01 +08:00
|
|
|
|
req.rainfall, req.duration, req.operation_type, req.occurred_time
|
2026-06-06 08:38:19 +08:00
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"暴雨预测失败: {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"预测失败: {e}")
|
|
|
|
|
|
|
2026-06-06 13:18:25 +08:00
|
|
|
|
# 保存推理结果
|
|
|
|
|
|
record_id = None
|
2026-06-14 15:31:42 +08:00
|
|
|
|
if result_map:
|
2026-06-06 13:18:25 +08:00
|
|
|
|
try:
|
|
|
|
|
|
record_id = dbn_repository.save_inference_result(
|
2026-06-14 14:38:20 +08:00
|
|
|
|
disaster_name=req.disaster_name,
|
2026-06-06 13:18:25 +08:00
|
|
|
|
event_type="rainfall",
|
2026-06-14 16:29:01 +08:00
|
|
|
|
occurred_time=occurred_time,
|
2026-06-06 13:18:25 +08:00
|
|
|
|
operation_type=req.operation_type,
|
|
|
|
|
|
condition=condition,
|
2026-06-14 15:31:42 +08:00
|
|
|
|
result=result_map
|
2026-06-06 13:18:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"推理结果已保存,record_id={record_id}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"保存推理结果失败: {e}", exc_info=True)
|
|
|
|
|
|
|
2026-06-14 16:41:31 +08:00
|
|
|
|
return PredictResponse(code=200, message="success", data=PredictData(record_id=record_id, list=result_map_with_location))
|
2026-06-28 09:26:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/district-summary", summary="获取各区降雨概况")
|
|
|
|
|
|
async def district_summary(req: DistrictSummaryRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
根据推理结果ID获取各行政区的降雨概况(用于报告生成)。
|
|
|
|
|
|
|
|
|
|
|
|
逻辑:
|
|
|
|
|
|
- 如果预测时传了 rainfall → 直接用 condition 中的值
|
|
|
|
|
|
- 如果预测时没传 rainfall → 从 xian_meteorology 按区聚合实际气象数据
|
|
|
|
|
|
|
|
|
|
|
|
- **inference_id**: 推理结果ID
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 1. 查推理结果
|
|
|
|
|
|
record = dbn_repository.get_inference_result(req.inference_id)
|
|
|
|
|
|
if not record:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"推理结果不存在: {req.inference_id}")
|
|
|
|
|
|
|
|
|
|
|
|
condition = record.get('condition') or {}
|
|
|
|
|
|
occurred_time = record.get('occurred_time')
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 如果传了 rainfall → 直接用 condition 中的值
|
|
|
|
|
|
if condition.get('rainfall') is not None:
|
|
|
|
|
|
region_code = condition.get('region_code')
|
|
|
|
|
|
rainfall_val = float(condition['rainfall'])
|
|
|
|
|
|
duration_val = float(condition.get('duration', 0))
|
|
|
|
|
|
|
|
|
|
|
|
if region_code:
|
|
|
|
|
|
# 查 district 名称
|
|
|
|
|
|
district_row = db_helper.execute_query_one(
|
|
|
|
|
|
"SELECT name FROM xian_district WHERE code = %s AND is_delete = 0", (region_code,)
|
|
|
|
|
|
)
|
|
|
|
|
|
district_name = district_row['name'] if district_row else region_code
|
|
|
|
|
|
items = [{
|
|
|
|
|
|
'district_name': district_name,
|
|
|
|
|
|
'district_code': region_code,
|
|
|
|
|
|
'rainfall': rainfall_val,
|
|
|
|
|
|
'duration_hours': duration_val
|
|
|
|
|
|
}]
|
|
|
|
|
|
else:
|
|
|
|
|
|
items = [{
|
|
|
|
|
|
'district_name': '全市',
|
|
|
|
|
|
'district_code': '',
|
|
|
|
|
|
'rainfall': rainfall_val,
|
|
|
|
|
|
'duration_hours': duration_val
|
|
|
|
|
|
}]
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 3. 自动推演模式 → 从气象表按区聚合
|
|
|
|
|
|
items = rain_repo.get_district_rainfall_summary(occurred_time)
|
|
|
|
|
|
|
|
|
|
|
|
if not items:
|
|
|
|
|
|
return {"code": 200, "message": "success", "data": []}
|
|
|
|
|
|
|
|
|
|
|
|
# 转换为响应格式
|
|
|
|
|
|
result_items = [
|
|
|
|
|
|
DistrictSummaryItem(**item).model_dump()
|
|
|
|
|
|
for item in items
|
|
|
|
|
|
]
|
|
|
|
|
|
return {"code": 200, "message": "success", "data": result_items}
|