| |
| """ |
| Wildfire susceptibility & aircraft-allocation decision-support dashboard — Andalusia (Spain). |
| |
| Companion web application for the accompanying manuscript. |
| Following the scope described in the paper (Section 2.7), the app takes a pre-computed fire |
| **susceptibility** map (raster, 0-1) and supports the **allocation** stage only: it clusters the |
| high-susceptibility areas (K-means), proposes candidate sites for firefighting infrastructure, |
| and evaluates coverage against the real INFOCA aircraft bases and the GPS-validated 2008 fires. |
| |
| Run locally: streamlit run app.py |
| """ |
| import os |
| |
| |
| for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"): |
| os.environ.setdefault(_v, "4") |
| import tempfile |
|
|
| import numpy as np |
| import pandas as pd |
| import geopandas as gpd |
| from rasterio.features import geometry_mask |
|
|
| import folium |
| from streamlit_folium import st_folium |
| import streamlit as st |
| import plotly.graph_objects as go |
|
|
| import matplotlib |
| matplotlib.use("Agg") |
| from matplotlib.colors import LinearSegmentedColormap, Normalize, to_rgba |
|
|
| from alocacao_core import ( |
| carregar_raster, executar_alocacao, distancia_ao_mais_proximo, cobertura, suavizar_risco, |
| ) |
|
|
| try: |
| from streamlit_option_menu import option_menu |
| HAS_MENU = True |
| except Exception: |
| HAS_MENU = False |
|
|
| |
| AQUI = os.path.dirname(os.path.abspath(__file__)) |
| D = os.path.join(AQUI, "dados") |
| RASTER = os.path.join(D, "mapa_risco_andaluzia.tif") |
| BORDER = os.path.join(D, "andaluzia.geojson") |
| PROV = os.path.join(D, "provincias_andaluzia.geojson") |
| BASES = os.path.join(D, "bases_andaluzia_reais.csv") |
| FOCOS = os.path.join(D, "focos_incendio_2008.csv") |
| FACIL = os.path.join(D, "_facilidades.pkl") |
| COMPAR = os.path.join(D, "comparacao_alocacao.csv") |
|
|
| PAPER_URL = "https://doi.org/XX.XXXX/XXXXX" |
| CITATION = ("Author(s) (2026). Modelling wildfire susceptibility and optimizing the allocation of " |
| "firefighting aircraft using artificial intelligence. Manuscript in preparation.") |
|
|
| |
| SUS_HEX = ["#1a9850", "#fee08b", "#d73027"] |
| CMAP = LinearSegmentedColormap.from_list("sus", SUS_HEX) |
| ZONE_HEX = ["#1a9850", "#a6d96a", "#fee08b", "#fc8d59", "#d73027"] |
| ZONE_LABELS = ["Very low", "Low", "Medium", "High", "Very high"] |
| EDGES = [0.2, 0.4, 0.6, 0.8] |
| |
| |
| |
| GREEN_DARK = "#1e5631" |
| GRAY_DARK = "#4d4d4d" |
| GRAY = "#888888" |
| MODEL_EN = {"K-means": "K-means", "p-mediana": "p-median", "p-centro": "p-center", "MCLP": "MCLP"} |
|
|
| st.set_page_config(page_title="Andalusia — Wildfire susceptibility & aircraft allocation", |
| layout="wide", initial_sidebar_state="expanded") |
|
|
| |
| st.markdown(""" |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); |
| :root { --accent:#1e5631; --accent2:#2e8b57; --ink:#1f2a37; --muted:#6b7280; --line:#e6e8ec; } |
| html, body, [data-testid="stAppViewContainer"], [data-testid="stSidebar"], [class*="css"] { |
| font-family:'Inter',system-ui,-apple-system,sans-serif; } |
| [data-testid="stAppViewContainer"] { background:#f6f7f9; } |
| [data-testid="stHeader"] { background:transparent; } |
| [data-testid="stDecoration"] { display:none; } |
| .block-container { padding-top:3.2rem; padding-bottom:2.5rem; max-width:1440px; } |
| |
| /* sidebar */ |
| [data-testid="stSidebar"] { background:#eef0f2; border-right:1px solid #e3e6e9; } |
| [data-testid="stSidebar"] > div:first-child { background:#eef0f2; } |
| .side-brand { background:linear-gradient(135deg,#1e5631,#2e8b57); color:#fff; border-radius:12px; |
| padding:13px 15px; margin:2px 0 14px 0; box-shadow:0 3px 10px rgba(30,86,49,.22); } |
| .side-brand b { font-size:.92rem; font-weight:700; display:block; line-height:1.25; } |
| .side-brand span { font-size:.72rem; opacity:.9; } |
| [data-testid="stSidebar"] h3, [data-testid="stSidebar"] h6 { |
| font-size:.7rem !important; text-transform:uppercase; letter-spacing:.09em; |
| color:#79828b !important; font-weight:700; margin:.55rem 0 .2rem 0; } |
| [data-testid="stSidebar"] hr { margin:.7rem 0; border-color:#dfe3e7; } |
| |
| /* section nav (fallback radio -> pills) */ |
| .block-container div[role="radiogroup"] { flex-direction:row; flex-wrap:wrap; gap:5px; |
| background:#fff; border:1px solid var(--line); border-radius:12px; padding:5px; |
| box-shadow:0 1px 2px rgba(16,24,40,.05); } |
| .block-container div[role="radiogroup"] label { border-radius:9px; padding:6px 13px; margin:0; cursor:pointer; } |
| .block-container div[role="radiogroup"] label > div:first-child { display:none; } |
| .block-container div[role="radiogroup"] label:hover { background:#eef2ee; } |
| .block-container div[role="radiogroup"] label:has(input:checked) { background:var(--accent); } |
| .block-container div[role="radiogroup"] label:has(input:checked) * { color:#fff !important; } |
| |
| /* page header */ |
| .page-title { font-size:1.62rem; font-weight:700; color:var(--ink); letter-spacing:-.01em; margin:.1rem 0 .9rem 0; } |
| .page-title::after { content:""; display:block; width:56px; height:3px; border-radius:3px; |
| background:linear-gradient(90deg,var(--accent),var(--accent2)); margin-top:9px; } |
| .lede { color:var(--muted); font-size:.95rem; margin:0 0 .6rem 0; max-width:1050px; } |
| .lede a { color:var(--accent); } |
| |
| /* KPI cards */ |
| .kpi { background:#fff; border:1px solid var(--line); border-radius:16px; padding:16px 18px 15px 20px; |
| box-shadow:0 1px 2px rgba(16,24,40,.05); height:100%; position:relative; overflow:hidden; |
| transition:transform .13s ease, box-shadow .13s ease; } |
| .kpi::before { content:""; position:absolute; left:0; top:0; bottom:0; width:4px; |
| background:linear-gradient(var(--accent),var(--accent2)); } |
| .kpi:hover { transform:translateY(-2px); box-shadow:0 8px 22px rgba(16,24,40,.09); } |
| .kpi .v { font-size:1.8rem; font-weight:700; color:var(--ink); line-height:1.05; } |
| .kpi .l { font-size:.82rem; color:var(--muted); margin-top:3px; font-weight:500; } |
| .kpi .s { font-size:.74rem; color:#9aa1ac; margin-top:5px; } |
| |
| /* section titles + misc */ |
| .sec-title { font-weight:700; color:var(--ink); font-size:1.16rem; margin:.7rem 0 .5rem 0; |
| padding-left:11px; border-left:4px solid var(--accent); } |
| .small { color:var(--muted); font-size:.85rem; } |
| div[data-testid="stMetricValue"] { font-size:1.4rem; } |
| [data-testid="stDataFrame"] { border:1px solid var(--line); border-radius:12px; overflow:hidden; } |
| .stDownloadButton button, .stButton button { border-radius:10px; font-weight:600; } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| @st.cache_resource(show_spinner=False) |
| def load_raster(path): |
| return carregar_raster(path) |
|
|
| @st.cache_data(show_spinner=False) |
| def smoothed(path): |
| """Median-filtered map for display/zoning (as in the paper's figures); metrics use the raw map.""" |
| _, data = load_raster(path) |
| return np.nan_to_num(suavizar_risco(data, 5), nan=0.0) |
|
|
| @st.cache_data(show_spinner=False) |
| def load_csv(path, **kw): |
| return pd.read_csv(path, **kw) if os.path.exists(path) else None |
|
|
| @st.cache_data(show_spinner=False) |
| def load_geo(path): |
| return gpd.read_file(path) if os.path.exists(path) else None |
|
|
| @st.cache_data(show_spinner=False) |
| def load_facilities(path): |
| return pd.read_pickle(path) if os.path.exists(path) else None |
|
|
| def _fix_enc(s): |
| """Recover accents in strings saved as UTF-8 bytes decoded as latin-1 (mojibake).""" |
| if isinstance(s, str) and ("Ã" in s or "Â" in s): |
| try: |
| return s.encode("latin-1").decode("utf-8") |
| except Exception: |
| return s |
| return s |
|
|
| @st.cache_data(show_spinner=False) |
| def load_bases(path): |
| if not os.path.exists(path): |
| return None |
| b = pd.read_csv(path) |
| for c in ("nome", "provincia", "tipo"): |
| if c in b.columns: |
| b[c] = b[c].map(_fix_enc) |
| return b |
|
|
| @st.cache_data(show_spinner=False) |
| def allocate(path, k, thr): |
| src, data = load_raster(path) |
| r = executar_alocacao(src, data, n_clusters=int(k), limiar_risco=float(thr)) |
| return r.centroides, r.rotulos, r.pontos_lonlat, r.pesos, int(r.n_pontos) |
|
|
| @st.cache_data(show_spinner=False) |
| def zoning_stats(path): |
| """% of area and % of 2008 fires per susceptibility class (Table 4).""" |
| src, data = load_raster(path) |
| sm = smoothed(path) |
| border = load_geo(BORDER) |
| inside = geometry_mask([g for g in border.geometry], (src.height, src.width), |
| src.transform, invert=True) |
| valid = inside & (sm > 0) |
| cls = np.digitize(sm[valid], EDGES) |
| area_pct = [100 * float(np.mean(cls == i)) for i in range(5)] |
| foc = load_csv(FOCOS) |
| fv = np.array([v[0] for v in src.sample(foc[["lon", "lat"]].values)], dtype=float) |
| fv = fv[np.isfinite(fv)] |
| fcls = np.digitize(fv, EDGES) |
| foco_pct = [100 * float(np.mean(fcls == i)) for i in range(5)] |
| return area_pct, foco_pct |
|
|
| @st.cache_data(show_spinner=False) |
| def province_stats(path): |
| """Mean susceptibility and % of high-susceptibility area (>0.6) per province (Table 5).""" |
| src, data = load_raster(path) |
| prov = load_geo(PROV) |
| rows = [] |
| for _, p in prov.iterrows(): |
| m = (geometry_mask([p.geometry], (src.height, src.width), src.transform, invert=True) |
| & np.isfinite(data) & (data > 0)) |
| v = data[m] |
| if v.size: |
| rows.append((p.get("name", "?"), round(float(v.mean()), 2), |
| round(100 * float(np.mean(v > 0.6)), 1))) |
| df = pd.DataFrame(rows, columns=["Province", "Mean susceptibility", "High-susc. area (%)"]) |
| return df.sort_values("Mean susceptibility", ascending=False).reset_index(drop=True) |
|
|
| |
| def _ybounds(src): |
| return min(src.bounds.top, src.bounds.bottom), max(src.bounds.top, src.bounds.bottom) |
|
|
| def base_map(src): |
| clat = (src.bounds.top + src.bounds.bottom) / 2 |
| clon = (src.bounds.left + src.bounds.right) / 2 |
| m = folium.Map(location=[clat, clon], zoom_start=7, tiles=None, control_scale=True) |
| folium.TileLayer("cartodbpositron", name="Light basemap").add_to(m) |
| folium.TileLayer("OpenStreetMap", name="OpenStreetMap", show=False).add_to(m) |
| folium.TileLayer( |
| "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", |
| attr="Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics", |
| name="Satellite (Esri)", show=False).add_to(m) |
| return m |
|
|
| def add_susceptibility(m, src, data, opacity=0.72): |
| rgba = CMAP(Normalize(0, 1)(np.clip(data, 0, 1))) |
| rgba[..., 3] = np.where(data > 0, 1.0, 0.0) |
| ymin, ymax = _ybounds(src) |
| folium.raster_layers.ImageOverlay( |
| image=np.flipud(rgba), bounds=[[ymin, src.bounds.left], [ymax, src.bounds.right]], |
| opacity=opacity, name="Fire susceptibility", interactive=False, zindex=1).add_to(m) |
|
|
| def add_zoning(m, src, data, opacity=0.8): |
| cls = np.digitize(np.clip(data, 0, 1), EDGES) |
| rgba = np.zeros((*data.shape, 4)) |
| for i, c in enumerate(ZONE_HEX): |
| r, g, b, _ = to_rgba(c) |
| rgba[cls == i] = [r, g, b, 1.0] |
| rgba[..., 3] = np.where(data > 0, 1.0, 0.0) |
| ymin, ymax = _ybounds(src) |
| folium.raster_layers.ImageOverlay( |
| image=np.flipud(rgba), bounds=[[ymin, src.bounds.left], [ymax, src.bounds.right]], |
| opacity=opacity, name="Susceptibility zoning", interactive=False, zindex=1).add_to(m) |
|
|
| def add_border(m, border, prov=None, show_prov=False): |
| if prov is not None and show_prov: |
| folium.GeoJson(prov, name="Provinces", |
| style_function=lambda x: {"color": "#3b3b6b", "weight": 1, "fill": False}).add_to(m) |
| if border is not None: |
| folium.GeoJson(border, name="Andalusia", |
| style_function=lambda x: {"color": "#111", "weight": 2, "fill": False}).add_to(m) |
|
|
| def add_centroids(m, cen, radius_km=None, color=GREEN_DARK, label="Proposed centroid"): |
| for i, (lon, lat) in enumerate(cen): |
| if radius_km: |
| folium.Circle([lat, lon], radius=radius_km * 1000, color=color, weight=1, |
| fill=True, fill_opacity=0.05).add_to(m) |
| folium.Marker([lat, lon], tooltip=f"{label} {i + 1}", |
| icon=folium.Icon(color="black", icon="plane", prefix="fa")).add_to(m) |
|
|
| def add_bases(m, bases): |
| fg = folium.FeatureGroup(name="INFOCA bases").add_to(m) |
| for _, b in bases.iterrows(): |
| folium.Marker([b["lat"], b["lon"]], |
| tooltip=f"{b.get('nome','base')} — {b.get('tipo','')} ({b.get('provincia','')})", |
| icon=folium.Icon(color="gray", icon="fire", prefix="fa")).add_to(fg) |
|
|
| def render_fires(m, focos_xy, show_all=False, gap_mask=None, show_gaps=False): |
| """Draw the 2008 fires: all of them (dark dots) and/or the uncovered ones (magenta).""" |
| if focos_xy is None: |
| return |
| if show_all: |
| fg = folium.FeatureGroup(name="2008 fires").add_to(m) |
| for lon, lat in focos_xy: |
| folium.CircleMarker([lat, lon], radius=1, color="#4d4d4d", fill=True, opacity=0.35).add_to(fg) |
| if show_gaps and gap_mask is not None and gap_mask.any(): |
| fg2 = folium.FeatureGroup(name="Uncovered fires").add_to(m) |
| for lon, lat in focos_xy[gap_mask]: |
| folium.CircleMarker([lat, lon], radius=3, color="#111111", weight=1.5, fill=True, |
| fill_color="#ffffff", fill_opacity=1.0).add_to(fg2) |
|
|
| SUS_LEGEND = """<div style="position:fixed;bottom:26px;left:12px;z-index:9999;background:rgba(255,255,255,.92); |
| padding:8px 10px;border-radius:8px;font-size:12px;border:1px solid #bbb;"> |
| <b>Fire susceptibility</b><br> |
| <span style="background:linear-gradient(to right,#1a9850,#fee08b,#d73027);display:inline-block;width:130px;height:10px;"></span><br> |
| <span style="float:left;">low</span><span style="float:right;">high</span></div>""" |
|
|
| def show_map(m, height=560, legend=True): |
| if legend: |
| m.get_root().html.add_child(folium.Element(SUS_LEGEND)) |
| folium.LayerControl(collapsed=True).add_to(m) |
| st_folium(m, width=None, height=height, returned_objects=[]) |
|
|
| def kpi(col, value, label, sub=""): |
| col.markdown(f'<div class="kpi"><div class="v">{value}</div><div class="l">{label}</div>' |
| f'<div class="s">{sub}</div></div>', unsafe_allow_html=True) |
|
|
| |
| if not os.path.exists(RASTER): |
| st.error("Susceptibility raster not found in `dados/`. Please check the deployment data files.") |
| st.stop() |
|
|
| with st.sidebar: |
| st.markdown('<div class="side-brand"><b>Andalusia · Wildfire allocation</b>' |
| '<span>Decision-support dashboard</span></div>', unsafe_allow_html=True) |
| st.markdown("### Controls") |
| up = st.file_uploader("Custom susceptibility raster (GeoTIFF 0–1)", type=["tif", "tiff"]) |
| path = RASTER |
| if up is not None: |
| tmp = os.path.join(tempfile.gettempdir(), "andalusia_upload.tif") |
| with open(tmp, "wb") as f: |
| f.write(up.getbuffer()) |
| path = tmp |
| st.caption("Using uploaded raster.") |
| k = st.slider("Number of clusters (K)", 5, 60, 31, 1, |
| help="31 = number of real INFOCA bases (paired comparison).") |
| thr = st.slider("Susceptibility threshold", 0.50, 1.00, 0.75, 0.01, |
| help="Relative to the map maximum; clustering uses pixels above it.") |
| radius = st.slider("Operational radius (km)", 5, 60, 25, 1, |
| help="≈ initial-attack reach of aerial means (helicopter ~6–8 min).") |
| st.markdown("---") |
| st.markdown("###### Map layers") |
| ly_sus = st.checkbox("Susceptibility", True) |
| ly_bases = st.checkbox("INFOCA bases", True) |
| ly_focos = st.checkbox("2008 fires", False) |
| ly_gaps = st.checkbox("Highlight uncovered fires", True) |
| gap_ref = st.radio("Uncovered relative to", ["Proposed centroids", "Real INFOCA bases"], |
| index=0, disabled=not ly_gaps, |
| help="Which network's coverage gaps to highlight in magenta on the map.") |
| ly_prov = st.checkbox("Province borders", False) |
|
|
| src, data = load_raster(path) |
| data_disp = smoothed(path) |
| bases = load_bases(BASES) |
| focos = load_csv(FOCOS) |
| prov = load_geo(PROV) |
| border = load_geo(BORDER) |
| facil = load_facilities(FACIL) |
| compar = load_csv(COMPAR, index_col=0) |
| bases_xy = bases[["lon", "lat"]].values if bases is not None else None |
| focos_xy = focos[["lon", "lat"]].values if focos is not None else None |
|
|
| cen, rot, pts, pesos, npts = allocate(path, k, thr) |
| d_base = distancia_ao_mais_proximo(bases_xy, cen) if bases_xy is not None else None |
| cov_cen = cobertura(focos_xy, cen, radius) if focos_xy is not None else None |
| cov_base = cobertura(focos_xy, bases_xy, radius) if (focos_xy is not None and bases_xy is not None) else None |
| |
| if focos_xy is not None and gap_ref == "Real INFOCA bases" and cov_base is not None: |
| gap_mask = ~cov_base |
| elif focos_xy is not None and cov_cen is not None: |
| gap_mask = ~cov_cen |
| else: |
| gap_mask = None |
| |
| if gap_ref == "Real INFOCA bases" and cov_base is not None: |
| ref_cov, oth_cov, ref_short, oth_short = cov_base, cov_cen, "real bases", "centroids" |
| else: |
| ref_cov, oth_cov, ref_short, oth_short = cov_cen, cov_base, "centroids", "real bases" |
|
|
| |
| SECTIONS = ["Overview", "Allocation", "Coverage & gaps", "Location models", |
| "Susceptibility zoning", "Scenarios", "About"] |
| if HAS_MENU: |
| page = option_menu(None, SECTIONS, |
| icons=["speedometer2", "airplane", "bullseye", "diagram-3", |
| "layers", "sliders", "info-circle"], |
| orientation="horizontal", |
| styles={"container": {"padding": "5px", "background-color": "#ffffff", |
| "border": "1px solid #e6e8ec", "border-radius": "12px", |
| "box-shadow": "0 1px 2px rgba(16,24,40,.05)"}, |
| "nav-link": {"font-size": "0.86rem", "font-weight": "500", |
| "color": "#374151", "padding": "7px 12px", |
| "border-radius": "9px", "margin": "0 2px", |
| "--hover-color": "#eef2ee"}, |
| "icon": {"font-size": "0.9rem"}, |
| "nav-link-selected": {"background-color": "#1e5631", "color": "#ffffff", |
| "font-weight": "600"}}) |
| else: |
| page = st.radio("Section", SECTIONS, horizontal=True, label_visibility="collapsed") |
|
|
| |
| if page == "Overview": |
| st.markdown('<div class="page-title">Wildfire susceptibility & firefighting-aircraft allocation' |
| ' — Andalusia (Spain)</div>', unsafe_allow_html=True) |
|
|
| c1, c2, c3, c4 = st.columns(4) |
| kpi(c1, f"{d_base.mean():.1f} km" if d_base is not None else "—", |
| "Mean base → nearest centroid", |
| f"median {np.median(d_base):.1f} · max {d_base.max():.1f} km" if d_base is not None else "") |
| if ref_cov is not None: |
| delta = (ref_cov.mean() - oth_cov.mean()) * 100 if oth_cov is not None else 0 |
| kpi(c2, f"{100*ref_cov.mean():.1f}%", f"2008 fires covered ≤{radius} km", |
| (f"{ref_short} · {delta:+.1f} p.p. vs {oth_short}" if oth_cov is not None else ref_short)) |
| kpi(c3, f"{len(cen)}", "Proposed centroids", f"from {npts:,} high-susceptibility pixels") |
| kpi(c4, f"{len(bases) if bases is not None else 0}", "Real INFOCA bases", "CEDEFO · PISTA · BRICA · aux") |
|
|
| st.markdown('<div class="sec-title">Reference allocation map</div>', unsafe_allow_html=True) |
| m = base_map(src) |
| if ly_sus: |
| add_susceptibility(m, src, data_disp) |
| add_border(m, border, prov, ly_prov) |
| render_fires(m, focos_xy, show_all=ly_focos, gap_mask=gap_mask, show_gaps=ly_gaps) |
| add_centroids(m, cen, radius_km=radius) |
| if ly_bases and bases is not None: |
| add_bases(m, bases) |
| show_map(m, height=560, legend=ly_sus) |
| cap = (f"K = {k} · threshold = {thr:.2f} · radius = {radius} km. " |
| "Blue: proposed centroids (coverage circles). Red: real INFOCA bases.") |
| if ly_gaps and gap_mask is not None: |
| cap += f" Magenta: 2008 fires uncovered by {gap_ref.lower()} (≤{radius} km)." |
| st.caption(cap) |
|
|
| |
| elif page == "Allocation": |
| st.markdown('<div class="sec-title">K-means allocation from the susceptibility map</div>', |
| unsafe_allow_html=True) |
| st.markdown('<p class="small">Adjust K, the susceptibility threshold and the operational radius in the ' |
| 'sidebar. Centroids gravitate toward the highest-susceptibility areas (risk-weighted K-means).</p>', |
| unsafe_allow_html=True) |
|
|
| a, b = st.columns([2.3, 1]) |
| with a: |
| m = base_map(src) |
| if ly_sus: |
| add_susceptibility(m, src, data_disp) |
| add_border(m, border, prov, ly_prov) |
| render_fires(m, focos_xy, show_all=ly_focos, gap_mask=gap_mask, show_gaps=ly_gaps) |
| add_centroids(m, cen, radius_km=radius) |
| if ly_bases and bases is not None: |
| add_bases(m, bases) |
| show_map(m, height=580, legend=ly_sus) |
| with b: |
| st.metric("Proposed centroids", len(cen)) |
| if d_base is not None: |
| st.metric("Mean base → centroid", f"{d_base.mean():.1f} km", |
| help=f"median {np.median(d_base):.1f} km · max {d_base.max():.1f} km") |
| if cov_cen is not None: |
| st.metric(f"Fires covered ≤{radius} km", f"{100*cov_cen.mean():.1f}%", |
| delta=(f"{100*(cov_cen.mean()-cov_base.mean()):+.1f} p.p. vs bases" |
| if cov_base is not None else None)) |
| st.metric("High-susceptibility pixels", f"{npts:,}") |
|
|
| st.markdown('<div class="sec-title">Per-cluster statistics</div>', unsafe_allow_html=True) |
| dvar = distancia_ao_mais_proximo(cen, bases_xy) if bases_xy is not None else [None] * len(cen) |
| dfc = pd.DataFrame({ |
| "cluster": range(1, len(cen) + 1), |
| "lat": np.round(cen[:, 1], 4), "lon": np.round(cen[:, 0], 4), |
| "mean susceptibility": np.round( |
| [pesos[rot == i].mean() if np.any(rot == i) else 0 for i in range(len(cen))], 3), |
| "pixels": [int((rot == i).sum()) for i in range(len(cen))], |
| "dist. to nearest base (km)": np.round(dvar, 1) if bases_xy is not None else dvar, |
| }) |
| st.dataframe(dfc, hide_index=True, width="stretch", height=300) |
| st.download_button("Download proposed centroids (CSV)", |
| dfc.to_csv(index=False).encode("utf-8"), "proposed_centroids.csv", "text/csv") |
|
|
| |
| elif page == "Coverage & gaps": |
| st.markdown('<div class="sec-title">Coverage of 2008 fires vs operational radius</div>', |
| unsafe_allow_html=True) |
| if focos_xy is None: |
| st.info("Fire data not available.") |
| else: |
| radii = list(range(5, 61, 5)) |
| cc = [100 * cobertura(focos_xy, cen, R).mean() for R in radii] |
| cb = ([100 * cobertura(focos_xy, bases_xy, R).mean() for R in radii] if bases_xy is not None else None) |
| fig = go.Figure() |
| fig.add_trace(go.Scatter(x=radii, y=cc, mode="lines+markers", name="Proposed centroids", |
| line=dict(color="#1e5631", width=3))) |
| if cb is not None: |
| fig.add_trace(go.Scatter(x=radii, y=cb, mode="lines+markers", name="Real INFOCA bases", |
| line=dict(color="#888888", width=3, dash="dash"))) |
| fig.add_vline(x=radius, line_dash="dot", line_color="#4d4d4d", |
| annotation_text=f"{radius} km", annotation_position="top") |
| fig.update_layout(height=420, xaxis_title="Operational radius (km)", |
| yaxis_title="2008 fires covered (%)", legend=dict(orientation="h", y=1.12), |
| margin=dict(l=10, r=10, t=30, b=10), template="plotly_white") |
| st.plotly_chart(fig, width="stretch") |
|
|
| c1, c2, c3 = st.columns(3) |
| kpi(c1, f"{100*cov_cen.mean():.1f}%", f"Covered by centroids ≤{radius} km") |
| if cov_base is not None: |
| kpi(c2, f"{100*cov_base.mean():.1f}%", f"Covered by real bases ≤{radius} km") |
| kpi(c3, f"{int((~ref_cov).sum()):,}", f"Uncovered fires ({ref_short})", f"of {len(focos_xy):,} total") |
|
|
| st.markdown('<div class="sec-title">Distance from each real base to the nearest proposed centroid</div>', |
| unsafe_allow_html=True) |
| if d_base is not None: |
| h = go.Figure(go.Histogram(x=d_base, nbinsx=14, marker_color=GREEN_DARK)) |
| h.add_vline(x=d_base.mean(), line_dash="dash", line_color="#4d4d4d", |
| annotation_text=f"mean {d_base.mean():.1f} km", annotation_position="top right") |
| h.update_layout(height=320, xaxis_title="km", yaxis_title="number of bases", |
| template="plotly_white", margin=dict(l=10, r=10, t=20, b=10)) |
| st.plotly_chart(h, width="stretch") |
|
|
| |
| elif page == "Location models": |
| st.markdown('<div class="sec-title">Facility-location models (p = 31)</div>', unsafe_allow_html=True) |
| st.markdown('<p class="small">Pre-computed operations-research benchmark: K-means, p-median (Hakimi, 1964), ' |
| 'p-center (worst-case response) and MCLP (maximal coverage; Church & ReVelle, 1974). ' |
| 'Each model wins on its own objective.</p>', unsafe_allow_html=True) |
| if compar is not None: |
| tbl = compar.rename(index=MODEL_EN) |
| tbl.columns = ["Mean weighted dist. (km)", "Max distance (km)", "Susceptibility covered ≤25 km (%)", |
| "2008 fires covered ≤25 km (%)", "Mean dist. to real bases (km)"] |
| st.dataframe(tbl.round(1), width="stretch") |
| metrics = [("2008 fires covered ≤25 km (%)", "#1e5631"), |
| ("Susceptibility covered ≤25 km (%)", "#888888")] |
| fig = go.Figure() |
| for met, col in metrics: |
| fig.add_trace(go.Bar(name=met, x=tbl.index, y=tbl[met], marker_color=col)) |
| fig.update_layout(barmode="group", height=360, template="plotly_white", |
| legend=dict(orientation="h", y=1.15), margin=dict(l=10, r=10, t=30, b=10), |
| yaxis_title="%") |
| st.plotly_chart(fig, width="stretch") |
|
|
| if facil is not None: |
| names = list(facil.keys()) |
| sel = st.selectbox("Show facilities for model", names, |
| format_func=lambda n: MODEL_EN.get(n, n)) |
| fac_xy = np.asarray(facil[sel]) |
| m = base_map(src) |
| add_susceptibility(m, src, data_disp) |
| add_border(m, border, prov, ly_prov) |
| if bases is not None: |
| add_bases(m, bases) |
| for i, (lon, lat) in enumerate(fac_xy): |
| folium.Marker([lat, lon], tooltip=f"{MODEL_EN.get(sel, sel)} facility {i + 1}", |
| icon=folium.Icon(color="black", icon="plane", prefix="fa")).add_to(m) |
| show_map(m, height=520) |
| st.caption("Blue: proposed facilities for the selected model. Red: real INFOCA bases.") |
|
|
| |
| elif page == "Susceptibility zoning": |
| st.markdown('<div class="sec-title">Susceptibility zoning (5 classes) and spatial validation</div>', |
| unsafe_allow_html=True) |
| a, b = st.columns([2.2, 1]) |
| with a: |
| m = base_map(src) |
| add_zoning(m, src, data_disp) |
| add_border(m, border, prov, True) |
| render_fires(m, focos_xy, show_all=ly_focos) |
| show_map(m, height=560, legend=False) |
| legend = "".join( |
| f'<span style="display:inline-block;margin:2px 10px 2px 0;"><span style="background:{c};' |
| f'display:inline-block;width:14px;height:14px;border:1px solid #999;vertical-align:middle;"></span> ' |
| f'{l}</span>' for c, l in zip(ZONE_HEX, ZONE_LABELS)) |
| st.markdown(f'<div class="small">Classes: {legend}</div>', unsafe_allow_html=True) |
| with b: |
| area_pct, foco_pct = zoning_stats(path) |
| fig = go.Figure() |
| fig.add_trace(go.Bar(name="% of area", x=ZONE_LABELS, y=area_pct, marker_color="#888888")) |
| fig.add_trace(go.Bar(name="% of 2008 fires", x=ZONE_LABELS, y=foco_pct, marker_color="#1e5631")) |
| fig.update_layout(barmode="group", height=340, template="plotly_white", |
| legend=dict(orientation="h", y=1.2), margin=dict(l=10, r=10, t=30, b=10), |
| yaxis_title="%") |
| st.plotly_chart(fig, width="stretch") |
| high = foco_pct[3] + foco_pct[4] |
| kpi(st.container(), f"{high:.1f}%", "2008 fires in High + Very high", "spatial validation") |
|
|
| st.markdown('<div class="sec-title">By province</div>', unsafe_allow_html=True) |
| pdf = province_stats(path) |
| c1, c2 = st.columns([1, 1.3]) |
| with c1: |
| st.dataframe(pdf, hide_index=True, width="stretch", height=330) |
| with c2: |
| fig = go.Figure(go.Bar(x=pdf["Mean susceptibility"], y=pdf["Province"], orientation="h", |
| marker_color="#1e5631")) |
| fig.update_layout(height=330, template="plotly_white", xaxis_title="Mean susceptibility", |
| margin=dict(l=10, r=10, t=10, b=10), yaxis=dict(autorange="reversed")) |
| st.plotly_chart(fig, width="stretch") |
|
|
| |
| elif page == "Scenarios": |
| st.markdown('<div class="sec-title">Compare two allocation scenarios</div>', unsafe_allow_html=True) |
| cA, cB = st.columns(2) |
| with cA: |
| st.markdown("**Scenario A**") |
| kA = st.slider("K — A", 5, 60, 31, 1, key="kA") |
| tA = st.slider("Threshold — A", 0.50, 1.00, 0.75, 0.01, key="tA") |
| with cB: |
| st.markdown("**Scenario B**") |
| kB = st.slider("K — B", 5, 60, 15, 1, key="kB") |
| tB = st.slider("Threshold — B", 0.50, 1.00, 0.85, 0.01, key="tB") |
|
|
| def scen(kk, tt): |
| c = allocate(path, kk, tt)[0] |
| covc = cobertura(focos_xy, c, radius) if focos_xy is not None else None |
| cc = covc.mean() * 100 if covc is not None else None |
| db = distancia_ao_mais_proximo(bases_xy, c).mean() if bases_xy is not None else None |
| gaps = int((~covc).sum()) if covc is not None else None |
| return c, cc, db, gaps |
|
|
| cenA, ccA, dbA, gA = scen(kA, tA) |
| cenB, ccB, dbB, gB = scen(kB, tB) |
| t = pd.DataFrame([ |
| {"Scenario": "A", "K": kA, "Threshold": tA, "Centroids": len(cenA), |
| "Mean base→centroid (km)": round(dbA, 1) if dbA else None, |
| f"Fires covered ≤{radius}km (%)": round(ccA, 1) if ccA else None, "Uncovered fires": gA}, |
| {"Scenario": "B", "K": kB, "Threshold": tB, "Centroids": len(cenB), |
| "Mean base→centroid (km)": round(dbB, 1) if dbB else None, |
| f"Fires covered ≤{radius}km (%)": round(ccB, 1) if ccB else None, "Uncovered fires": gB}, |
| ]) |
| st.dataframe(t, hide_index=True, width="stretch") |
|
|
| m1, m2 = st.columns(2) |
| for col, (cc, lab) in zip((m1, m2), [(cenA, "A"), (cenB, "B")]): |
| with col: |
| st.markdown(f"**Scenario {lab}**") |
| mm = base_map(src) |
| add_susceptibility(mm, src, data_disp) |
| add_border(mm, border, prov, False) |
| add_centroids(mm, cc, radius_km=radius) |
| if bases is not None: |
| add_bases(mm, bases) |
| show_map(mm, height=430, legend=False) |
|
|
| |
| elif page == "About": |
| st.markdown('<div class="sec-title">About this application</div>', unsafe_allow_html=True) |
| st.markdown(""" |
| This web application is the **decision-support companion** to the accompanying manuscript. |
| Following the paper's scope, it performs **only the allocation stage**: |
| it takes a pre-computed **fire-susceptibility** map (raster, 0–1) and clusters the highest-susceptibility |
| areas with **K-means** to propose candidate sites for firefighting infrastructure, then benchmarks them |
| against the real **Plan INFOCA** aircraft bases and the **GPS-validated 2008 fires** (LABIF-UCO). |
| |
| **What you can do** |
| - *Overview* — headline metrics and the reference allocation map. |
| - *Allocation* — interactive K-means (K / susceptibility threshold / radius) with per-cluster stats. |
| - *Coverage & gaps* — coverage-vs-radius curve and base→centroid distances. |
| - *Location models* — K-means vs p-median / p-center / MCLP (operations research). |
| - *Susceptibility zoning* — 5-class zoning, area-vs-fires validation and per-province breakdown. |
| - *Scenarios* — side-by-side comparison of two parameter sets. |
| """) |
| c1, c2 = st.columns(2) |
| with c1: |
| st.markdown("""**Data sources** |
| - Susceptibility map: deep-learning model (AUC = 0.94) over topographic, meteorological, vegetation and |
| anthropogenic factors (SRTM, TerraClimate, MODIS, GlobCover). |
| - Fire occurrences: **2008 field/GPS-validated** points (LABIF-UCO). |
| - Firefighting bases: Plan INFOCA / REDIAM (Junta de Andalucía), CC BY 4.0.""") |
| with c2: |
| st.markdown(f"""**How to cite** |
| |
| > {CITATION} |
| |
| Paper: [{PAPER_URL}]({PAPER_URL}) |
| |
| *Terminology:* the mapped quantity is fire **susceptibility** (spatial likelihood of occurrence); |
| "risk" is reserved for the operational allocation context.""") |
| if bases is not None: |
| st.download_button("Download INFOCA bases (CSV)", |
| bases.to_csv(index=False).encode("utf-8"), "infoca_bases.csv", "text/csv") |
| if compar is not None: |
| st.download_button("Download location-models comparison (CSV)", |
| compar.to_csv().encode("utf-8"), "location_models_comparison.csv", "text/csv") |
|
|
| st.markdown('<div class="small" style="text-align:center;margin-top:18px;">' |
| 'Andalusia wildfire susceptibility & aircraft allocation · decision-support app · ' |
| 'companion to the accompanying manuscript</div>', unsafe_allow_html=True) |
|
|