import pandas as pd import numpy as np import gradio as gr import matplotlib.pyplot as plt import h3.api.basic_str as h3 import folium from shapely.geometry import Polygon import geopandas as gpd import tempfile import zipfile import os import glob from shapely.ops import unary_union # Define feature lists & descriptions PRIMARY_FEATURES = ['OEM Critical Facilities', 'High Frequency Flooding', 'Low Frequency Flooding', 'METRO', 'HPW Critical Facilities', 'Other Critical Facilities'] SECONDARY_FEATURES = ['PCI', 'ADT', 'RPL_THEME_1', 'RPL_THEME_2', 'RPL_THEME_3', 'RPL_THEME_4', 'RPL_THEME_ALL'] ALL_FEATURES = PRIMARY_FEATURES + SECONDARY_FEATURES EXTRA_COLUMNS = ['PCI Score', 'SURFACETYP', 'METRO_ROUTES', 'STREETNAME'] FEATURE_DESCRIPTIONS = { 'OEM Critical Facilities': 'Distance to HFD, HPD, hospitals, and OEM facilities.', 'HPW Critical Facilities': 'Distance to Houston Water facilities.', 'Other Critical Facilities': 'Distance to NGOs, private entities, MSC, CC.', 'High Frequency Flooding': 'Roadway flooding instance for 2, 5, 10 and 25 Year Storm events.', 'Low Frequency Flooding': 'Roadway flooding instance for 50, 100 and 500 Year Storm events.', 'METRO': 'Number of METRO bus routes.', 'PCI': 'Pavement Condition Index.', 'ADT': 'Annual Average Daily Traffic.', 'RPL_THEME_1': 'Socioeconomic vulnerability (SVI theme 1).', 'RPL_THEME_2': 'Household characteristics (SVI theme 2).', 'RPL_THEME_3': 'Racial & ethnic minority (SVI theme 3).', 'RPL_THEME_4': 'Housing & transportation (SVI theme 4).', 'RPL_THEME_ALL': 'Overall social vulnerability (combined).' } # Load data including SurfaceTyp only once def load_data(path="./MainInstances.xlsx"): df = pd.read_excel(path, dtype={'SURFACETYP': str, 'METRO_ROUTES': str}) df.columns = df.columns.str.strip() # Ensure SurfaceTyp and METRO_ROUTES columns exist if 'SURFACETYP' not in df.columns: df['SURFACETYP'] = '' if 'METRO_ROUTES' not in df.columns: df['METRO_ROUTES'] = '' # Ensure other columns exist for col in ['GRID_ID'] + ALL_FEATURES + ['PCI Score', 'STREETNAME']: if col not in df.columns: df[col] = 0 # Trim to needed columns cols = ['GRID_ID'] + ALL_FEATURES + ['PCI Score', 'SURFACETYP', 'METRO_ROUTES', 'STREETNAME'] df = df[cols] # Convert numeric fields num_cols = ALL_FEATURES + ['PCI Score'] df[num_cols] = df[num_cols].apply(pd.to_numeric, errors='coerce').fillna(0) # Clean SurfaceTyp and METRO_ROUTES df['METRO_ROUTES'] = df['METRO_ROUTES'].fillna('').astype(str).str.strip() df['SURFACETYP'] = df['SURFACETYP'].fillna('').astype(str).str.strip() return df # H3 boundary to coords def h3_poly_coords(h3_id): return [(lat, lon) for lat, lon in h3.cell_to_boundary(h3_id)] # Compute weighted index def calculate_index(df, features, weights): w = np.array(weights) / np.sum(weights) df_idx = df.copy() df_idx['Index'] = df_idx[features].values.dot(w) return df_idx # Stats markdown def stats_text(df_idx): s = df_idx['Index'].describe() return ( "### Index Statistics\n" f"- Min: {s['min']:.4f}\n" f"- 25%: {s['25%']:.4f}\n" f"- Median: {s['50%']:.4f}\n" f"- 75%: {s['75%']:.4f}\n" f"- Max: {s['max']:.4f}" ) # Histogram def make_histogram(df_idx): fig, ax = plt.subplots() ax.hist(df_idx['Index'], bins=30) ax.set_title('Index Distribution') ax.set_xlabel('Index') ax.set_ylabel('Frequency') plt.tight_layout() plt.close(fig) return fig # Create index map def create_index_map(df_idx, features): sel = df_idx[df_idx['Index'] >= df_idx['Index'].quantile(0.5)] center = (29.76, -95.37) if not sel.empty: center = h3.cell_to_latlng(sel.iloc[0]['GRID_ID']) m = folium.Map(location=center, zoom_start=10) for _, row in sel.iterrows(): idx = row['Index'] fill_opacity = 0.6 if 3 <= idx <= 4 else 0 norm = (idx - sel['Index'].min()) / (sel['Index'].max() - sel['Index'].min()) if sel['Index'].max() > sel['Index'].min() else 0.5 color = f"rgb({int(255*norm)},0,{int(255*(1-norm))})" popup = "
".join( [f"{feat}: {row[feat]:.2f}" for feat in features] + [f"{col}: {row[col]}" for col in EXTRA_COLUMNS] + [f"Index: {idx:.2f}"] ) folium.Polygon( locations=h3_poly_coords(row['GRID_ID']), color='black', fill=True, fill_color=color, fill_opacity=fill_opacity, popup=popup ).add_to(m) return m._repr_html_() # Clustering logic def find_clusters(hex_ids, min_size): clusters, visited = [], set() for h in hex_ids: if h in visited: continue cluster, stack = {h}, [h] visited.add(h) while stack: cur = stack.pop() for nb in h3.grid_disk(cur, 1): if nb in hex_ids and nb not in visited: visited.add(nb) cluster.add(nb) stack.append(nb) if len(cluster) >= min_size: clusters.append(cluster) return clusters # Generate clusters map/table and shapefile def create_cluster_outputs(df_idx, threshold, min_size, pci_path="./PCI.json"): # 1) Filter by threshold filt = df_idx[df_idx['Index'] >= threshold] if filt.empty: return "No hexes above threshold.", None, None, None # 2) Find clusters clusters = find_clusters(filt['GRID_ID'].tolist(), min_size) if not clusters: return "No clusters found.", None, None, None # 3) Flatten all hex IDs ids = set().union(*clusters) # 4) Build table of cluster hexes table = df_idx[df_idx['GRID_ID'].isin(ids)][['GRID_ID', 'Index'] + EXTRA_COLUMNS] # 5) Create Folium map centered on first hex center = h3.cell_to_latlng(next(iter(ids))) m = folium.Map(location=center, zoom_start=12) # 6) Draw each hexagon with popup, and collect Shapely polygons hex_polys = [] for _, row in table.iterrows(): h3_id = row['GRID_ID'] coords = h3_poly_coords(h3_id) # HTML popup popup_html = ( f"Hex: {h3_id}
" f"Index: {row['Index']:.2f}
" + "
".join(f"{col}: {row[col]}" for col in EXTRA_COLUMNS) ) folium.Polygon( locations=coords, color='blue', fill=True, fill_opacity=0.5, popup=folium.Popup(popup_html, max_width=300) ).add_to(m) # Note: Shapely expects (lon, lat) hex_polys.append(Polygon([(lon, lat) for lat, lon in coords])) # 7) Union hex polygons for the PCI intersection cluster_union = unary_union(hex_polys) # 8) Load PCI GeoJSON, set & transform CRS if os.path.exists(pci_path): pci_gdf = gpd.read_file(pci_path) pci_gdf = pci_gdf.set_crs("EPSG:2278", allow_override=True).to_crs(epsg=4326) # 9) Filter line segments that intersect the cluster_union lines = pci_gdf[pci_gdf.intersects(cluster_union)] if not lines.empty: # 10) Serialize any Timestamp fields to string for col in lines.columns: if pd.api.types.is_datetime64_any_dtype(lines[col]): lines[col] = lines[col].astype(str) # 11) Add PCI lines as a GeoJson layer folium.GeoJson( data=lines.to_json(), name="PCI Lines", popup=folium.GeoJsonPopup( fields=[c for c in lines.columns if c != 'geometry'], aliases=[c.replace('_', ' ').title() for c in lines.columns if c != 'geometry'], localize=True ), style_function=lambda feat: {'color': 'red', 'weight': 2} ).add_to(m) # 12) Add layer control so overlays are visible/toggleable folium.LayerControl().add_to(m) # 13) Generate the Shapefile ZIP tmp = tempfile.mkdtemp() base = os.path.join(tmp, 'focus_area') # Crucial Fix: Shapely needs (lon, lat) while h3 provides (lat, lon) shp_polys = [Polygon([(lon, lat) for lat, lon in h3.cell_to_boundary(h)]) for h in table['GRID_ID']] gdf = gpd.GeoDataFrame(table, geometry=shp_polys, crs='EPSG:4326') gdf.to_file(base + '.shp') zip_path = base + '.zip' with zipfile.ZipFile(zip_path, 'w') as zf: for f in glob.glob(base + '.*'): if not f.endswith('.zip'): zf.write(f, arcname=os.path.basename(f)) # Return the zip_path to the Gradio file component instead of None return "Clusters, PCI overlay, and Shapefile ready!", m._repr_html_(), table, zip_path # Build Gradio app def create_app(): # If load_data fails during initial boot without excel file, we can bypass or load a dummy try: df = load_data() except FileNotFoundError: df = pd.DataFrame() with gr.Blocks() as app: gr.Markdown("# Focus Area Identification by creating a Risk Index") gr.Markdown("Build a custom risk index for Houston thoroughfare hexes by assigning feature weights.") href = "https://www.mermaidchart.com/raw/7431d57c-8e4d-47ec-bb22-ec64c34f5d03?theme=light&version=v0.1&format=svg" gr.Markdown(f"[This is a link to view the behind the scenes flow chart]({href})") # Feature Weights gr.Markdown("### Step 1: Assign weights to features") init = pd.DataFrame({ "Feature": ALL_FEATURES, "Description": [FEATURE_DESCRIPTIONS[f] for f in ALL_FEATURES], "Weight": [0]*len(ALL_FEATURES) }) wf = gr.Dataframe(value=init, headers=["Feature","Description","Weight"], datatype=["str","str","number"], interactive=True) btn1 = gr.Button("Calculate Index") stat_msg = gr.Markdown() out_stats = gr.Markdown() out_hist = gr.Plot() out_map = gr.HTML() def calc(wt): try: df_wt = pd.DataFrame(wt, columns=["Feature","Description","Weight"]) if isinstance(wt, list) else wt df_wt['Weight'] = pd.to_numeric(df_wt['Weight'], errors='coerce').fillna(0) sel = df_wt[df_wt['Weight']>0] if not np.isclose(sel['Weight'].sum(), 100): raise ValueError(f"Weights sum to {sel['Weight'].sum():.2f}, must be 100.") df_idx = calculate_index(df, sel['Feature'].tolist(), sel['Weight'].tolist()) return "Risk Index created!", stats_text(df_idx), make_histogram(df_idx), create_index_map(df_idx, sel['Feature'].tolist()) except Exception as e: return f"⚠️ Error calculating index: {e}", None, None, None btn1.click(calc, inputs=[wf], outputs=[stat_msg, out_stats, out_hist, out_map]) # Clustering gr.Markdown("### Step 2: Prioritized Focus Area Creation") thr = gr.Number(label="Index Threshold", value=3) ms = gr.Number(label="Minimum Cluster Size", value=3) btn2 = gr.Button("Create Clusters") cl_msg = gr.Markdown() cl_map = gr.HTML() cl_tbl = gr.Dataframe(label="Focus Area Hexes Data") cl_zip = gr.File(label="Download Focus Area Shapefile Zip") def cluster(wt, t, msize): try: df_wt = pd.DataFrame(wt, columns=["Feature","Description","Weight"]) if isinstance(wt, list) else wt df_wt['Weight'] = pd.to_numeric(df_wt['Weight'], errors='coerce').fillna(0) sel = df_wt[df_wt['Weight']>0] if not np.isclose(sel['Weight'].sum(), 100): raise ValueError(f"Weights sum to {sel['Weight'].sum():.2f}, must be 100.") df_idx = calculate_index(df, sel['Feature'].tolist(), sel['Weight'].tolist()) return create_cluster_outputs(df_idx, t, int(msize)) except Exception as e: return f"⚠️ Error clustering: {e}", None, None, None btn2.click(cluster, inputs=[wf, thr, ms], outputs=[cl_msg, cl_map, cl_tbl, cl_zip]) return app if __name__ == '__main__': app = create_app() app.launch()