File size: 13,276 Bytes
c902913 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | import os
import tempfile
import pandas as pd
import numpy as np
import folium
import gradio as gr
def haversine_distance(lat1, lon1, lat2, lon2):
"""Calculates great-circle distance in kilometers."""
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2
c = 2.0 * np.arcsin(np.sqrt(a))
return c * 6371.0
def run_network_equity_audit(df_start, df_dest, start_lat, start_lon, dest_lat, dest_lon, dest_label, circuity_factor=1.3):
"""Calculates route distance to closest destinations and correlates with demographics."""
try:
n_start = len(df_start)
n_dest = len(df_dest)
if n_start == 0 or n_dest == 0:
return None, "Error: Both datasets must contain at least 1 record."
start_lats = df_start[start_lat].values
start_lons = df_start[start_lon].values
dest_lats = df_dest[dest_lat].values
dest_lons = df_dest[dest_lon].values
closest_dest_idx = []
simulated_distances = []
for i in range(n_start):
# Calculate geodesic distance to all destinations
dists = haversine_distance(start_lats[i], start_lons[i], dest_lats, dest_lons)
# Apply urban circuity factor (standard road network winding multiplier)
dists_adjusted = dists * circuity_factor
min_idx = np.argmin(dists_adjusted)
closest_dest_idx.append(min_idx)
simulated_distances.append(dists_adjusted[min_idx])
df_audit = df_start.copy()
df_audit["Nearest_Destination_Index"] = closest_dest_idx
# Destination names
dest_names = df_dest[dest_label].values if dest_label in df_dest.columns else [f"Hub_{j}" for j in range(n_dest)]
df_audit["Nearest_Destination"] = [dest_names[idx] for idx in closest_dest_idx]
df_audit["Nearest_Dest_Lat"] = [dest_lats[idx] for idx in closest_dest_idx]
df_audit["Nearest_Dest_Lon"] = [dest_lons[idx] for idx in closest_dest_idx]
df_audit["Estimated_Travel_Distance_km"] = simulated_distances
# Categorize accessibility
# Green / High: <= 3 km. Orange / Moderate: 3 to 7 km. Red / Isolated: > 7 km.
conditions = [
df_audit["Estimated_Travel_Distance_km"] <= 3.0,
(df_audit["Estimated_Travel_Distance_km"] > 3.0) & (df_audit["Estimated_Travel_Distance_km"] <= 7.0),
df_audit["Estimated_Travel_Distance_km"] > 7.0
]
choices = ["High Accessibility", "Moderate Accessibility", "Isolated (Transit Desert)"]
df_audit["Accessibility_Status"] = np.select(conditions, choices, default="Moderate")
# Correlate demographics with access
# Find all numerical columns excluding lat, lon, calculations
exclude = [start_lat, start_lon, "Nearest_Destination_Index", "Nearest_Dest_Lat", "Nearest_Dest_Lon", "Estimated_Travel_Distance_km"]
num_cols = [c for c in df_start.columns if pd.api.types.is_numeric_dtype(df_start[c]) and c not in exclude]
correlations = []
for col in num_cols:
# Split demographic average for Highly Accessible vs Isolated neighborhoods
avg_accessible = df_audit[df_audit["Accessibility_Status"] == "High Accessibility"][col].mean()
avg_isolated = df_audit[df_audit["Accessibility_Status"] == "Isolated (Transit Desert)"][col].mean()
avg_accessible = 0.0 if np.isnan(avg_accessible) else avg_accessible
avg_isolated = 0.0 if np.isnan(avg_isolated) else avg_isolated
# Simple inequality quotient: Isolated Avg / Accessible Avg
inequality_ratio = avg_isolated / avg_accessible if avg_accessible != 0 else 0.0
correlations.append({
"Socio-Demographic Factor": col,
"Average in High-Access Areas": avg_accessible,
"Average in Isolated Areas": avg_isolated,
"Inequality Ratio (Isolated/Accessible)": inequality_ratio
})
df_compare = pd.DataFrame(correlations)
return df_compare, df_audit
except Exception as e:
print(f"Network audit failed: {e}")
return None, f"Network mapping failed: {e}"
def generate_network_map(df_audit, df_dest, start_lat, start_lon, dest_lat, dest_lon, dest_label):
"""Draws a beautiful Folium map showing connecting travel paths color-coded by accessibility."""
mean_lat = df_dest[dest_lat].mean()
mean_lon = df_dest[dest_lon].mean()
m = folium.Map(location=[mean_lat, mean_lon], zoom_start=12, tiles="CartoDB dark_matter")
# Color map for routes
status_colors = {
"High Accessibility": "#10b981", # Green
"Moderate Accessibility": "#f97316", # Orange
"Isolated (Transit Desert)": "#ef4444" # Red
}
# 1. Plot starting tract points and routes to closest destinations
for i, row in df_audit.iterrows():
s_lat = row[start_lat]
s_lon = row[start_lon]
d_lat = row["Nearest_Dest_Lat"]
d_lon = row["Nearest_Dest_Lon"]
d_name = row["Nearest_Destination"]
dist = row["Estimated_Travel_Distance_km"]
status = row["Accessibility_Status"]
color = status_colors.get(status, "#6b7280")
# Add travel connection path (direct line representing route)
folium.PolyLine(
locations=[[s_lat, s_lon], [d_lat, d_lon]],
color=color,
weight=2,
opacity=0.6,
dash_array="5, 5" if status == "Isolated (Transit Desert)" else None
).add_to(m)
# Add neighborhood centroid circle
folium.CircleMarker(
location=[s_lat, s_lon],
radius=6,
color=color,
fill=True,
fill_color=color,
fill_opacity=0.8,
weight=1.5,
popup=f"Nearest Hub: {d_name}<br>Simulated Distance: {dist:.2f} km<br>Status: {status}"
).add_to(m)
# 2. Plot vital Destination Hub markers (high contrast white/gold icon)
for i, row in df_dest.iterrows():
lat = row[dest_lat]
lon = row[dest_lon]
name = row[dest_label] if dest_label in df_dest.columns else f"Hub {i}"
folium.Marker(
location=[lat, lon],
popup=f"<b>Destination Hub: {name}</b>",
icon=folium.Icon(color="orange", icon="home")
).add_to(m)
return m
def full_network_pipeline(file_start, file_dest, start_lat, start_lon, dest_lat, dest_lon, dest_label, circuity):
"""Executes loading, network distance mapping, comparative audits, and downloads."""
if file_start is None or file_dest is None:
return None, "Please upload both the Demographic Starting Points CSV and Destination Hubs CSV files.", pd.DataFrame(), None
try:
df_start = pd.read_csv(file_start.name)
df_dest = pd.read_csv(file_dest.name)
# Column checks
for c in [start_lat, start_lon]:
if c not in df_start.columns:
return None, f"ERROR: Demographic column '{c}' not found! Check columns.", pd.DataFrame(), None
for c in [dest_lat, dest_lon]:
if c not in df_dest.columns:
return None, f"ERROR: Destination column '{c}' not found! Check columns.", pd.DataFrame(), None
df_start_clean = df_start.dropna(subset=[start_lat, start_lon]).copy()
df_dest_clean = df_dest.dropna(subset=[dest_lat, dest_lon]).copy()
df_compare, df_audit = run_network_equity_audit(
df_start_clean, df_dest_clean, start_lat, start_lon, dest_lat, dest_lon, dest_label, circuity
)
if df_compare is None:
# df_compare holds the error string
return None, df_audit, pd.DataFrame(), None
# Draw map
map_obj = generate_network_map(
df_audit, df_dest_clean, start_lat, start_lon, dest_lat, dest_lon, dest_label
)
# Save HTML map
temp_map = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
map_obj.save(temp_map.name)
isolated_count = len(df_audit[df_audit["Accessibility_Status"] == "Isolated (Transit Desert)"])
total_count = len(df_audit)
status_md = f"""
### 📊 Network Transit Equity Metrics:
* **Total Starting Neighborhoods**: `{total_count}`
* **Isolated Neighborhoods (Transit Deserts)**: `{isolated_count} ({isolated_count/total_count:.1%})`
* **Highly Accessible Areas (Short Travel)**: `{len(df_audit[df_audit["Accessibility_Status"] == "High Accessibility"])}`
* **Applied Road Winding Circuity Factor**: `{circuity:.2f}x`
*Interpretation*: If the **Inequality Ratio** on the right is greater than 1.0, it indicates that isolated neighborhoods have a higher concentration of that demographic attribute than accessible zones, proving spatial polarization!
"""
# CSV download path
temp_csv = tempfile.NamedTemporaryFile(delete=False, suffix="_transit_audit.csv")
df_audit.to_csv(temp_csv.name, index=False)
return temp_map.name, status_md, df_compare, temp_csv.name
except Exception as e:
return None, f"Transit audit processing failed: {e}", pd.DataFrame(), None
# Custom styling (Monochrome / Indigo theme)
custom_css = """
body { background-color: #0d0f12; color: #e3e6eb; font-family: 'Inter', sans-serif; }
.gradio-container { max-width: 1200px !important; margin: 0 auto !important; }
h1, h2, h3 { color: #ffffff !important; font-weight: 700 !important; }
.btn-primary { background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important; border: none !important; color: white !important; font-weight: 600 !important; }
.btn-primary:hover { filter: brightness(1.1); }
"""
with gr.Blocks(theme=gr.themes.Monochrome(), css=custom_css) as demo:
gr.Markdown(
"""
# 🕸️ Route Equity & Spatial Network Analyzer
### Analyze spatial access by calculating simulated road-network routing (using circuity multipliers) from neighborhoods to vital resources. Identify geographic exclusion and audit transit deserts.
"""
)
with gr.Row():
with gr.Column(scale=4):
with gr.Card():
gr.Markdown("### 1. Upload Starting Tract Coordinates")
file_start_input = gr.File(label="Upload Neighborhood Centroids CSV", file_types=[".csv"])
with gr.Row():
start_lat_name = gr.Textbox(label="Neighborhood Lat Column", value="Latitude")
start_lon_name = gr.Textbox(label="Neighborhood Lon Column", value="Longitude")
with gr.Card():
gr.Markdown("### 2. Upload Destination Hubs (Hospitals/Services)")
file_dest_input = gr.File(label="Upload Vital Hub POIs CSV", file_types=[".csv"])
with gr.Row():
dest_lat_name = gr.Textbox(label="Vital Hub Lat Column", value="Latitude")
dest_lon_name = gr.Textbox(label="Vital Hub Lon Column", value="Longitude")
dest_lbl_name = gr.Textbox(label="Hub Name/Label Column", value="Name")
with gr.Card():
gr.Markdown("### 3. Route Settings")
circuity_slider = gr.Slider(
minimum=1.0, maximum=2.0, value=1.3, step=0.05,
label="Urban Circuity Winding Multiplier (Simulate roads vs. crow flies)"
)
analyze_btn = gr.Button("Analyze Route Network Equity", variant="primary", elem_classes="btn-primary")
with gr.Column(scale=6):
with gr.Tabs():
with gr.TabItem("🗺️ Dynamic Transit Network Map"):
map_output = gr.HTML(label="Leaflet Route Map Grid", value="<div style='text-align: center; padding: 50px; color: gray;'>Map will load here...</div>")
summary_output = gr.Markdown("Please load coordinates and calculate routing.")
with gr.TabItem("📊 Socio-Spatial Inequality Report"):
table_output = gr.Dataframe(
label="Calculated Access Disparities Table (Isolated vs. High-Access)",
interactive=False,
wrap=True
)
download_btn = gr.File(label="Download Labeled CSV Database", interactive=False)
analyze_btn.click(
fn=full_network_pipeline,
inputs=[file_start_input, file_dest_input, start_lat_name, start_lon_name, dest_lat_name, dest_lon_name, dest_lbl_name, circuity_slider],
outputs=[map_output, summary_output, table_output, download_btn]
)
if __name__ == "__main__":
demo.launch()
|