File size: 12,296 Bytes
27842e4
 
00b70ec
27842e4
0ba1155
27842e4
 
1470e01
e83f295
 
 
 
1661019
ebd99a3
0da2389
adbb525
 
f54358b
bb0256a
27842e4
0da2389
33cad06
 
 
39663f4
 
33cad06
 
 
 
 
 
 
 
0da2389
 
adbb525
d961ef7
2516134
6126b25
adbb525
 
 
 
 
 
 
 
2516134
6126b25
dc622ef
adbb525
 
dc622ef
 
adbb525
 
2516134
6126b25
adbb525
 
2516134
bb0256a
adbb525
6126b25
0ba1155
adbb525
0ba1155
47c89eb
0ba1155
adbb525
0ba1155
 
f54358b
 
 
 
857736e
f54358b
2ff4dc9
f54358b
fc423f0
2ff4dc9
 
 
 
 
f54358b
0ba1155
857736e
f54358b
 
 
 
 
 
 
 
 
 
adbb525
47c89eb
 
f54358b
 
47c89eb
f86d7a2
f54358b
2ff4dc9
adbb525
2ff4dc9
33cad06
47c89eb
 
 
2ff4dc9
47c89eb
33cad06
47c89eb
dc622ef
47c89eb
33cad06
0ba1155
33cad06
adbb525
1504a7c
 
 
adbb525
1504a7c
 
 
 
 
 
 
 
 
 
 
 
 
7a7462f
1504a7c
adbb525
d2645ba
1504a7c
 
adbb525
 
1504a7c
 
 
adbb525
 
1504a7c
adbb525
 
d2645ba
adbb525
 
1504a7c
 
adbb525
 
1504a7c
 
adbb525
 
 
 
 
 
 
 
 
 
 
 
 
 
1504a7c
adbb525
7a7462f
adbb525
 
 
7a7462f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
adbb525
 
1504a7c
 
7a7462f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1504a7c
d2645ba
2ff4dc9
7a7462f
 
 
 
 
 
2ff4dc9
dc622ef
2ff4dc9
adbb525
 
 
 
2ff4dc9
adbb525
 
 
 
 
 
 
 
 
 
 
005fd45
adbb525
 
 
 
005fd45
adbb525
 
 
005fd45
adbb525
005fd45
 
 
adbb525
 
2ff4dc9
adbb525
 
 
 
 
 
d2645ba
adbb525
 
 
 
 
 
 
 
 
d2645ba
adbb525
005fd45
adbb525
d2645ba
 
adbb525
 
 
 
 
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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
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 = "<br>".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"<b>Hex:</b> {h3_id}<br>"
            f"<b>Index:</b> {row['Index']:.2f}<br>"
            + "<br>".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()