# -*- coding: utf-8 -*- """ 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 # Cap BLAS threads BEFORE importing numpy/sklearn — avoids an OpenBLAS thread-metadata # blow-up (spurious MemoryError) on many-core machines and keeps memory low on small cloud tiers. 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: # graceful fallback if the component is unavailable HAS_MENU = False # --------------------------------------------------------------------------- paths 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" # <- replace with the DOI once assigned CITATION = ("Author(s) (2026). Modelling wildfire susceptibility and optimizing the allocation of " "firefighting aircraft using artificial intelligence. Manuscript in preparation.") # susceptibility palette (green -> yellow -> red), consistent with the article figures 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] # app palette — article family (forest green / gray / white / black), matching the correlation # matrix (#4D4D4D <-> white <-> forestgreen). The susceptibility raster (SUS_HEX) and the 5-class # zoning (ZONE_HEX) intentionally keep the green->yellow->red map colors. 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") # --------------------------------------------------------------------------- styling st.markdown(""" """, unsafe_allow_html=True) # --------------------------------------------------------------------------- loaders (cached) @st.cache_resource(show_spinner=False) def load_raster(path): return carregar_raster(path) # (rasterio dataset, 2-D array 0..1) @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) # --------------------------------------------------------------------------- map helpers 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 = """
Fire susceptibility

lowhigh
""" 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'
{value}
{label}
' f'
{sub}
', unsafe_allow_html=True) # --------------------------------------------------------------------------- load data 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('
Andalusia · Wildfire allocation' 'Decision-support dashboard
', 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) # smoothed copy for cleaner map display (paper-consistent) 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 # base -> centroid 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 # which network's coverage gaps to highlight on the maps 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 # selected reference network -> the coverage KPI cards follow the same selector 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" # --------------------------------------------------------------------------- navigation 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") # ===================================================================== OVERVIEW if page == "Overview": st.markdown('
Wildfire susceptibility & firefighting-aircraft allocation' ' — Andalusia (Spain)
', 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('
Reference allocation map
', 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) # ===================================================================== ALLOCATION elif page == "Allocation": st.markdown('
K-means allocation from the susceptibility map
', unsafe_allow_html=True) st.markdown('

Adjust K, the susceptibility threshold and the operational radius in the ' 'sidebar. Centroids gravitate toward the highest-susceptibility areas (risk-weighted K-means).

', 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('
Per-cluster statistics
', 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") # ===================================================================== COVERAGE & GAPS elif page == "Coverage & gaps": st.markdown('
Coverage of 2008 fires vs operational radius
', 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('
Distance from each real base to the nearest proposed centroid
', 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") # ===================================================================== LOCATION MODELS elif page == "Location models": st.markdown('
Facility-location models (p = 31)
', unsafe_allow_html=True) st.markdown('

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.

', 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.") # ===================================================================== ZONING elif page == "Susceptibility zoning": st.markdown('
Susceptibility zoning (5 classes) and spatial validation
', 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' ' f'{l}' for c, l in zip(ZONE_HEX, ZONE_LABELS)) st.markdown(f'
Classes: {legend}
', 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('
By province
', 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") # ===================================================================== SCENARIOS elif page == "Scenarios": st.markdown('
Compare two allocation scenarios
', 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) # ===================================================================== ABOUT elif page == "About": st.markdown('
About this application
', 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('
' 'Andalusia wildfire susceptibility & aircraft allocation · decision-support app · ' 'companion to the accompanying manuscript
', unsafe_allow_html=True)