Spaces:
Runtime error
Runtime error
| """ | |
| Streamlit UI for the Bernalillo County Urban Opportunity Index, | |
| designed for legal aid staff and community members in Bernalillo County. | |
| Run with: | |
| streamlit run ui.py | |
| """ | |
| import os | |
| import geopandas as gpd | |
| import pandas as pd | |
| import streamlit as st | |
| import folium | |
| from streamlit_folium import st_folium | |
| from bern_uoi_pipeline import build_bern_uoi, ACS_YEAR, OUTPUT_DIR | |
| DATA_GPKG = os.path.join(OUTPUT_DIR, "bern_uoi_tracts.gpkg") | |
| DATA_LAYER = "tracts" | |
| # ========================= | |
| # DATA LOADING | |
| # ========================= | |
| def load_data() -> gpd.GeoDataFrame: | |
| """ | |
| Load Bernalillo UOI data from GeoPackage if present. | |
| If key fields (like eviction_resilience_score) are missing, rebuild. | |
| """ | |
| if os.path.exists(DATA_GPKG): | |
| gdf = gpd.read_file(DATA_GPKG, layer=DATA_LAYER) | |
| print(f"Loaded existing {DATA_GPKG}") | |
| # If this is an older file without eviction columns, rebuild | |
| if "eviction_resilience_score" not in gdf.columns: | |
| print("Old data detected → rebuilding Bernalillo UOI dataset...") | |
| gdf = build_bern_uoi(refresh=True) | |
| else: | |
| gdf = build_bern_uoi(refresh=True) | |
| return gdf | |
| # ========================= | |
| # MAP BUILDER | |
| # ========================= | |
| def make_choropleth(gdf: gpd.GeoDataFrame, color_col: str, legend_name: str) -> folium.Map: | |
| """ | |
| Build a simple choropleth map for the chosen indicator. | |
| Values are clipped to the 5th–95th percentile range so that | |
| a few extreme tracts do not distort the color scale. | |
| """ | |
| # Map center = mean of tract centroids | |
| centroids = gdf.geometry.centroid | |
| center_lat = centroids.y.mean() | |
| center_lon = centroids.x.mean() | |
| # Copy and create a clipped plotting column | |
| gdf_plot = gdf.copy() | |
| # Use to_numeric to handle strings like "<NA>" | |
| series = pd.to_numeric(gdf_plot[color_col], errors="coerce") | |
| if series.notna().sum() > 0: | |
| q_low = series.quantile(0.05) | |
| q_high = series.quantile(0.95) | |
| gdf_plot["_plot_val"] = series.clip(lower=q_low, upper=q_high) | |
| else: | |
| gdf_plot["_plot_val"] = series | |
| m = folium.Map( | |
| location=[center_lat, center_lon], | |
| zoom_start=11, | |
| tiles="CartoDB positron", | |
| ) | |
| folium.Choropleth( | |
| geo_data=gdf_plot.to_json(), | |
| data=gdf_plot, | |
| columns=["GEOID", "_plot_val"], | |
| key_on="feature.properties.GEOID", | |
| fill_color="YlGnBu", | |
| fill_opacity=0.8, | |
| line_opacity=0.3, | |
| nan_fill_opacity=0.15, | |
| legend_name=legend_name, | |
| ).add_to(m) | |
| # Hover tooltip uses the original (unclipped) values | |
| folium.GeoJson( | |
| gdf, | |
| style_function=lambda x: {"fillOpacity": 0, "color": "#444", "weight": 0.4}, | |
| tooltip=folium.features.GeoJsonTooltip( | |
| fields=["NAME", color_col], | |
| aliases=["Tract:", legend_name + ":"], | |
| localize=True, | |
| ), | |
| ).add_to(m) | |
| folium.LayerControl().add_to(m) | |
| return m | |
| # ========================= | |
| # APP LAYOUT | |
| # ========================= | |
| def main(): | |
| st.set_page_config( | |
| page_title="Bernalillo County Urban Opportunity Index", | |
| layout="wide", | |
| ) | |
| st.title("Bernalillo County Opportunity Map") | |
| st.markdown( | |
| """ | |
| This map shows how different parts of Bernalillo County compare on everyday basics like: | |
| - **Internet at home** | |
| - **Housing and rent** | |
| - **Health coverage** | |
| - **Poverty and income** | |
| - **Disability and education** | |
| - **Eviction risk** | |
| It’s meant to help **community members and legal aid staff** see where needs are greatest and | |
| where people may need extra support. | |
| """ | |
| ) | |
| gdf = load_data() | |
| # ----- SIDEBAR CONTROLS ----- | |
| st.sidebar.header("Pick what to see") | |
| group = st.sidebar.radio( | |
| "What do you want to look at?", | |
| ( | |
| "Overall opportunity", | |
| "Internet access", | |
| "Housing costs", | |
| "Health coverage", | |
| "Money & poverty", | |
| "Income", | |
| "Disability", | |
| "Education", | |
| "Eviction risk", | |
| ), | |
| ) | |
| value_type = st.sidebar.radio( | |
| "How should the numbers be shown?", | |
| ( | |
| "Opportunity score (0–1, higher = better)", | |
| "Original value (as collected)", | |
| ), | |
| ) | |
| # ----- COLUMN SELECTION ----- | |
| if group == "Overall opportunity": | |
| col_norm = "uoi_score" | |
| col_raw = "uoi_score" # same numbers, just labeled differently | |
| label = "Overall opportunity score (0–1)" | |
| elif group == "Internet access": | |
| col_norm = "norm_broadband" | |
| col_raw = "pct_broadband" | |
| label = "Households with home internet (%)" | |
| elif group == "Housing costs": | |
| col_norm = "norm_rent_burdened" | |
| col_raw = "pct_rent_burdened" | |
| label = "Households with high housing costs (%)" | |
| elif group == "Health coverage": | |
| col_norm = "norm_uninsured" | |
| col_raw = "pct_uninsured" | |
| label = "People without health insurance (%)" | |
| elif group == "Money & poverty": | |
| col_norm = "norm_poverty" | |
| col_raw = "pct_poverty" | |
| label = "People living below the poverty line (%)" | |
| elif group == "Income": | |
| col_norm = "norm_income" | |
| col_raw = "median_hh_income" | |
| label = "Median household income (dollars)" | |
| elif group == "Disability": | |
| col_norm = "norm_disability" | |
| col_raw = "pct_disability" | |
| label = "People living with a disability (%)" | |
| elif group == "Education": | |
| col_norm = "norm_hs_or_higher" | |
| col_raw = "pct_hs_or_higher" | |
| label = "Adults with high school or higher (%)" | |
| else: # Eviction risk | |
| # “Opportunity score” view: resilience (higher = safer) | |
| # “Original value” view: risk (higher = more pressure) | |
| col_norm = "eviction_resilience_score" | |
| col_raw = "eviction_risk_score" | |
| label = "Eviction risk score (0–1, higher = more risk)" | |
| if value_type.startswith("Opportunity"): | |
| color_col = col_norm | |
| legend_name = f"{group} (score 0–1)" | |
| else: | |
| color_col = col_raw | |
| legend_name = label | |
| # ----- SAFETY CHECKS ----- | |
| if color_col not in gdf.columns: | |
| st.warning( | |
| f"The column **{color_col}** is not available in the current data. " | |
| "This may happen if the file is from an older version of the pipeline. " | |
| "Try restarting after deleting old files in the `outputs/` folder, or pick a different topic." | |
| ) | |
| return | |
| plot_gdf = gdf[~gdf[color_col].isna()].copy() | |
| if plot_gdf.empty: | |
| st.warning( | |
| "No data are available yet for this topic. " | |
| "Try picking a different item in the sidebar." | |
| ) | |
| return | |
| # ----- MAIN LAYOUT: MAP + TABLE ----- | |
| col_map, col_table = st.columns([2, 1], gap="large") | |
| with col_map: | |
| st.subheader("Map") | |
| m = make_choropleth(plot_gdf, color_col=color_col, legend_name=legend_name) | |
| st_folium(m, width="100%", height=600) | |
| with col_table: | |
| st.subheader("Neighborhood list") | |
| df_table = plot_gdf[["GEOID", "NAME", color_col]].copy() | |
| df_table = df_table.sort_values(color_col, ascending=False).reset_index(drop=True) | |
| df_table.index = df_table.index + 1 | |
| df_table.rename( | |
| columns={ | |
| "NAME": "Tract name", | |
| color_col: legend_name, | |
| }, | |
| inplace=True, | |
| ) | |
| st.dataframe( | |
| df_table, | |
| use_container_width=True, | |
| hide_index=False, | |
| ) | |
| st.caption( | |
| "If you pick **Opportunity score**, higher numbers mean better access and lower hardship. " | |
| "If you pick **Original value**, you’ll see the raw numbers (percentages, dollar amounts, or distances) " | |
| "for each neighborhood." | |
| ) | |
| # ----- FOOTER / METHODS BLURB ----- | |
| with st.expander("How this map was made", expanded=False): | |
| st.markdown( | |
| """ | |
| **Where the data come from** | |
| - Public data from the U.S. Census Bureau’s American Community Survey ({}) | |
| - We look at each small area (“census tract”) in Bernalillo County. | |
| **What we measure** | |
| - **Internet access:** share of households that have any kind of home internet subscription | |
| - **Housing costs:** share of households paying 30% or more of their income on housing | |
| - **Health coverage:** share of people who do *not* have health insurance | |
| - **Poverty:** share of people living below the federal poverty line | |
| - **Income:** typical (median) household income in dollars | |
| - **Disability:** share of people living with a disability | |
| - **Education:** share of adults (25+) with a high school diploma, GED, or higher | |
| - **Eviction risk (proxy):** a 0–1 score built from rent burden and poverty | |
| **How the score works** | |
| - For each measure, we put neighborhoods on the same 0–1 scale so they can be compared. | |
| - Areas with **better internet**, **lower housing burden**, **more people insured**, **less poverty**, | |
| **higher incomes**, **fewer disability-related barriers**, and **higher education levels** get **higher scores**. | |
| - The overall opportunity score is the average of all of these pieces. | |
| In short: **higher scores = more opportunity and easier access to basics.** | |
| """.format(ACS_YEAR) | |
| ) | |
| if __name__ == "__main__": | |
| main() |