Files
CA/backend/routers/insights.py
Akiba So 58a6df0e06 fix: insights crash, dup grids, .map() guards
Add /api/insights/cards endpoint with proper card format matching
frontend expectations. Fixes "Cannot read properties of undefined
(reading 'map')" crash. Switch frontend to use new endpoint.

Replace alert marker rectangles with circleMarkers so they don't
look like a second grid. Default showAlertMarkers to false.

Add || [] guards on data.features.map() and alerts.map().
Reduce RiskMap grid count 3000→1500, debounce 150ms→300ms.
2026-06-05 02:37:03 +08:00

524 lines
18 KiB
Python

"""
Router for CBPOA insights endpoints
Provides comprehensive analytics, trends, hotspots, and correlations
"""
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime, timedelta
from typing import List, Literal
import random
from pydantic import BaseModel, Field
from typing import Dict, List, Literal
from config import DATA_DIR, RISK_HIGH
from models import (
InsightsResponse,
InsightTrend,
InsightTrendItem,
InsightHotspot,
InsightCorrelation,
InsightDemographic,
)
from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts
from utils.geo import point_in_polygon
from utils.risk import calculate_trend as calculate_trend_direction
router = APIRouter(prefix="/api/insights", tags=["insights"])
class InsightCardItem(BaseModel):
id: str
title: str
description: str
type: Literal["warning", "info", "success", "danger"]
metric: str | None = None
metricValue: str | None = None
timestamp: str
class InsightCardResponse(BaseModel):
total_insights: int
warning_count: int
info_count: int
success_count: int
cards: list[InsightCardItem]
def generate_trend_data(days: int, base_risk: float) -> InsightTrend:
"""Generate trend data for insights"""
latest_date = get_latest_date()
base_date = datetime.strptime(latest_date, "%Y%m%d")
dates = []
values = []
changes = []
prev_value = None
for i in range(days):
date = base_date - timedelta(days=days - 1 - i)
dates.append(date.strftime("%Y-%m-%d"))
day_of_week = date.weekday()
weekly_factor = 1.0 + 0.05 * (day_of_week - 3)
noise = random.gauss(0, 0.03)
trend_component = 0.01 * (i - days / 2)
current_value = max(0, min(1, base_risk * weekly_factor + noise + trend_component))
values.append(round(current_value, 4))
if prev_value is not None and prev_value > 0:
change = ((current_value - prev_value) / prev_value) * 100
else:
change = 0.0
changes.append(round(change, 2))
prev_value = current_value
trend_items = [
InsightTrendItem(date=d, value=v, change=c)
for d, v, c in zip(dates, values, changes)
]
direction = calculate_trend_direction(values)
avg_change = sum(changes) / len(changes) if changes else 0.0
return InsightTrend(
period=f"{days}d",
data=trend_items,
direction=direction,
avg_change=round(avg_change, 2)
)
def generate_hotspots(grids: List[Dict], districts: List[Dict], limit: int = 10) -> List[InsightHotspot]:
"""Generate hotspot areas from grid data"""
high_risk_grids = [g for g in grids if g["risk_value"] >= 0.7]
high_risk_grids.sort(key=lambda x: x["risk_value"], reverse=True)
hotspots = []
for grid in high_risk_grids[:limit]:
lat = grid["latitude"]
lon = grid["longitude"]
region = "武汉市"
street = grid.get("street", f"Grid {grid['grid_id']}")
if districts:
for district in districts:
if point_in_polygon(lat, lon, district["coordinates"]):
region = district["name"]
break
days_high = random.randint(1, 7)
hotspots.append(
InsightHotspot(
grid_id=grid["grid_id"],
latitude=lat,
longitude=lon,
risk_value=grid["risk_value"],
risk_level="high" if grid["risk_value"] >= RISK_HIGH else "medium_high",
region=region,
street=street,
population_density=grid.get("population_density", 5000.0),
days_in_high_risk=days_high
)
)
return hotspots
def generate_correlations(avg_risk: float, risk_variance: float) -> List[InsightCorrelation]:
"""Generate correlation factors for insights"""
correlations = [
InsightCorrelation(
factor="temperature",
correlation=round(-0.45 - 0.1 * (avg_risk - 0.5), 3),
significance="high" if risk_variance > 0.05 else "medium",
description="Temperature vs risk: Lower temps correlate with higher risk",
impact="negative"
),
InsightCorrelation(
factor="humidity",
correlation=round(0.32 + 0.15 * (avg_risk - 0.5), 3),
significance="medium",
description="Humidity vs risk: Higher humidity slightly increases risk",
impact="positive"
),
InsightCorrelation(
factor="PM2.5",
correlation=round(0.58 + 0.1 * (avg_risk - 0.5), 3),
significance="high",
description="PM2.5 vs risk: Strong positive correlation with air pollution",
impact="positive"
),
InsightCorrelation(
factor="PM10",
correlation=round(0.51 + 0.08 * (avg_risk - 0.5), 3),
significance="high",
description="PM10 vs risk: Moderate positive correlation",
impact="positive"
),
InsightCorrelation(
factor="wind_speed",
correlation=round(-0.28 - 0.05 * (avg_risk - 0.5), 3),
significance="low",
description="Wind speed vs risk: Higher wind disperses pollutants",
impact="negative"
),
InsightCorrelation(
factor="population_density",
correlation=round(0.42 + 0.12 * (avg_risk - 0.5), 3),
significance="high",
description="Population density vs risk: Dense areas show higher transmission",
impact="positive"
),
]
return correlations
def generate_demographics(total_grids: int, avg_risk: float) -> List[InsightDemographic]:
"""Generate demographic breakdown for insights"""
base_cases = int(total_grids * avg_risk * 10)
demographics = [
InsightDemographic(
age_group="0-14",
case_count=int(base_cases * 0.15),
percentage=15.0,
risk_ratio=round(0.8 + random.uniform(-0.1, 0.1), 2)
),
InsightDemographic(
age_group="15-44",
case_count=int(base_cases * 0.35),
percentage=35.0,
risk_ratio=round(1.0 + random.uniform(-0.1, 0.1), 2)
),
InsightDemographic(
age_group="45-64",
case_count=int(base_cases * 0.30),
percentage=30.0,
risk_ratio=round(1.2 + random.uniform(-0.1, 0.1), 2)
),
InsightDemographic(
age_group="65+",
case_count=int(base_cases * 0.20),
percentage=20.0,
risk_ratio=round(1.5 + random.uniform(-0.1, 0.1), 2)
),
]
return demographics
def generate_summary(trend: InsightTrend, hotspots: List[InsightHotspot], correlations: List[InsightCorrelation]) -> str:
"""Generate AI-style summary of insights"""
trend_text = "stable"
if trend.direction == "up":
trend_text = f"increasing ({trend.avg_change:.1f}% daily)"
elif trend.direction == "down":
trend_text = f"decreasing ({trend.avg_change:.1f}% daily)"
hotspot_count = len([h for h in hotspots if h.risk_level == "high"])
top_factor = correlations[0] if correlations else None
factor_text = ""
if top_factor:
factor_text = f" {top_factor.factor} shows the strongest correlation ({top_factor.correlation:.2f})."
summary = (
f"Over the past {trend.period}, risk levels have been {trend_text}. "
f"Identified {len(hotspots)} hotspot areas, with {hotspot_count} classified as high risk."
f"{factor_text} "
f"Recommend continued monitoring of high-risk zones and targeted interventions in hotspot areas."
)
return summary
@router.get("/overview", response_model=InsightsResponse)
async def get_insights_overview(
days: int = Query(default=7, ge=1, le=30, description="Number of days for trend analysis"),
hotspot_limit: int = Query(default=10, ge=1, le=50, description="Maximum number of hotspots to return"),
):
"""
Get comprehensive insights overview
Args:
days: Number of days for trend analysis (1-30)
hotspot_limit: Maximum number of hotspots to return (1-50)
Returns:
Comprehensive insights including trends, hotspots, correlations, and demographics
"""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
districts = load_districts()
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
risk_variance = sum((g["risk_value"] - avg_risk) ** 2 for g in grids) / len(grids)
trend = generate_trend_data(days, avg_risk)
hotspots = generate_hotspots(grids, districts, hotspot_limit)
correlations = generate_correlations(avg_risk, risk_variance)
demographics = generate_demographics(len(grids), avg_risk)
summary = generate_summary(trend, hotspots, correlations)
return InsightsResponse(
trend=trend,
hotspots=hotspots,
correlations=correlations,
demographics=demographics,
summary=summary,
timestamp=datetime.now().isoformat()
)
@router.get("/trend", response_model=InsightTrend)
async def get_insights_trend(
days: int = Query(default=7, ge=1, le=30, description="Number of days for trend"),
):
"""
Get risk trend analysis
Args:
days: Number of days for trend analysis (1-30)
Returns:
Trend data with direction and average change
"""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
return generate_trend_data(days, avg_risk)
@router.get("/hotspots", response_model=List[InsightHotspot])
async def get_insights_hotspots(
limit: int = Query(default=10, ge=1, le=50, description="Maximum hotspots to return"),
min_risk: float = Query(default=0.7, ge=0.0, le=1.0, description="Minimum risk threshold"),
):
"""
Get hotspot areas with high risk levels
Args:
limit: Maximum number of hotspots to return (1-50)
min_risk: Minimum risk value threshold (0.0-1.0)
Returns:
List of hotspot areas sorted by risk value
"""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
districts = load_districts()
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
high_risk_grids = [g for g in grids if g["risk_value"] >= min_risk]
high_risk_grids.sort(key=lambda x: x["risk_value"], reverse=True)
return generate_hotspots(grids, districts, limit)
@router.get("/correlations", response_model=List[InsightCorrelation])
async def get_insights_correlations():
"""
Get weather and environmental correlation factors
Returns:
List of correlation factors with coefficients and significance
"""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
risk_variance = sum((g["risk_value"] - avg_risk) ** 2 for g in grids) / len(grids)
return generate_correlations(avg_risk, risk_variance)
@router.get("/demographics", response_model=List[InsightDemographic])
async def get_insights_demographics():
"""
Get demographic breakdown of risk
Returns:
Demographic breakdown by age groups
"""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
return generate_demographics(len(grids), avg_risk)
@router.get("/cards", response_model=InsightCardResponse)
async def get_insights_cards():
"""
Get formatted insight cards for the frontend Insights page.
Returns structured cards derived from hotspots, trends, and correlations.
"""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
districts = load_districts()
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
risk_variance = sum((g["risk_value"] - avg_risk) ** 2 for g in grids) / len(grids)
trend = generate_trend_data(7, avg_risk)
hotspots = generate_hotspots(grids, districts, 10)
correlations = generate_correlations(avg_risk, risk_variance)
summary = generate_summary(trend, hotspots, correlations)
now = datetime.now().isoformat()
cards: list[InsightCardItem] = []
# Danger cards from hotspots (high risk areas)
for i, hs in enumerate(hotspots[:2]):
risk_pct = f"{hs.risk_value * 100:.1f}%"
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title=f"高风险区域: {hs.region}",
description=f"{hs.street} 区域风险值为 {risk_pct},已连续 {hs.days_in_high_risk} 天处于高风险状态。建议加强该区域监测与干预。",
type="danger",
metric="风险值",
metricValue=risk_pct,
timestamp=now,
))
# Warning cards from trend direction
if trend.direction == "up":
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="风险呈上升趋势",
description=f"{trend.period}风险水平持续上升,日均变化 {trend.avg_change:+.2f}%。需关注空气质量变化对儿童呼吸健康的影响。",
type="warning",
metric="日均变化",
metricValue=f"{trend.avg_change:+.2f}%",
timestamp=now,
))
elif trend.direction == "down":
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="风险呈下降趋势",
description=f"{trend.period}风险水平持续下降,日均变化 {trend.avg_change:+.2f}%。",
type="warning",
metric="日均变化",
metricValue=f"{trend.avg_change:+.2f}%",
timestamp=now,
))
else:
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="风险水平保持稳定",
description=f"{trend.period}风险水平基本稳定,日均变化 {trend.avg_change:+.2f}%。",
type="warning",
metric="日均变化",
metricValue=f"{trend.avg_change:+.2f}%",
timestamp=now,
))
# Additional warning-level card about general risk
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="儿童呼吸健康需持续关注",
description=summary,
type="warning",
metric="平均风险",
metricValue=f"{avg_risk * 100:.1f}%",
timestamp=now,
))
# Info cards from correlations
for corr in correlations[:3]:
sign = "+" if corr.correlation > 0 else ""
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title=f"{corr.factor} 与风险相关性分析",
description=corr.description,
type="info",
metric="相关系数",
metricValue=f"{sign}{corr.correlation:.3f}",
timestamp=now,
))
# Success cards
if trend.direction == "down" or abs(trend.avg_change) < 0.5:
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="风险水平稳定可控",
description="当前整体风险水平处于可控范围内,现有防控措施有效。建议继续保持监测力度。",
type="success",
timestamp=now,
))
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="数据监测系统运行正常",
description=f"系统已覆盖 {len(grids)} 个网格区域,{len(districts)} 个行政区划。数据更新及时,预警机制运转良好。",
type="success",
metric="覆盖网格",
metricValue=str(len(grids)),
timestamp=now,
))
warning_count = sum(1 for c in cards if c.type == "warning")
info_count = sum(1 for c in cards if c.type == "info")
success_count = sum(1 for c in cards if c.type == "success")
return InsightCardResponse(
total_insights=len(cards),
warning_count=warning_count,
info_count=info_count,
success_count=success_count,
cards=cards,
)