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,117 @@
import { create } from 'zustand';
import axios from 'axios';
import { analysisApi, insightsApi } from '@/services/api';
function isCancelError(e: unknown): boolean {
return axios.isCancel(e) || (e as Error)?.message === 'canceled';
}
interface TrendDataPoint {
date: string;
aqi: number;
pm25: number;
pm10: number;
so2: number;
no2: number;
co: number;
o3: number;
}
interface DistrictData {
district: string;
avg_aqi: number;
avg_risk: number;
high_risk_count: number;
population: number;
}
interface InsightCard {
id: string;
title: string;
description: string;
type: 'warning' | 'info' | 'success' | 'danger';
metric?: string;
metricValue?: string;
timestamp: string;
}
interface InsightsOverview {
total_insights: number;
warning_count: number;
info_count: number;
success_count: number;
cards: InsightCard[];
}
interface AnalysisState {
trendData: TrendDataPoint[];
districtData: DistrictData[];
insights: InsightsOverview | null;
isLoading: boolean;
error: string | null;
selectedDays: number;
setSelectedDays: (days: number) => void;
fetchTrend: (days?: number) => Promise<void>;
fetchDistricts: () => Promise<void>;
fetchInsights: () => Promise<void>;
clearError: () => void;
}
export const useAnalysisStore = create<AnalysisState>((set, get) => ({
trendData: [],
districtData: [],
insights: null,
isLoading: false,
error: null,
selectedDays: 7,
setSelectedDays: (days) => {
set({ selectedDays: days });
get().fetchTrend(days);
},
clearError: () => set({ error: null }),
fetchTrend: async (days = 7) => {
set({ isLoading: true, error: null });
try {
const data = await analysisApi.getTrend(days);
const trendData: TrendDataPoint[] = (data.dates || []).map((date: string, i: number) => ({
date,
aqi: Math.round((data.values?.[i] || 0.5) * 200),
pm25: Math.round((data.values?.[i] || 0.5) * 100),
pm10: Math.round((data.values?.[i] || 0.5) * 150),
so2: Math.round((data.values?.[i] || 0.5) * 30),
no2: Math.round((data.values?.[i] || 0.5) * 80),
co: Math.round((data.values?.[i] || 0.5) * 2 * 100) / 100,
o3: Math.round((data.values?.[i] || 0.5) * 150),
}));
set({ trendData, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载趋势数据失败', isLoading: false });
}
},
fetchDistricts: async () => {
set({ isLoading: true, error: null });
try {
const data = await analysisApi.getDistricts();
set({ districtData: data.districts || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载区域数据失败', isLoading: false });
}
},
fetchInsights: async () => {
set({ isLoading: true, error: null });
try {
const data = await insightsApi.getOverview();
set({ insights: data, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载洞察数据失败', isLoading: false });
}
},
}));

View File

@@ -0,0 +1,276 @@
import { create } from 'zustand';
import axios from 'axios';
import type { GridRisk, GridDetail, Alert, Stats, ForecastDay } from '@/types';
import { riskApi, alertApi, gridApi } from '@/services/api';
function isCancelError(e: unknown): boolean {
return axios.isCancel(e) || (e as Error)?.message === 'canceled';
}
interface RiskState {
grids: GridRisk[];
selectedGrid: GridDetail | null;
selectedGridId: string | null;
alerts: Alert[];
stats: Stats | null;
forecastDay: ForecastDay;
isLoading: boolean;
error: string | null;
showFullscreen: boolean;
setForecastDay: (day: ForecastDay) => void;
setSelectedGridId: (id: string | null) => void;
setShowFullscreen: (show: boolean) => void;
fetchRiskMap: () => Promise<void>;
fetchGridDetail: (gridId: string) => Promise<void>;
fetchAlerts: () => Promise<void>;
fetchStats: () => Promise<void>;
clearError: () => void;
}
export const useRiskStore = create<RiskState>((set, get) => ({
grids: [],
selectedGrid: null,
selectedGridId: null,
alerts: [],
stats: null,
forecastDay: 0,
isLoading: false,
error: null,
showFullscreen: false,
setForecastDay: (day) => {
set({ forecastDay: day });
get().fetchRiskMap();
},
setSelectedGridId: (id) => {
set({ selectedGridId: id });
if (id) get().fetchGridDetail(id);
else set({ selectedGrid: null });
},
setShowFullscreen: (show) => set({ showFullscreen: show }),
clearError: () => set({ error: null }),
fetchRiskMap: async () => {
set({ isLoading: true, error: null });
try {
const { forecastDay } = get();
const data = forecastDay === 0
? await riskApi.getCurrentRiskMap()
: await riskApi.getForecast(forecastDay);
set({ grids: data.grids || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载风险地图失败', isLoading: false });
}
},
fetchGridDetail: async (gridId) => {
set({ isLoading: true, error: null });
try {
const data = await riskApi.getGridDetail(gridId);
set({ selectedGrid: data.grid, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载网格详情失败', isLoading: false });
}
},
fetchAlerts: async () => {
try {
const data = await alertApi.getAlerts({ min_risk: 0.6 });
set({ alerts: data.alerts || [] });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载预警数据失败' });
}
},
fetchStats: async () => {
try {
const data = await riskApi.getStats();
set({ stats: data });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载统计数据失败' });
}
},
}));
export { useAnalysisStore } from './analysisStore';
interface TimelineState {
currentDate: string;
startDate: string;
endDate: string;
isPlaying: boolean;
playbackSpeed: number;
setCurrentDate: (date: string) => void;
setDateRange: (start: string, end: string) => void;
setPlaying: (playing: boolean) => void;
setPlaybackSpeed: (speed: number) => void;
goToNextDay: () => void;
goToPrevDay: () => void;
}
export const useTimelineStore = create<TimelineState>((set, get) => ({
currentDate: new Date().toISOString().split('T')[0],
startDate: '2022-12-01',
endDate: '2024-12-30',
isPlaying: false,
playbackSpeed: 1,
setCurrentDate: (date) => set({ currentDate: date }),
setDateRange: (start, end) => set({ startDate: start, endDate: end }),
setPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
goToNextDay: () => {
const { currentDate, endDate } = get();
const next = new Date(currentDate);
next.setDate(next.getDate() + 1);
if (next.toISOString().split('T')[0] <= endDate) {
set({ currentDate: next.toISOString().split('T')[0] });
}
},
goToPrevDay: () => {
const { currentDate, startDate } = get();
const prev = new Date(currentDate);
prev.setDate(prev.getDate() - 1);
if (prev.toISOString().split('T')[0] >= startDate) {
set({ currentDate: prev.toISOString().split('T')[0] });
}
},
}));
interface GridFeature {
grid_id: string;
latitude: number;
longitude: number;
district: string;
AQI: number;
PM25: number;
PM10: number;
total_cases: number;
}
interface MonitoringState {
gridFeatures: GridFeature[];
aggregatedData: Array<{ date: string; district: string; total_cases: number; avg_AQI: number }>;
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
selectedDistrict: string | null;
isLoading: boolean;
error: string | null;
fetchGridFeatures: (date: string) => Promise<void>;
fetchAggregatedData: (startDate: string, endDate: string, district?: string) => Promise<void>;
fetchDistrictCases: () => Promise<void>;
setSelectedDistrict: (district: string | null) => void;
clearError: () => void;
}
export const useMonitoringStore = create<MonitoringState>((set) => ({
gridFeatures: [],
aggregatedData: [],
districtCases: [],
selectedDistrict: null,
isLoading: false,
error: null,
clearError: () => set({ error: null }),
fetchGridFeatures: async (date) => {
set({ isLoading: true, error: null });
try {
const data = await gridApi.getGridsGeoJSON(date);
const features: GridFeature[] = data.features.map((f: any) => ({
grid_id: f.properties.grid_id,
latitude: f.properties.latitude,
longitude: f.properties.longitude,
district: f.properties.district,
AQI: f.properties.AQI || 0,
PM25: f.properties.PM25 || 0,
PM10: f.properties.PM10 || 0,
total_cases: f.properties.total_cases || 0,
}));
set({ gridFeatures: features, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载网格数据失败', isLoading: false });
}
},
fetchAggregatedData: async (startDate, endDate, district) => {
set({ isLoading: true, error: null });
try {
const data = await gridApi.getHistoricalAggregated(startDate, endDate, 'daily', district);
set({ aggregatedData: data.aggregations || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载聚合数据失败', isLoading: false });
}
},
fetchDistrictCases: async () => {
set({ isLoading: true, error: null });
try {
const { caseApi } = await import('@/services/api');
const data = await caseApi.getDistricts();
set({ districtCases: data.districts || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载区县病例数据失败', isLoading: false });
}
},
setSelectedDistrict: (district) => set({ selectedDistrict: district }),
}));
interface PredictionState {
predictions: GridPrediction[];
predictionDays: number;
isLoading: boolean;
error: string | null;
fetchPredictions: (date: string, days: number, district?: string) => Promise<void>;
clearError: () => void;
}
interface GridPrediction {
grid_id: string;
latitude: number;
longitude: number;
risk_1day: number;
risk_3day: number;
risk_7day: number;
risk_level: string;
}
export const usePredictionStore = create<PredictionState>((set) => ({
predictions: [],
predictionDays: 7,
isLoading: false,
error: null,
clearError: () => set({ error: null }),
fetchPredictions: async (date, days, district) => {
set({ isLoading: true, error: null });
try {
const data = await gridApi.getMultiDayPrediction(date, days, district);
set({ predictions: data.predictions || [], isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载预测数据失败', isLoading: false });
}
},
}));