feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统

Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.

Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.

Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
  geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
  monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
  export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
  feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review

Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
This commit is contained in:
2026-06-05 02:13:49 +08:00
commit fc468464b2
117 changed files with 18282 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
"""
Generate 100x100m grid summary from geocoded case data
Uses EPSG:4326 coordinates (degrees) directly
"""
import pandas as pd
import numpy as np
from pathlib import Path
from shapely.geometry import Point, box
from shapely.ops import unary_union
import warnings
warnings.filterwarnings('ignore')
# Paths
OUTPUT_DIR = Path("/home/akiba/CA/outputs")
INPUT_FILE = OUTPUT_DIR / "geocoded_all_cases.csv"
OUTPUT_FILE = OUTPUT_DIR / "grid_risk_summary.csv"
# Wuhan bounding box (EPSG:4326 degrees)
WUHAN_BOUNDS = {
'min_lat': 29.9,
'max_lat': 31.4,
'min_lon': 113.6,
'max_lon': 115.1
}
# Grid resolution: 100m in degrees at Wuhan latitude (~30.5°)
# 1 degree latitude ≈ 111 km
# 1 degree longitude ≈ 111 km * cos(latitude)
GRID_SIZE_LAT = 0.0009 # ~100m latitude
GRID_SIZE_LON = 0.0010 # ~100m longitude at 30.5° latitude
def filter_valid_coordinates(df: pd.DataFrame) -> pd.DataFrame:
"""Filter out invalid coordinates"""
# Remove null coordinates
df = df.dropna(subset=['latitude', 'longitude'])
# Filter valid Wuhan bounds
df = df[
(df['latitude'] >= WUHAN_BOUNDS['min_lat']) &
(df['latitude'] <= WUHAN_BOUNDS['max_lat']) &
(df['longitude'] >= WUHAN_BOUNDS['min_lon']) &
(df['longitude'] <= WUHAN_BOUNDS['max_lon'])
]
# Filter swapped coordinates (lat > 50 or lon > 120 indicates swap)
df = df[
(df['latitude'] < 50) &
(df['longitude'] < 120)
]
return df
def create_grid() -> pd.DataFrame:
"""Create 100x100m grid over Wuhan area"""
grids = []
grid_id = 0
lat_min = WUHAN_BOUNDS['min_lat']
lat_max = WUHAN_BOUNDS['max_lat']
lon_min = WUHAN_BOUNDS['min_lon']
lon_max = WUHAN_BOUNDS['max_lon']
lat = lat_min
while lat < lat_max:
lon = lon_min
while lon < lon_max:
center_y = lat + GRID_SIZE_LAT / 2
center_x = lon + GRID_SIZE_LON / 2
grids.append({
'grid_id': grid_id,
'center_y': round(center_y, 6),
'center_x': round(center_x, 6),
'lat_min': lat,
'lat_max': lat + GRID_SIZE_LAT,
'lon_min': lon,
'lon_max': lon + GRID_SIZE_LON
})
grid_id += 1
lon += GRID_SIZE_LON
lat += GRID_SIZE_LAT
return pd.DataFrame(grids)
def aggregate_cases_to_grid(cases_df: pd.DataFrame, grid_df: pd.DataFrame) -> pd.DataFrame:
"""Aggregate cases to grid cells"""
# Assign each case to a grid cell
cases_df['grid_lat_idx'] = ((cases_df['latitude'] - WUHAN_BOUNDS['min_lat']) / GRID_SIZE_LAT).astype(int)
cases_df['grid_lon_idx'] = ((cases_df['longitude'] - WUHAN_BOUNDS['min_lon']) / GRID_SIZE_LON).astype(int)
cases_df['grid_id'] = cases_df['grid_lat_idx'] * int((WUHAN_BOUNDS['max_lon'] - WUHAN_BOUNDS['min_lon']) / GRID_SIZE_LON) + cases_df['grid_lon_idx']
# Aggregate by grid
grid_stats = cases_df.groupby('grid_id').agg(
total_cases=('case_id', 'count'),
outpatient_cases=('case_type', lambda x: (x == 'outpatient').sum()),
inpatient_cases=('case_type', lambda x: (x == 'inpatient').sum())
).reset_index()
# Merge with grid geometry
result = grid_df.merge(grid_stats, on='grid_id', how='left')
# Fill NaN with 0 for grids with no cases
result['total_cases'] = result['total_cases'].fillna(0).astype(int)
result['outpatient_cases'] = result['outpatient_cases'].fillna(0).astype(int)
result['inpatient_cases'] = result['inpatient_cases'].fillna(0).astype(int)
# Calculate case density (cases per km²)
# Grid area = 0.1 km × 0.1 km = 0.01 km²
result['cases_per_km2'] = result['total_cases'] / 0.01
# Calculate risk index (normalized by max cases)
max_cases = result['total_cases'].max()
if max_cases > 0:
result['risk_index'] = result['total_cases'] / max_cases
else:
result['risk_index'] = 0.0
# Assign risk level
def get_risk_level(risk_index):
if risk_index >= 0.8:
return 'high'
elif risk_index >= 0.6:
return 'medium_high'
elif risk_index >= 0.4:
return 'medium'
elif risk_index >= 0.2:
return 'medium_low'
else:
return 'low'
result['risk_level'] = result['risk_index'].apply(get_risk_level)
# Select final columns
result = result[[
'grid_id', 'center_y', 'center_x',
'total_cases', 'outpatient_cases', 'inpatient_cases',
'cases_per_km2', 'risk_index', 'risk_level'
]]
return result
def main():
print(f"Reading geocoded cases from {INPUT_FILE}...")
df = pd.read_csv(INPUT_FILE)
print(f" Total records: {len(df):,}")
print("Filtering valid coordinates...")
df = filter_valid_coordinates(df)
print(f" Valid records: {len(df):,}")
print("Creating 100x100m grid...")
grid_df = create_grid()
print(f" Total grid cells: {len(grid_df):,}")
print("Aggregating cases to grid...")
result = aggregate_cases_to_grid(df, grid_df)
print(f"Saving to {OUTPUT_FILE}...")
result.to_csv(OUTPUT_FILE, index=False)
# Summary statistics
print("\n=== Summary ===")
print(f"Grid cells with cases: {(result['total_cases'] > 0).sum():,}")
print(f"Total cases: {result['total_cases'].sum():,}")
print(f"Max cases in single grid: {result['total_cases'].max():,}")
print(f"Risk index range: {result['risk_index'].min():.3f} - {result['risk_index'].max():.3f}")
print(f"Coordinate ranges:")
print(f" Latitude: {result['center_y'].min():.4f} to {result['center_y'].max():.4f}")
print(f" Longitude: {result['center_x'].min():.4f} to {result['center_x'].max():.4f}")
print("\nSample row:")
print(result.iloc[0].to_dict())
if __name__ == "__main__":
main()