# -*- 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'', 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)