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:
333
scripts/build_road_graph.py
Normal file
333
scripts/build_road_graph.py
Normal file
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build Road Network Graph for Wuhan Respiratory Disease Risk Prediction Platform
|
||||
Extracts Wuhan OSM road network and builds graph structure
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import geopandas as gpd
|
||||
from shapely.geometry import shape, MultiPolygon, Polygon
|
||||
from scipy.sparse import csr_matrix, lil_matrix
|
||||
import networkx as nx
|
||||
import pyrosm
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# Paths
|
||||
PBF_PATH = '/home/akiba/CA/Datas/地图/hubei-260129.osm.pbf'
|
||||
WUHAN_GEOJSON = '/home/akiba/CA/Datas/武汉市.geojson'
|
||||
OUTPUT_DIR = '/home/akiba/CA/processed/graph'
|
||||
|
||||
def load_wuhan_boundary():
|
||||
"""Load Wuhan boundary from geojson"""
|
||||
with open(WUHAN_GEOJSON, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Combine all district polygons into one
|
||||
geometries = []
|
||||
for feat in data['features']:
|
||||
geom = shape(feat['geometry'])
|
||||
geometries.append(geom)
|
||||
|
||||
# Create union of all geometries
|
||||
boundary = geometries[0]
|
||||
for g in geometries[1:]:
|
||||
boundary = boundary.union(g)
|
||||
|
||||
return boundary, data['features']
|
||||
|
||||
def get_district_for_point(point, features):
|
||||
"""Find which district a point belongs to"""
|
||||
for feat in features:
|
||||
geom = shape(feat['geometry'])
|
||||
if geom.contains(point):
|
||||
return feat['properties']['name']
|
||||
return 'unknown'
|
||||
|
||||
def build_road_graph():
|
||||
"""Build road network graph from OSM data"""
|
||||
print("Loading Wuhan boundary...")
|
||||
boundary, district_features = load_wuhan_boundary()
|
||||
print(f" Boundary type: {boundary.geom_type}")
|
||||
|
||||
print("Reading OSM data...")
|
||||
# Initialize OSM reader with Wuhan boundary
|
||||
print(" Initializing OSM reader...")
|
||||
osm = pyrosm.OSM(PBF_PATH, bounding_box=boundary)
|
||||
|
||||
# Get all drivable roads (more comprehensive than just primary/secondary)
|
||||
print("Extracting roads within Wuhan boundary...")
|
||||
# Filter to Wuhan boundary using bounding box first (faster)
|
||||
bounds = boundary.bounds
|
||||
print(f" Bounding box: {bounds}")
|
||||
|
||||
# Read roads using pyrosm with custom filter
|
||||
# Get all highways first, then filter to boundary
|
||||
print(" Reading highways...")
|
||||
highways = osm.get_data_by_custom_criteria({
|
||||
'highway': ['motorway', 'trunk', 'primary', 'secondary', 'tertiary',
|
||||
'unclassified', 'residential', 'living_street', 'pedestrian',
|
||||
'track', 'service', 'road']
|
||||
})
|
||||
print(f" Total highway elements: {len(highways)}")
|
||||
|
||||
if len(highways) == 0:
|
||||
print("ERROR: No highways found. Trying alternative approach...")
|
||||
return None
|
||||
|
||||
# Convert to GeoDataFrame
|
||||
gdf = gpd.GeoDataFrame(highways, geometry='geometry', crs='EPSG:4326')
|
||||
print(f" GeoDataFrame size: {len(gdf)}")
|
||||
|
||||
# Filter to Wuhan boundary
|
||||
print(" Clipping to Wuhan boundary...")
|
||||
gdf_clipped = gdf[gdf.geometry.is_valid].copy()
|
||||
gdf_clipped = gdf_clipped[gdf_clipped.intersects(boundary)]
|
||||
gdf_clipped = gdf_clipped.geometry.apply(lambda g: g.intersection(boundary) if g.is_valid else None)
|
||||
gdf_clipped = gdf_clipped.dropna()
|
||||
|
||||
# Explode MultiLineStrings to LineStrings
|
||||
def explode_geom(g):
|
||||
if g.geom_type == 'MultiLineString':
|
||||
return list(g.geoms)
|
||||
elif g.geom_type == 'LineString':
|
||||
return [g]
|
||||
elif g.geom_type == 'MultiPolygon':
|
||||
# Get all polygon exteriors as LineStrings
|
||||
result = []
|
||||
for poly in g.geoms:
|
||||
result.append(poly.exterior)
|
||||
return result
|
||||
elif g.geom_type == 'Polygon':
|
||||
# Intersection of a LineString with boundary can return Polygon
|
||||
return [g.exterior]
|
||||
elif g.geom_type == 'GeometryCollection':
|
||||
result = []
|
||||
for geom in g.geoms:
|
||||
result.extend(explode_geom(geom))
|
||||
return result
|
||||
return []
|
||||
|
||||
all_geoms = []
|
||||
for g in gdf_clipped.geometry:
|
||||
all_geoms.extend(explode_geom(g))
|
||||
|
||||
print(f" Total line segments after clipping: {len(all_geoms)}")
|
||||
|
||||
if len(all_geoms) == 0:
|
||||
print("ERROR: No geometries after clipping")
|
||||
return None
|
||||
|
||||
# Build graph
|
||||
print("Building graph structure...")
|
||||
G = nx.MultiDiGraph()
|
||||
|
||||
node_id_counter = 0
|
||||
node_info = {} # osmid -> (lat, lon, district, road_type)
|
||||
|
||||
# First pass: collect all unique points
|
||||
all_points = set()
|
||||
point_to_node = {}
|
||||
|
||||
for i, geom in enumerate(all_geoms):
|
||||
coords = list(geom.coords)
|
||||
for coord in coords:
|
||||
all_points.add(coord)
|
||||
|
||||
print(f" Total unique points: {len(all_points)}")
|
||||
|
||||
# Map points to node IDs
|
||||
for pt in all_points:
|
||||
point_to_node[pt] = node_id_counter
|
||||
node_id_counter += 1
|
||||
|
||||
# Add nodes to graph
|
||||
for pt, nid in point_to_node.items():
|
||||
G.add_node(nid, osmid=nid, x=pt[0], y=pt[1])
|
||||
|
||||
# Second pass: create edges from line segments
|
||||
edge_count = 0
|
||||
edges_data = []
|
||||
|
||||
for geom in all_geoms:
|
||||
coords = list(geom.coords)
|
||||
for i in range(len(coords) - 1):
|
||||
u = point_to_node[coords[i]]
|
||||
v = point_to_node[coords[i+1]]
|
||||
|
||||
# Calculate edge weight (1/length_km)
|
||||
dx = coords[i+1][0] - coords[i][0]
|
||||
dy = coords[i+1][1] - coords[i][1]
|
||||
length_deg = np.sqrt(dx**2 + dy**2)
|
||||
# Approximate conversion at Wuhan latitude (30N)
|
||||
length_km = length_deg * 111.32 * np.cos(np.radians(30))
|
||||
length_km = max(length_km, 0.0001) # avoid division by zero
|
||||
|
||||
weight = 1.0 / length_km
|
||||
|
||||
G.add_edge(u, v, weight=weight, length=length_km)
|
||||
edges_data.append((u, v, length_km, weight))
|
||||
edge_count += 1
|
||||
|
||||
print(f" Graph nodes: {G.number_of_nodes()}")
|
||||
print(f" Graph edges: {G.number_of_edges()}")
|
||||
|
||||
# Check node count and apply fallback if needed
|
||||
if G.number_of_nodes() > 70000:
|
||||
print("\nNode count exceeds 70k, applying highway filter...")
|
||||
# Filter to major roads only
|
||||
major_roads = osm.get_data_by_custom_criteria({
|
||||
'highway': ['motorway', 'trunk', 'primary', 'secondary', 'tertiary']
|
||||
})
|
||||
gdf_major = gpd.GeoDataFrame(major_roads, geometry='geometry', crs='EPSG:4326')
|
||||
gdf_major = gdf_major[gdf_major.geometry.is_valid].copy()
|
||||
gdf_major = gdf_major[gdf_major.intersects(boundary)]
|
||||
|
||||
# Rebuild graph
|
||||
G = nx.MultiDiGraph()
|
||||
node_id_counter = 0
|
||||
point_to_node = {}
|
||||
|
||||
all_geoms = []
|
||||
for g in gdf_major.geometry:
|
||||
all_geoms.extend(explode_geom(g))
|
||||
|
||||
all_points = set()
|
||||
for geom in all_geoms:
|
||||
coords = list(geom.coords)
|
||||
for coord in coords:
|
||||
all_points.add(coord)
|
||||
|
||||
for pt in all_points:
|
||||
point_to_node[pt] = node_id_counter
|
||||
node_id_counter += 1
|
||||
|
||||
for pt, nid in point_to_node.items():
|
||||
G.add_node(nid, osmid=nid, x=pt[0], y=pt[1])
|
||||
|
||||
for geom in all_geoms:
|
||||
coords = list(geom.coords)
|
||||
for i in range(len(coords) - 1):
|
||||
u = point_to_node[coords[i]]
|
||||
v = point_to_node[coords[i+1]]
|
||||
dx = coords[i+1][0] - coords[i][0]
|
||||
dy = coords[i+1][1] - coords[i][1]
|
||||
length_deg = np.sqrt(dx**2 + dy**2)
|
||||
length_km = length_deg * 111.32 * np.cos(np.radians(30))
|
||||
length_km = max(length_km, 0.0001)
|
||||
weight = 1.0 / length_km
|
||||
G.add_edge(u, v, weight=weight, length=length_km)
|
||||
|
||||
print(f" Filtered graph nodes: {G.number_of_nodes()}")
|
||||
print(f" Filtered graph edges: {G.number_of_edges()}")
|
||||
|
||||
node_count = G.number_of_nodes()
|
||||
if node_count < 15000 or node_count > 70000:
|
||||
print(f"WARNING: Node count {node_count} outside target range 15k-70k")
|
||||
|
||||
# Check connectivity
|
||||
print("\nChecking graph connectivity...")
|
||||
if G.number_of_nodes() > 0:
|
||||
# Get largest weakly connected component
|
||||
if G.is_directed():
|
||||
connected = list(nx.weakly_connected_components(G))
|
||||
else:
|
||||
connected = list(nx.connected_components(G))
|
||||
largest_cc = max(connected, key=len)
|
||||
print(f" Total components: {len(connected)}")
|
||||
print(f" Largest component size: {len(largest_cc)}")
|
||||
print(f" Largest component ratio: {len(largest_cc)/G.number_of_nodes():.2%}")
|
||||
|
||||
# Keep only largest component
|
||||
nodes_to_remove = set(G.nodes()) - set(largest_cc)
|
||||
G.remove_nodes_from(nodes_to_remove)
|
||||
print(f" After pruning to largest CC: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
|
||||
|
||||
# Relabel nodes to consecutive integers 0..n-1 for adjacency matrix
|
||||
old_nodes = list(G.nodes())
|
||||
new_nodes = range(len(old_nodes))
|
||||
mapping = dict(zip(old_nodes, new_nodes))
|
||||
G = nx.relabel_nodes(G, mapping, copy=False)
|
||||
print(f" Relabeled nodes to consecutive IDs 0..{G.number_of_nodes()-1}")
|
||||
|
||||
# Build output files
|
||||
print("\nGenerating output files...")
|
||||
|
||||
# 1. Node metadata
|
||||
node_data = []
|
||||
for nid in G.nodes():
|
||||
props = G.nodes[nid]
|
||||
# Approximate lat/lon
|
||||
lat = props.get('y', 0)
|
||||
lon = props.get('x', 0)
|
||||
node_data.append({
|
||||
'osmid': nid,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'district': 'unknown', # Would need reverse geocoding
|
||||
'road_type': 'unknown'
|
||||
})
|
||||
|
||||
node_df = pd.DataFrame(node_data)
|
||||
node_df.to_parquet(f'{OUTPUT_DIR}/node_metadata.parquet', index=False)
|
||||
print(f" Saved node_metadata.parquet: {len(node_df)} nodes")
|
||||
|
||||
# 2. Edge list
|
||||
edge_data = []
|
||||
for u, v, data in G.edges(data=True):
|
||||
edge_data.append({
|
||||
'source': u,
|
||||
'target': v,
|
||||
'weight': data.get('weight', 1.0),
|
||||
'length_km': data.get('length', 0)
|
||||
})
|
||||
|
||||
edge_df = pd.DataFrame(edge_data)
|
||||
edge_df.to_csv(f'{OUTPUT_DIR}/edge_list.csv', index=False)
|
||||
print(f" Saved edge_list.csv: {len(edge_df)} edges")
|
||||
|
||||
# 3. Adjacency matrix (sparse CSR)
|
||||
print(" Building adjacency matrix...")
|
||||
n = G.number_of_nodes()
|
||||
adj = lil_matrix((n, n), dtype=np.float32)
|
||||
|
||||
for u, v, data in G.edges(data=True):
|
||||
adj[u, v] = data.get('weight', 1.0)
|
||||
# Make it symmetric for undirected use
|
||||
adj[v, u] = data.get('weight', 1.0)
|
||||
|
||||
adj_csr = adj.tocsr()
|
||||
np.savez(f'{OUTPUT_DIR}/adjacency_matrix.npz', data=adj_csr.data, indices=adj_csr.indices, indptr=adj_csr.indptr, shape=adj_csr.shape)
|
||||
print(f" Saved adjacency_matrix.npz: {adj_csr.shape}")
|
||||
|
||||
# Verify outputs
|
||||
print("\n=== VERIFICATION ===")
|
||||
print(f"Node count: {G.number_of_nodes()}")
|
||||
print(f"Edge count: {G.number_of_edges()}")
|
||||
print(f"Target range: 15,000 - 70,000")
|
||||
|
||||
# Check components
|
||||
if G.number_of_nodes() > 0:
|
||||
if G.is_directed():
|
||||
components = list(nx.weakly_connected_components(G))
|
||||
else:
|
||||
components = list(nx.connected_components(G))
|
||||
print(f"Connected components: {len(components)}")
|
||||
|
||||
# Verify files exist
|
||||
for fname in ['adjacency_matrix.npz', 'edge_list.csv', 'node_metadata.parquet']:
|
||||
fpath = f'{OUTPUT_DIR}/{fname}'
|
||||
if os.path.exists(fpath):
|
||||
size = os.path.getsize(fpath)
|
||||
print(f" {fname}: {size/1024:.1f} KB")
|
||||
else:
|
||||
print(f" {fname}: MISSING")
|
||||
|
||||
print("\nDone!")
|
||||
return G
|
||||
|
||||
if __name__ == '__main__':
|
||||
G = build_road_graph()
|
||||
Reference in New Issue
Block a user