Jomaric commited on
Commit
c902913
·
0 Parent(s):

feat: initial release of GIS space

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