RAVE SCOUT: análisis de aislamiento de España y visor de localizaciones

Barrido de los 498.528 km² de la península y Baleares sobre malla de 100 m
(EPSG:3035) para localizar sitios alejados de todo con acceso rodado.

- 12,5 M de edificios y 1,4 M de km de viales de OSM, incluidos los países
  vecinos para no inflar el aislamiento en la franja fronteriza
- Espacios protegidos oficiales (Red Natura 2000 + designación nacional) vía
  la Agencia Europea de Medio Ambiente: cubren el 29 % del país frente al 15 %
  que tenía OSM
- Modelo acústico con propagación real; es el filtro que de verdad corta, y
  deja 505 km² en toda España
- 355 localizaciones valoradas de 1 a 5 estrellas en seis criterios
- Visor autónomo con relieve, ortofoto PNOA por localización, relieve 3D y
  mapa deslizante con satélite, catastro y espacios protegidos
This commit is contained in:
sito 2026-08-08 20:29:27 +02:00
commit d4b3b803e0
394 changed files with 7393 additions and 0 deletions

92
src/build_protected.py Normal file
View file

@ -0,0 +1,92 @@
"""Máscara oficial de espacios protegidos, en sustitución de la de OSM.
Une Red Natura 2000 (ZEC/LIC + ZEPA) y los espacios de designación nacional
(parques nacionales y naturales, reservas, monumentos naturales, paisajes
protegidos). Un punto vale solo si está fuera de las dos.
Guarda además los nombres y la geometría reproyectada para poder decir, en cada
candidato, cuál es el espacio protegido más cercano y a qué distancia.
"""
import json
import pickle
import sys
import time
import numpy as np
import rasterio.features
from shapely.geometry import mapping, shape
from shapely.ops import transform as shp_transform
from shapely.validation import make_valid
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
from scipy.ndimage import distance_transform_edt
NAME_KEYS = ("SITENAME", "SITE_NAME", "NAME", "CDDA_NAME", "name")
CODE_KEYS = ("SITECODE", "SITE_CODE", "CDDA_ID", "cdda_id")
def load(path, kind):
with open(path) as fh:
gj = json.load(fh)
recs = []
bad = 0
for f in gj.get("features", []):
g = f.get("geometry")
if not g:
continue
try:
geom = shape(g)
if not geom.is_valid:
geom = make_valid(geom)
geom = shp_transform(lambda xx, yy: C.to_grid(xx, yy), geom)
except Exception:
bad += 1
continue
if geom.is_empty:
continue
p = f.get("properties", {})
recs.append({
"geom": geom,
"name": next((str(p[k]) for k in NAME_KEYS if p.get(k)), ""),
"code": next((str(p[k]) for k in CODE_KEYS if p.get(k)), ""),
"kind": kind,
})
print(f" {path.name:22s} {len(recs):>6,} espacios ({bad} descartados)", flush=True)
return recs
def main():
t0 = time.time()
recs = (load(C.RAW / "prot" / "natura2000.geojson", "Natura 2000") +
load(C.RAW / "prot" / "natda.geojson", "Designación nacional"))
with open(C.INTERIM / "prot_official.pkl", "wb") as fh:
pickle.dump(recs, fh, protocol=4)
print("rasterizando…", flush=True)
mask = rasterio.features.rasterize(
[(mapping(r["geom"]), 1) for r in recs],
out_shape=(C.HEIGHT, C.WIDTH), transform=C.TRANSFORM,
fill=0, dtype=np.uint8, all_touched=True) # all_touched: conservador
np.save(C.INTERIM / "mask_protected_official.npy", mask)
spain = np.load(C.INTERIM / "mask_spain.npy").astype(bool)
old = np.load(C.INTERIM / "mask_protected.npy").astype(bool)
new = mask.astype(bool)
print(f"\ncobertura sobre España:")
print(f" OSM {(old & spain).sum()/spain.sum()*100:5.1f}% "
f"({(old & spain).sum()*0.01:>8,.0f} km2)")
print(f" oficial {(new & spain).sum()/spain.sum()*100:5.1f}% "
f"({(new & spain).sum()*0.01:>8,.0f} km2)")
gained = (new & ~old & spain).sum()
print(f" protegido que OSM no veía: {gained*0.01:,.0f} km2 "
f"({gained/spain.sum()*100:.1f}% del país)")
print("\ndistancia al protegido más cercano…", flush=True)
d = distance_transform_edt(~new, sampling=C.RES).astype(np.float32)
np.save(C.INTERIM / "d_protected_official.npy", d)
print(f" listo en {time.time()-t0:.0f}s")
if __name__ == "__main__":
main()

168
src/build_rasters.py Normal file
View file

@ -0,0 +1,168 @@
"""Rasteriza las capas de OSM y calcula las transformadas de distancia.
Cada capa de distancia responde a una pregunta concreta sobre un punto del mapa:
d_build ¿a qué distancia está el edificio más cercano? -> quién te oye
d_major ¿y la carretera con tráfico? -> quién te ve
d_access ¿y el vial por el que puedo meter un coche? -> si puedes llegar
Las tres a la vez son el problema: aislamiento y acceso tiran en sentidos
opuestos, y el sitio bueno es el que maximiza una con la otra acotada.
Los puntos de Portugal, Andorra y el Pirineo francés se mezclan con los
españoles antes de calcular distancias. Si no, toda la franja fronteriza daría
aislamiento falso por ausencia de datos al otro lado.
"""
import pickle
import sys
import time
import numpy as np
import rasterio.features
from scipy.ndimage import distance_transform_edt, uniform_filter
from shapely.geometry import mapping
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
SUFFIXES = ["", "_pt", "_ad", "_fr1", "_fr2", "_fr3"]
def load_points(base):
"""Junta la capa española con las de los vecinos que existan."""
xs, ys = [], []
for s in SUFFIXES:
p = C.INTERIM / f"{base}{s}.npy"
if p.exists():
a = np.load(p)
if a.shape[1]:
xs.append(a[0])
ys.append(a[1])
if not xs:
return None
return np.concatenate(xs), np.concatenate(ys)
def mask_from_points(base):
pts = load_points(base)
m = np.zeros((C.HEIGHT, C.WIDTH), dtype=bool)
if pts is None:
print(f" [aviso] sin datos para {base}", flush=True)
return m, 0
r, c = C.xy_to_rowcol(*pts)
ok = C.inside(r, c)
m[r[ok], c[ok]] = True
return m, int(ok.sum())
def edt(mask, name):
"""Distancia en metros a la celda ocupada más cercana."""
t0 = time.time()
d = distance_transform_edt(~mask, sampling=C.RES).astype(np.float32)
np.save(C.INTERIM / f"{name}.npy", d)
print(f" {name:12s} p50={np.median(d):8.0f} m max={d.max():9.0f} m "
f"({time.time()-t0:.0f}s)", flush=True)
return d
def rasterize_areas(cat, names_out=None):
with open(C.INTERIM / f"area_{cat}.pkl", "rb") as fh:
recs = pickle.load(fh)
shapes = [(mapping(r["geom"]), 1) for r in recs if not r["geom"].is_empty]
if not shapes:
return np.zeros((C.HEIGHT, C.WIDTH), dtype=np.uint8)
out = rasterio.features.rasterize(
shapes, out_shape=(C.HEIGHT, C.WIDTH), transform=C.TRANSFORM,
fill=0, dtype=np.uint8, all_touched=False)
print(f" area_{cat:14s} {len(shapes):>7,} polígonos "
f"{out.mean()*100:5.2f}% de la malla", flush=True)
return out
def main():
print("=== máscara de España (unión de comunidades) ===", flush=True)
with open(C.INTERIM / "area_admin_region.pkl", "rb") as fh:
regions = [r for r in pickle.load(fh) if r["geom"].area > 1e6]
print(f" {len(regions)} comunidades, "
f"{sum(r['geom'].area for r in regions)/1e6:,.0f} km2", flush=True)
spain = rasterio.features.rasterize(
[(mapping(r["geom"]), 1) for r in regions],
out_shape=(C.HEIGHT, C.WIDTH), transform=C.TRANSFORM,
fill=0, dtype=np.uint8, all_touched=False)
# La máscara tiene que usar EXACTAMENTE la misma ventana lat/lon con la que
# se filtraron los puntos. Si no, un territorio que entra en la malla pero
# cuyos edificios se descartaron (Melilla: la rejilla en 3035 no está
# alineada con lat/lon y baja más al sur que la ventana) aparece como el
# punto más aislado del país por ausencia de datos.
window = rasterio.features.rasterize(
[(mapping(C.window_polygon()), 1)],
out_shape=(C.HEIGHT, C.WIDTH), transform=C.TRANSFORM,
fill=0, dtype=np.uint8, all_touched=False)
dropped = int((spain & ~window.astype(bool)).sum())
if dropped:
print(f" [ventana] {dropped*0.01:,.0f} km2 fuera de la ventana de "
f"análisis descartados (territorios sin datos de puntos)")
spain = (spain.astype(bool) & window.astype(bool)).astype(np.uint8)
np.save(C.INTERIM / "mask_spain.npy", spain)
print(f" {spain.sum()*0.01:,.0f} km2 rasterizados "
f"({spain.mean()*100:.1f}% de la malla)", flush=True)
print("=== exclusiones ===", flush=True)
for cat in ("protected", "military", "water", "urban"):
np.save(C.INTERIM / f"mask_{cat}.npy", rasterize_areas(cat))
# Distancia al espacio protegido más cercano. Estar fuera del polígono no
# basta: los parques se delimitaron rodeando justo lo más despoblado, así
# que el óptimo del modelo tiende a pegarse al borde, que es donde hay
# zona periférica de protección y vigilancia.
print("=== distancia a exclusiones ===", flush=True)
for cat in ("protected", "military"):
m = np.load(C.INTERIM / f"mask_{cat}.npy").astype(bool)
edt(m, f"d_{cat}")
print("=== rasterizando puntos y calculando distancias ===", flush=True)
layers = {
"d_build": "buildings",
"d_major": "line_major",
"d_minor": "line_minor",
"d_track": "line_track",
"d_path": "line_path",
"d_rail": "line_rail",
}
build_mask = None
for out_name, base in layers.items():
m, n = mask_from_points(base)
print(f" {base:12s} {n:>12,} pts -> {m.sum():>11,} celdas", flush=True)
if out_name == "d_build":
build_mask = m.copy()
edt(m, out_name)
del m
# Núcleos de población, agrupados por tamaño.
big = np.zeros((C.HEIGHT, C.WIDTH), dtype=bool)
any_p = np.zeros((C.HEIGHT, C.WIDTH), dtype=bool)
for kind in ("city", "town", "village", "hamlet", "isolated_dwelling",
"farm", "suburb", "borough", "quarter", "neighbourhood"):
m, n = mask_from_points(f"place_{kind}")
if n == 0:
continue
any_p |= m
if kind in ("city", "town"):
big |= m
edt(big, "d_place_big")
edt(any_p, "d_place_any")
del big, any_p
# Densidad de edificios en 5 km: aproxima el resplandor lumínico y el
# "cuánta gente vive alrededor" mejor que la distancia al más cercano,
# que se deja engañar por una nave aislada en medio de la nada.
print("=== densidad de edificios 5 km ===", flush=True)
k = int(round(5000 / C.RES)) | 1
dens = uniform_filter(build_mask.astype(np.float32), size=k, mode="nearest")
dens *= k * k # nº de celdas con edificio en la ventana
np.save(C.INTERIM / "dens_build_5km.npy", dens.astype(np.float32))
print(f" p50={np.median(dens):.0f} p99={np.percentile(dens,99):.0f} "
"celdas con edificio en la ventana", flush=True)
if __name__ == "__main__":
main()

119
src/build_rasters2.py Normal file
View file

@ -0,0 +1,119 @@
"""Segunda tanda de rásters: agua, arbolado, roca, calidad de pista y clima.
Estas capas no miden aislamiento sino habitabilidad del sitio. La distinción
importa: el modelo anterior contestaba "¿está lejos de todo?", y estas capas
contestan "¿y se puede estar ahí?".
Del clima se sacan dos números por celda: la máxima del mes más cálido y la
mínima del mes más frío. WorldClim viene a 2,5' (~4,6 km), demasiado grueso para
un país con tanto desnivel, así que se baja a 100 m corrigiendo por altitud con
el gradiente térmico estándar de 6,5 °C/km sobre la diferencia entre el DEM fino
y la altitud implícita de la celda gruesa.
"""
import glob
import pickle
import sys
import time
import numpy as np
import rasterio
import rasterio.features
from rasterio.warp import Resampling, reproject
from scipy.ndimage import distance_transform_edt, uniform_filter
from shapely.geometry import mapping
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
LAPSE = 6.5 / 1000.0 # °C por metro
def edt_from_points(base, out):
a = np.load(C.INTERIM / f"{base}.npy")
m = np.zeros((C.HEIGHT, C.WIDTH), dtype=bool)
r, c = C.xy_to_rowcol(a[0], a[1])
ok = C.inside(r, c)
m[r[ok], c[ok]] = True
d = distance_transform_edt(~m, sampling=C.RES).astype(np.float32)
np.save(C.INTERIM / f"{out}.npy", d)
print(f" {out:16s} desde {int(ok.sum()):>12,} pts "
f"p50={np.median(d)/1000:5.1f} km", flush=True)
del m, d
def frac_from_areas(cat, out, radius_m):
with open(C.INTERIM / f"area_{cat}.pkl", "rb") as fh:
geoms = pickle.load(fh)
shapes = [(mapping(g), 1) for g in geoms if not g.is_empty]
m = rasterio.features.rasterize(
shapes, out_shape=(C.HEIGHT, C.WIDTH), transform=C.TRANSFORM,
fill=0, dtype=np.uint8, all_touched=False)
k = int(round(radius_m * 2 / C.RES)) | 1
frac = uniform_filter(m.astype(np.float32), size=k, mode="nearest")
np.save(C.INTERIM / f"{out}.npy", frac.astype(np.float32))
print(f" {out:16s} {len(shapes):>8,} polígonos "
f"cobertura media {m.mean()*100:4.1f}%", flush=True)
del m, frac
def climate():
"""bio5 = máxima del mes más cálido; bio6 = mínima del mes más frío."""
files = {}
for p in glob.glob(str(C.RAW / "clima" / "*.tif")):
n = p.rsplit("/", 1)[-1]
if n.endswith("_bio_5.tif") or n.endswith("bio_5.tif"):
files["tmax"] = p
elif n.endswith("_bio_6.tif") or n.endswith("bio_6.tif"):
files["tmin"] = p
elif "elev" in n:
files["elev"] = p
missing = {"tmax", "tmin", "elev"} - set(files)
if missing:
print(f" [aviso] faltan capas de clima: {missing}; se omite", flush=True)
return
dem = np.load(C.INTERIM / "dem.npy")
out = {}
for key in ("tmax", "tmin", "elev"):
dst = np.full((C.HEIGHT, C.WIDTH), np.nan, dtype=np.float32)
with rasterio.open(files[key]) as src:
reproject(source=rasterio.band(src, 1), destination=dst,
src_transform=src.transform, src_crs=src.crs,
dst_transform=C.TRANSFORM, dst_crs=C.CRS_GRID,
src_nodata=src.nodata, dst_nodata=np.nan,
resampling=Resampling.bilinear)
out[key] = dst
# Corrección altitudinal: la celda gruesa lleva su propia altitud media;
# la diferencia con el DEM fino explica buena parte del error térmico.
dz = np.where(np.isfinite(dem) & np.isfinite(out["elev"]),
dem - out["elev"], 0.0).astype(np.float32)
for key in ("tmax", "tmin"):
adj = (out[key] - LAPSE * dz).astype(np.float32)
np.save(C.INTERIM / f"{key}.npy", adj)
ok = np.isfinite(adj)
print(f" {key:16s} p5={np.nanpercentile(adj[ok],5):5.1f} "
f"p50={np.nanpercentile(adj[ok],50):5.1f} "
f"p95={np.nanpercentile(adj[ok],95):5.1f} °C", flush=True)
def main():
t0 = time.time()
print("=== distancias a agua y pistas ===", flush=True)
for base, out in (("line_water", "d_water"), ("springs", "d_spring"),
("line_track_good", "d_track_good"),
("line_track_bad", "d_track_bad")):
edt_from_points(base, out)
print("=== fracciones de cobertura del suelo ===", flush=True)
frac_from_areas("forest", "frac_forest", 500)
frac_from_areas("rock", "frac_rock", 300)
frac_from_areas("farmland", "frac_farmland", 300)
print("=== clima ===", flush=True)
climate()
print(f"total {time.time()-t0:.0f}s")
if __name__ == "__main__":
main()

829
src/build_viewer.py Normal file
View file

@ -0,0 +1,829 @@
"""Genera el visor local: un único HTML autónomo, sin dependencias externas.
Todo va incrustado (relieve, capas, candidatos y un recorte del terreno por
candidato) para que funcione abriendo el fichero con doble clic, sin servidor y
sin conexión. Por eso las imágenes son WebP y el terreno se guarda como uint8
normalizado por tesela: en PNG y float el fichero se iba a 25 MB.
El 3D se dibuja con canvas 2D y algoritmo del pintor en lugar de WebGL o una
librería externa: son 48x48 celdas, va sobrado a 60 fps, y evita depender de un
CDN que en local no cargaría.
"""
import base64
import json
import pickle
import sys
import numpy as np
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
F = 5 # factor del mapa base (500 m/px)
N = 48 # muestras por lado del recorte 3D
S = 2 # paso en celdas -> 48*2*100 m = 9,6 km de lado
def b64(path):
return base64.b64encode(path.read_bytes()).decode("ascii")
def terrain_tiles(cands, dem):
"""Recorte de relieve alrededor de cada candidato, uint8 por tesela."""
buf = np.zeros((len(cands), N * N), dtype=np.uint8)
meta = []
half = N * S // 2
for i, cd in enumerate(cands):
r, c = cd["row"], cd["col"]
r0, c0 = r - half, c - half
rr = np.clip(np.arange(r0, r0 + N * S, S), 0, C.HEIGHT - 1)
cc = np.clip(np.arange(c0, c0 + N * S, S), 0, C.WIDTH - 1)
tile = dem[np.ix_(rr, cc)]
tile = np.where(np.isfinite(tile), tile, 0.0)
lo, hi = float(tile.min()), float(tile.max())
rng = max(hi - lo, 1.0)
buf[i] = ((tile - lo) / rng * 255).astype(np.uint8).ravel()
meta.append([round(lo, 1), round(rng, 1)])
return buf, meta
def main():
with open(C.INTERIM / "cands.pkl", "rb") as fh:
cands = pickle.load(fh)
dem = np.load(C.INTERIM / "dem.npy", mmap_mode="r")
print(f"recortando terreno de {len(cands)} candidatos…", flush=True)
tiles, tmeta = terrain_tiles(cands, dem)
keys = ["id", "lat", "lon", "estrellas", "score", "municipio", "comunidad",
"c_sonido", "c_soledad", "c_agua", "c_arbolado", "c_acceso", "c_clima",
"db_en_casa", "d_edificio", "d_carretera", "d_pista", "d_agua",
"d_protegido", "protegido_cerca", "km_protegido", "arbolado_pct",
"roca_pct", "pendiente", "tpi", "cota", "tmax", "tmin", "km_madrid"]
rows = []
for i, cd in enumerate(cands):
rec = [cd[k] for k in keys]
rec.append(round(cd["col"] / F, 1)) # px en la imagen base
rec.append(round(cd["row"] / F, 1))
rec.append(tmeta[i])
rows.append(rec)
payload = {
"keys": keys + ["px", "py", "tmeta"],
"rows": rows,
"img_w": (C.WIDTH // F), "img_h": (C.HEIGHT // F),
}
vendor = C.ROOT / "vendor"
html = TEMPLATE
for token, val in (
("__LEAFLET_CSS__", (vendor / "leaflet.css").read_text(encoding="utf-8")),
("__LEAFLET_JS__", (vendor / "leaflet.js").read_text(encoding="utf-8")),
("__RELIEF__", b64(C.OUT / "relieve.webp")),
("__PROT__", b64(C.OUT / "protegidos.webp")),
("__ISO__", b64(C.OUT / "aislamiento_ov.webp")),
("__TERRAIN__", base64.b64encode(tiles.tobytes()).decode("ascii")),
("__DATA__", json.dumps(payload, ensure_ascii=False, separators=(",", ":"))),
("__N_CAND__", f"{len(cands):,}".replace(",", ".")),
("__N__", str(N)),
):
html = html.replace(token, val)
out = C.OUT / "visor.html"
out.write_text(html, encoding="utf-8")
print(f"{out} {out.stat().st_size/1e6:.2f} MB")
TEMPLATE = r"""<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Visor de localizaciones · España</title>
<style>__LEAFLET_CSS__</style>
<style>
*,*::before,*::after{box-sizing:border-box}
:root{
--bg:#0e1116; --panel:#151a21; --panel2:#1b222b; --line:#273140;
--ink:#e7ecf3; --ink2:#9dabba; --muted:#6c7a89; --accent:#5aa2f0;
--s1:#8a7a4a; --s2:#a8923f; --s3:#c9a92f; --s4:#e6c235; --s5:#ffd95e;
--danger:#e0603f; --good:#4bb37b;
}
html,body{height:100%;margin:0}
body{background:var(--bg);color:var(--ink);
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:flex;overflow:hidden}
button,input,select{font:inherit;color:inherit}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
/* ---------- barra lateral ---------- */
#side{width:328px;flex:0 0 328px;background:var(--panel);
border-right:1px solid var(--line);display:flex;flex-direction:column;height:100vh}
#side header{padding:16px 18px 12px;border-bottom:1px solid var(--line)}
#side h1{margin:0;font-size:15px;letter-spacing:.02em}
#side .sub{color:var(--muted);font-size:11.5px;margin-top:3px}
.filters{padding:14px 18px;display:flex;flex-direction:column;gap:13px;
border-bottom:1px solid var(--line)}
.f-row{display:flex;flex-direction:column;gap:6px}
.f-row label{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;
color:var(--muted);display:flex;justify-content:space-between;align-items:baseline}
.f-row label b{color:var(--ink2);font-weight:600;letter-spacing:0;text-transform:none;
font-size:11.5px}
input[type=range]{width:100%;accent-color:var(--accent);height:16px}
select{width:100%;background:var(--panel2);border:1px solid var(--line);
border-radius:4px;padding:5px 7px;font-size:12.5px}
.starbtns{display:flex;gap:4px}
.starbtns button{flex:1;background:var(--panel2);border:1px solid var(--line);
border-radius:4px;padding:5px 0;cursor:pointer;font-size:12px;color:var(--ink2)}
.starbtns button[aria-pressed=true]{background:var(--accent);border-color:var(--accent);
color:#08131f;font-weight:600}
.count{padding:9px 18px;font-size:11.5px;color:var(--muted);
border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
#list{flex:1;overflow-y:auto}
.item{padding:9px 18px;border-bottom:1px solid rgba(39,49,64,.55);cursor:pointer;
display:flex;gap:10px;align-items:baseline}
.item:hover{background:var(--panel2)}
.item[aria-selected=true]{background:#1d2836;box-shadow:inset 3px 0 0 var(--accent)}
.item .st{font-size:11px;letter-spacing:-.5px;white-space:nowrap}
.item .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
font-size:12.5px}
.item .sc{font-size:11px;color:var(--muted)}
/* ---------- mapa ---------- */
#main{flex:1;position:relative;min-width:0}
/* width/height explícitos, no solo inset: canvas es un elemento reemplazado y
con width:auto el navegador usa su tamaño intrínseco (300x150) e ignora el
right/bottom del inset. */
#map{position:absolute;top:0;left:0;width:100%;height:100%;cursor:grab;display:block}
#map.drag{cursor:grabbing}
.toolbar{position:absolute;top:12px;left:12px;display:flex;gap:6px;flex-wrap:wrap;z-index:5}
.toolbar button{background:rgba(21,26,33,.93);border:1px solid var(--line);
border-radius:5px;padding:6px 11px;font-size:12px;cursor:pointer;color:var(--ink2)}
.toolbar button[aria-pressed=true]{background:var(--accent);border-color:var(--accent);
color:#08131f;font-weight:600}
.legend{position:absolute;bottom:12px;left:12px;background:rgba(21,26,33,.93);
border:1px solid var(--line);border-radius:5px;padding:9px 12px;font-size:11px;
display:flex;gap:13px;align-items:center;z-index:5}
.legend i{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:4px}
.hint{position:absolute;bottom:12px;right:12px;color:var(--muted);font-size:11px;
background:rgba(21,26,33,.9);padding:5px 9px;border-radius:4px;z-index:5}
/* ---------- mapa de cerca (Leaflet) ---------- */
#leaf{position:absolute;top:0;left:0;width:100%;height:100%;display:none;
background:#0a0d12;z-index:1}
body.cerca #map{display:none}
body.cerca #leaf{display:block}
body.cerca .legend{display:none}
#layers{position:absolute;top:52px;left:12px;z-index:6;width:186px;
background:rgba(21,26,33,.95);border:1px solid var(--line);border-radius:6px;
padding:9px 11px;display:none;flex-direction:column;gap:8px;font-size:12px}
body.cerca #layers{display:flex}
#layers .grp{display:flex;flex-direction:column;gap:4px}
#layers .ttl{font-size:9.5px;letter-spacing:.11em;text-transform:uppercase;
color:var(--muted)}
#layers label{display:flex;gap:7px;align-items:center;cursor:pointer;
color:var(--ink2);padding:1px 0}
#layers label:hover{color:var(--ink)}
#layers input{accent-color:var(--accent);margin:0}
#nosel{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
color:var(--muted);font-size:13px;text-align:center;z-index:2;display:none}
body.cerca.sinsel #nosel{display:block}
.leaflet-container{background:#0a0d12;font:inherit}
.leaflet-control-attribution{background:rgba(21,26,33,.9)!important;
color:var(--muted)!important;font-size:10px!important}
.leaflet-control-attribution a{color:var(--accent)!important}
.leaflet-bar a{background:#1b222b;color:var(--ink);border-color:var(--line)}
.leaflet-bar a:hover{background:#273140;color:#fff}
.leaflet-control-scale-line{background:rgba(21,26,33,.85);color:var(--ink2);
border-color:var(--line)}
.btn-cerca{width:100%;margin-top:2px;background:var(--accent);border:0;
border-radius:4px;padding:8px 0;font-size:12.5px;font-weight:600;color:#08131f;
cursor:pointer}
.btn-cerca:hover{filter:brightness(1.08)}
/* ---------- detalle ---------- */
#detail{position:absolute;top:0;right:0;width:372px;height:100%;background:var(--panel);
border-left:1px solid var(--line);transform:translateX(100%);
transition:transform .18s ease;overflow-y:auto;z-index:10}
#detail.open{transform:none}
@media (prefers-reduced-motion:reduce){#detail{transition:none}}
.d-head{padding:15px 18px;border-bottom:1px solid var(--line);position:relative}
.d-head h2{margin:0 26px 0 0;font-size:16px}
.d-head .loc{color:var(--muted);font-size:12px;margin-top:2px}
.d-head .stars{font-size:16px;margin-top:7px;letter-spacing:1px}
#close{position:absolute;top:13px;right:14px;background:none;border:0;
color:var(--muted);font-size:20px;cursor:pointer;line-height:1;padding:2px 5px}
#close:hover{color:var(--ink)}
.tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel2)}
.tabs button{flex:1;background:none;border:0;padding:8px 0;font-size:11.5px;
color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
.tabs button[aria-pressed=true]{color:var(--ink);border-bottom-color:var(--accent)}
.tabs button:hover{color:var(--ink2)}
#ortho{display:block;width:100%;height:246px;object-fit:cover;background:#0a0d12;
border-bottom:1px solid var(--line);cursor:zoom-in}
#tcanvas{display:none;width:100%;height:246px;background:#0a0d12;cursor:grab;
border-bottom:1px solid var(--line)}
#tcanvas.drag{cursor:grabbing}
#detail.t3d #ortho{display:none}
#detail.t3d #tcanvas{display:block}
.tlabel{padding:6px 18px;font-size:10.5px;color:var(--muted);
border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
.crits{padding:14px 18px;display:flex;flex-direction:column;gap:9px;
border-bottom:1px solid var(--line)}
.crit{display:grid;grid-template-columns:74px 1fr 30px;gap:9px;align-items:center;
font-size:11.5px}
.crit .bar{height:6px;background:var(--panel2);border-radius:3px;overflow:hidden}
.crit .bar i{display:block;height:100%;background:var(--accent);border-radius:3px}
.crit .v{text-align:right;color:var(--ink2);font-size:11px}
.facts{padding:14px 18px;display:flex;flex-direction:column;gap:0;
border-bottom:1px solid var(--line)}
.fact{display:flex;justify-content:space-between;gap:10px;padding:4px 0;font-size:12px;
border-bottom:1px solid rgba(39,49,64,.45)}
.fact:last-child{border-bottom:0}
.fact span{color:var(--muted)}
.fact b{font-weight:500}
.warn{margin:14px 18px;padding:10px 12px;border-left:3px solid var(--danger);
background:rgba(224,96,63,.09);font-size:11.5px;color:var(--ink2)}
.links{padding:12px 18px 24px;display:grid;grid-template-columns:1fr 1fr;gap:7px}
.links a{background:var(--panel2);border:1px solid var(--line);border-radius:5px;
padding:9px 11px;font-size:12.5px;text-decoration:none;color:var(--ink2);
display:flex;flex-direction:column;gap:1px;line-height:1.25}
.links a b{color:var(--accent);font-weight:600;font-size:12.5px}
.links a span{font-size:10.5px;color:var(--muted)}
.links a:hover{border-color:var(--accent);background:#1d2836}
.links-ttl{padding:2px 18px 0;font-size:9.5px;letter-spacing:.11em;
text-transform:uppercase;color:var(--muted)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* ---------- móvil y pantallas estrechas ---------- */
/* El mapa arriba y los controles debajo: en vertical la barra lateral fija de
328 px no cabe, y el panel de detalle tiene que poder ocupar la pantalla. */
@media (max-width:860px){
body{flex-direction:column}
#side{width:100%;flex:0 0 auto;height:auto;max-height:46vh;order:2;
border-right:0;border-top:1px solid var(--line)}
#main{order:1;flex:1 1 auto;min-height:54vh}
.filters{display:grid;grid-template-columns:1fr 1fr;gap:11px 14px;padding:12px 14px}
.filters .f-row:first-child,.filters .f-row:nth-child(2){grid-column:1/-1}
#side header{padding:11px 14px 9px}
#side h1{font-size:14px}
.count,.item{padding-left:14px;padding-right:14px}
/* a pantalla completa: dentro de #main solo tendría el 54% del alto */
#detail{position:fixed;inset:0;width:100%;height:100dvh;border-left:0;z-index:50}
#ortho,#tcanvas{height:38vh;max-height:300px}
.toolbar{gap:5px;max-width:calc(100% - 24px)}
.toolbar button{padding:5px 9px;font-size:11.5px}
#layers{top:auto;bottom:12px;left:12px;width:170px}
.hint{display:none}
.legend{font-size:10px;padding:7px 9px;gap:9px}
}
@media (max-width:520px){
.filters{grid-template-columns:1fr}
.filters .f-row:first-child,.filters .f-row:nth-child(2){grid-column:auto}
.links{grid-template-columns:1fr}
#side{max-height:42vh}
}
</style>
</head>
<body>
<aside id="side">
<header>
<h1>Localizaciones · España</h1>
<div class="sub">__N_CAND__ localizaciones · malla de 100 m</div>
</header>
<div class="filters">
<div class="f-row">
<label>Estrellas mínimas</label>
<div class="starbtns" id="starf">
<button data-s="1" aria-pressed="true">1+</button>
<button data-s="2" aria-pressed="false">2+</button>
<button data-s="3" aria-pressed="false">3+</button>
<button data-s="4" aria-pressed="false">4+</button>
<button data-s="5" aria-pressed="false">5</button>
</div>
</div>
<div class="f-row">
<label for="reg">Comunidad</label>
<select id="reg"><option value="">todas</option></select>
</div>
<div class="f-row">
<label for="fmad">Distancia desde Madrid <b id="vmad">sin límite</b></label>
<input type="range" id="fmad" min="25" max="700" step="25" value="700">
</div>
<div class="f-row">
<label for="fson">Aislamiento sonoro mínimo <b id="vson">0</b></label>
<input type="range" id="fson" min="0" max="100" step="5" value="0">
</div>
<div class="f-row">
<label for="fagu">Agua cerca mínimo <b id="vagu">0</b></label>
<input type="range" id="fagu" min="0" max="100" step="5" value="0">
</div>
<div class="f-row">
<label for="farb">Arbolado mínimo <b id="varb">0</b></label>
<input type="range" id="farb" min="0" max="100" step="5" value="0">
</div>
<div class="f-row">
<label for="facc">Acceso mínimo <b id="vacc">0</b></label>
<input type="range" id="facc" min="0" max="100" step="5" value="0">
</div>
</div>
<div class="count"><span id="cnt"></span><span id="cnt2"></span></div>
<div id="list"></div>
</aside>
<div id="main">
<canvas id="map"></canvas>
<div id="leaf"></div>
<div id="nosel">Elige una localización en la lista<br>para verla de cerca</div>
<div class="toolbar">
<button id="bEsp" aria-pressed="true">España</button>
<button id="bCerca" aria-pressed="false">Ver de cerca</button>
<span style="width:9px"></span>
<button id="bRel" aria-pressed="true">Relieve</button>
<button id="bIso" aria-pressed="false">Aislamiento</button>
<button id="bProt" aria-pressed="true">Protegidos</button>
<button id="bFit">Encajar</button>
</div>
<div id="layers">
<div class="grp">
<span class="ttl">Fondo</span>
<label><input type="radio" name="base" value="pnoa" checked> Satélite (PNOA)</label>
<label><input type="radio" name="base" value="mtn"> Topográfico (MTN)</label>
<label><input type="radio" name="base" value="osm"> OpenStreetMap</label>
</div>
<div class="grp">
<span class="ttl">Encima</span>
<label><input type="checkbox" id="ovCat"> Parcelas del catastro</label>
<label><input type="checkbox" id="ovN2k" checked> Red Natura 2000</label>
<label><input type="checkbox" id="ovEnp" checked> Espacios protegidos</label>
<label><input type="checkbox" id="ovPts" checked> Otras localizaciones</label>
</div>
</div>
<div class="legend" id="legend"></div>
<div class="hint">arrastra para mover · rueda para zoom · clic en un punto</div>
<aside id="detail">
<div class="d-head">
<button id="close" aria-label="Cerrar">×</button>
<h2 id="dName"></h2>
<div class="loc" id="dLoc"></div>
<div class="stars" id="dStars"></div>
</div>
<div class="tabs">
<button id="tabSat" aria-pressed="true">Vista aérea</button>
<button id="tab3d" aria-pressed="false">Relieve 3D</button>
</div>
<img id="ortho" alt="Vista aérea de la localización" src="">
<canvas id="tcanvas"></canvas>
<div class="tlabel"><span id="tHint">ortofoto PNOA · 640 m de lado · pulsa para ampliar</span>
<span id="tRange"></span></div>
<div class="crits" id="dCrits"></div>
<div class="facts" id="dFacts"></div>
<div id="dWarn"></div>
<div style="padding:0 18px">
<button class="btn-cerca" id="bVerCerca">Ver de cerca en el mapa</button>
</div>
<div class="links-ttl">Abrir en otros mapas</div>
<div class="links" id="dLinks"></div>
</aside>
</div>
<script>__LEAFLET_JS__</script>
<script>
const DATA = __DATA__;
const NT = __N__;
// ---- desempaquetado ----
const K = {}; DATA.keys.forEach((k,i)=>K[k]=i);
const C = DATA.rows;
const terrainRaw = atob("__TERRAIN__");
const TER = new Uint8Array(terrainRaw.length);
for(let i=0;i<terrainRaw.length;i++) TER[i]=terrainRaw.charCodeAt(i);
const TSIZE = NT*NT;
const SCOL = ["","#8a7a4a","#a8923f","#c9a92f","#e6c235","#ffd95e"];
const stars = n => "".repeat(n)+"".repeat(5-n);
// ---- imágenes ----
const layers = {};
function loadImg(name, b64){
return new Promise(res=>{ const i=new Image();
i.onload=()=>{layers[name]=i;res();}; i.onerror=()=>res();
i.src="data:image/webp;base64,"+b64; });
}
// ---- estado del mapa ----
const cv = document.getElementById('map'), cx = cv.getContext('2d');
let view = {x:0,y:0,k:1}, dpr = Math.min(devicePixelRatio||1,2);
let visible = [], selected = null;
const show = {rel:true, iso:false, prot:true};
function resize(){
const r = cv.getBoundingClientRect();
cv.width = r.width*dpr; cv.height = r.height*dpr;
draw();
}
function fit(){
const r = cv.getBoundingClientRect();
const k = Math.min(r.width/DATA.img_w, r.height/DATA.img_h)*0.96;
view.k = k;
view.x = (r.width - DATA.img_w*k)/2;
view.y = (r.height - DATA.img_h*k)/2;
draw();
}
function draw(){
const r = cv.getBoundingClientRect();
cx.setTransform(dpr,0,0,dpr,0,0);
cx.clearRect(0,0,r.width,r.height);
cx.fillStyle="#0e1116"; cx.fillRect(0,0,r.width,r.height);
cx.save();
cx.translate(view.x,view.y); cx.scale(view.k,view.k);
cx.imageSmoothingEnabled = view.k < 3;
if(show.rel && layers.rel) cx.drawImage(layers.rel,0,0,DATA.img_w,DATA.img_h);
if(show.iso && layers.iso) cx.drawImage(layers.iso,0,0,DATA.img_w,DATA.img_h);
if(show.prot && layers.prot) cx.drawImage(layers.prot,0,0,DATA.img_w,DATA.img_h);
cx.restore();
for(const c of visible){
const px = view.x + c[K.px]*view.k, py = view.y + c[K.py]*view.k;
if(px<-20||py<-20||px>r.width+20||py>r.height+20) continue;
const s = c[K.estrellas];
const rad = (s>=5?5.5:s>=4?4.6:s>=3?3.8:3.1) * (selected===c?1.6:1);
cx.beginPath(); cx.arc(px,py,rad+1.6,0,7); cx.fillStyle="rgba(8,11,16,.85)"; cx.fill();
cx.beginPath(); cx.arc(px,py,rad,0,7); cx.fillStyle=SCOL[s]; cx.fill();
if(selected===c){ cx.beginPath(); cx.arc(px,py,rad+5,0,7);
cx.strokeStyle="#5aa2f0"; cx.lineWidth=2; cx.stroke(); }
}
}
// ---- interacción del mapa ----
let drag=null;
cv.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY,vx:view.x,vy:view.y,moved:0};
cv.classList.add('drag'); cv.setPointerCapture(e.pointerId);});
cv.addEventListener('pointermove',e=>{ if(!drag) return;
drag.moved += Math.abs(e.clientX-drag.x)+Math.abs(e.clientY-drag.y);
view.x = drag.vx + (e.clientX-drag.x); view.y = drag.vy + (e.clientY-drag.y); draw();});
cv.addEventListener('pointerup',e=>{
cv.classList.remove('drag');
if(drag && drag.moved<5) pick(e);
drag=null;});
cv.addEventListener('wheel',e=>{
e.preventDefault();
const r=cv.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top;
const f = Math.exp(-e.deltaY*0.0016), nk = Math.min(40,Math.max(0.15,view.k*f));
view.x = mx - (mx-view.x)*(nk/view.k); view.y = my - (my-view.y)*(nk/view.k);
view.k = nk; draw();},{passive:false});
function pick(e){
const r=cv.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top;
let best=null, bd=18*18;
for(const c of visible){
const dx = view.x + c[K.px]*view.k - mx, dy = view.y + c[K.py]*view.k - my;
const d = dx*dx+dy*dy;
if(d<bd){bd=d;best=c;}
}
if(best) select(best);
}
// ---- filtros y lista ----
const el = id => document.getElementById(id);
let minStars = 1;
function apply(){
const reg = el('reg').value;
const mad = +el('fmad').value, son=+el('fson').value, agu=+el('fagu').value,
arb=+el('farb').value, acc=+el('facc').value;
visible = C.filter(c =>
c[K.estrellas] >= minStars &&
(!reg || c[K.comunidad]===reg) &&
(mad>=700 || c[K.km_madrid]<=mad) &&
c[K.c_sonido]>=son && c[K.c_agua]>=agu &&
c[K.c_arbolado]>=arb && c[K.c_acceso]>=acc);
visible.sort((a,b)=>b[K.score]-a[K.score]);
el('cnt').textContent = visible.length + (visible.length===1?" localización":" localizaciones");
const n5 = visible.filter(c=>c[K.estrellas]===5).length;
el('cnt2').textContent = n5 ? n5+" de 5★" : "";
renderList(); draw();
}
function renderList(){
const box = el('list');
box.innerHTML = "";
const frag = document.createDocumentFragment();
for(const c of visible.slice(0,400)){
const d = document.createElement('div');
d.className='item'; d.setAttribute('role','option');
d.setAttribute('aria-selected', selected===c ? 'true':'false');
d.innerHTML = `<span class="st" style="color:${SCOL[c[K.estrellas]]}">${"".repeat(c[K.estrellas])}</span>`+
`<span class="nm">${c[K.municipio]||''}</span>`+
`<span class="sc mono">${c[K.score].toFixed(0)}</span>`;
d.onclick = ()=>select(c);
frag.appendChild(d);
}
box.appendChild(frag);
if(visible.length>400){
const m=document.createElement('div');
m.className='item'; m.style.color='var(--muted)';
m.innerHTML=`<span class="nm"> y ${visible.length-400} más (afina los filtros)</span>`;
box.appendChild(m);
}
}
// ---- panel de detalle ----
const CRITS = [["c_sonido","Sonido"],["c_soledad","Soledad"],["c_acceso","Acceso"],
["c_agua","Agua"],["c_arbolado","Arbolado"],["c_clima","Clima"]];
function select(c){
selected = c;
el('detail').classList.add('open');
el('dName').textContent = c[K.municipio] || "Sin municipio";
el('dLoc').textContent = `${c[K.comunidad]} · ${c[K.lat].toFixed(5)}, ${c[K.lon].toFixed(5)} · ${c[K.cota]} m`;
el('dStars').innerHTML = `<span style="color:${SCOL[c[K.estrellas]]}">${stars(c[K.estrellas])}</span>`+
`<span style="color:var(--muted);font-size:12px;margin-left:8px">${c[K.score].toFixed(1)}/100</span>`;
el('dCrits').innerHTML = CRITS.map(([k,lab])=>{
const v = c[K[k]];
return `<div class="crit"><span style="color:var(--ink2)">${lab}</span>`+
`<span class="bar"><i style="width:${v}%"></i></span>`+
`<span class="v mono">${v}</span></div>`;
}).join("");
const f = [
["Nivel en la casa más cercana", c[K.db_en_casa].toFixed(0)+" dB"],
["Edificio más cercano", (c[K.d_edificio]/1000).toFixed(1)+" km"],
["Carretera más cercana", (c[K.d_carretera]/1000).toFixed(1)+" km"],
["Pista de acceso", c[K.d_pista]+" m"],
["Agua más cercana", c[K.d_agua]<10000 ? (c[K.d_agua]/1000).toFixed(1)+" km" : "lejos"],
["Arbolado alrededor", c[K.arbolado_pct]+" %"],
["Roca desnuda", c[K.roca_pct]+" %"],
["Pendiente", c[K.pendiente]+"°"],
["Hondonada (TPI)", c[K.tpi]+" m"],
["Máx. mes más cálido", c[K.tmax].toFixed(0)+" °C"],
["Mín. mes más frío", c[K.tmin].toFixed(0)+" °C"],
["Desde Madrid", c[K.km_madrid]+" km en línea recta"],
];
el('dFacts').innerHTML = f.map(([a,b])=>`<div class="fact"><span>${a}</span><b>${b}</b></div>`).join("");
const pk = c[K.protegido_cerca], pkm = c[K.km_protegido];
el('dWarn').innerHTML = pk
? `<div class="warn"><b>Espacio protegido a ${pkm} km:</b> ${pk}.
Está fuera, pero comprueba el límite exacto en el visor oficial antes de ir.</div>`
: `<div class="warn">Sin espacio protegido cartografiado en 12 km. Aun así,
comprueba la propiedad del terreno: casi todo esto es finca privada.</div>`;
const la=c[K.lat], lo=c[K.lon];
const lnk=(href,t,s)=>`<a href="${href}" target="_blank" rel="noopener">`+
`<b>${t}</b><span>${s}</span></a>`;
el('dLinks').innerHTML =
lnk(`https://www.google.com/maps/@${la},${lo},1500m/data=!3m1!1e3`,
'Google satélite','vista aérea y relieve') +
lnk(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${la},${lo}`,
'Street View','la entrada desde el asfalto') +
lnk(`https://www.mapillary.com/app/?lat=${la}&lng=${lo}&z=15`,
'Mapillary','fotos de pista, si las hay') +
lnk(`https://www.openstreetmap.org/#map=16/${la}/${lo}`,
'OpenStreetMap','caminos y topónimos') +
lnk(`https://www1.sedecatastro.gob.es/CYCBienInmueble/OVCListaBienes.aspx?`+
`del=0&mun=0&RCCompleta=&latitud=${la}&longitud=${lo}`,
'Catastro','de quién es la finca') +
lnk(`https://www.google.com/maps/dir/?api=1&destination=${la},${lo}`,
'Cómo llegar','ruta desde donde estés');
// La foto aérea se carga solo al seleccionar, no las 355 de golpe.
el('ortho').src = 'ortho/' + c[K.id] + '.jpg';
if(document.getElementById('detail').classList.contains('t3d')) drawTerrain(c);
else curTile = c;
// Si estamos en el mapa de cerca, que siga a la localización elegida.
if(L2 && document.body.classList.contains('cerca')){
document.body.classList.remove('sinsel');
L2.setView([c[K.lat],c[K.lon]], Math.max(L2.getZoom(),15));
selMarker.setLatLng([c[K.lat],c[K.lon]]).setStyle({opacity:1,fillOpacity:.28});
selMarker.bringToFront();
}
renderList(); draw();
}
el('close').onclick = ()=>{el('detail').classList.remove('open'); selected=null; renderList(); draw();};
// ---- terreno 3D (canvas 2D, algoritmo del pintor) ----
const tc = document.getElementById('tcanvas'), tx = tc.getContext('2d');
let rot = 0.62, tilt = 0.58, curTile = null;
function drawTerrain(c){
curTile = c;
const r = tc.getBoundingClientRect();
tc.width = r.width*dpr; tc.height = r.height*dpr;
tx.setTransform(dpr,0,0,dpr,0,0);
tx.clearRect(0,0,r.width,r.height);
tx.fillStyle="#0a0d12"; tx.fillRect(0,0,r.width,r.height);
const idx = C.indexOf(c), off = idx*TSIZE;
const [lo,rng] = c[K.tmeta];
el('tRange').textContent = `${Math.round(lo)}${Math.round(lo+rng)} m`;
// exageración relativa: si el rango real es pequeño, se amplifica para que
// una vaguada suave siga leyéndose como vaguada
const vex = Math.min(2.6, Math.max(0.8, 260/Math.max(rng,40)));
const cs = Math.min(r.width/(NT*1.5), r.height/(NT*1.15));
const cxp = r.width/2, cyp = r.height*0.60;
const ca=Math.cos(rot), sa=Math.sin(rot), ct=Math.cos(tilt), st=Math.sin(tilt);
// h viene en 0-255: hay que devolverlo a metros (h/255*rng) antes de pasarlo
// a píxeles con la escala del terreno (cs píxeles cada 200 m de celda).
function proj(i,j,h){
const x=(j-NT/2), y=(i-NT/2);
const rx = x*ca - y*sa, ry = x*sa + y*ca;
const hpx = (h/255)*rng*vex*cs/200;
return [cxp + rx*cs, cyp + ry*cs*ct - hpx];
}
// pintamos de atrás hacia delante según la rotación
const order=[];
for(let i=0;i<NT-1;i++) for(let j=0;j<NT-1;j++){
const x=(j-NT/2), y=(i-NT/2);
order.push([x*sa + y*ca, i, j]);
}
order.sort((a,b)=>a[0]-b[0]);
for(const [,i,j] of order){
const h00=TER[off+i*NT+j], h10=TER[off+i*NT+j+1],
h01=TER[off+(i+1)*NT+j], h11=TER[off+(i+1)*NT+j+1];
const p0=proj(i,j,h00), p1=proj(i,j+1,h10), p2=proj(i+1,j+1,h11), p3=proj(i+1,j,h01);
// sombreado por pendiente local
const dzx=(h10-h00), dzy=(h01-h00);
const nz = 1/Math.sqrt(dzx*dzx*0.0016+dzy*dzy*0.0016+1);
const lightv = Math.max(0, Math.min(1, 0.34 + 0.66*nz - (dzx+dzy)*0.014));
const t = h00/255;
const rr=Math.round((58+t*150)*lightv), gg=Math.round((74+t*136)*lightv),
bb=Math.round((66+t*104)*lightv);
tx.fillStyle=`rgb(${rr},${gg},${bb})`;
tx.beginPath(); tx.moveTo(p0[0],p0[1]); tx.lineTo(p1[0],p1[1]);
tx.lineTo(p2[0],p2[1]); tx.lineTo(p3[0],p3[1]); tx.closePath(); tx.fill();
}
// marca del punto exacto, en el centro del recorte
const cP = proj(NT/2, NT/2, TER[off+(NT/2|0)*NT+(NT/2|0)]);
tx.beginPath(); tx.arc(cP[0],cP[1]-9,4.5,0,7); tx.fillStyle="#ff7a45"; tx.fill();
tx.strokeStyle="#ff7a45"; tx.lineWidth=1.5;
tx.beginPath(); tx.moveTo(cP[0],cP[1]-5); tx.lineTo(cP[0],cP[1]); tx.stroke();
}
// pestañas del panel: la vista aérea es lo primero que se ve, y el 3D solo se
// dibuja si se pide, que es lo caro
function setTab(t3d){
const d=document.getElementById('detail');
d.classList.toggle('t3d', t3d);
el('tabSat').setAttribute('aria-pressed', String(!t3d));
el('tab3d').setAttribute('aria-pressed', String(t3d));
el('tHint').textContent = t3d
? 'relieve 9,6 × 9,6 km · arrastra para girar'
: 'ortofoto PNOA · 640 m de lado · pulsa para ampliar';
el('tRange').style.display = t3d ? '' : 'none';
if(t3d && curTile) drawTerrain(curTile);
}
el('tabSat').onclick=()=>setTab(false);
el('tab3d').onclick=()=>setTab(true);
el('ortho').onclick=()=>{ if(selected) goCerca(selected); };
let tdrag=null;
tc.addEventListener('pointerdown',e=>{tdrag={x:e.clientX,y:e.clientY,r:rot,t:tilt};
tc.classList.add('drag'); tc.setPointerCapture(e.pointerId);});
tc.addEventListener('pointermove',e=>{ if(!tdrag||!curTile) return;
rot = tdrag.r + (e.clientX-tdrag.x)*0.011;
tilt = Math.max(0.12, Math.min(1.25, tdrag.t + (e.clientY-tdrag.y)*0.006));
drawTerrain(curTile);});
tc.addEventListener('pointerup',()=>{tdrag=null;tc.classList.remove('drag');});
// ---- mapa de cerca (Leaflet + servicios oficiales) ----
// Esta es la única parte que necesita internet: las teselas vienen del IGN,
// del Catastro y de OSM. Sin conexión, el resto del visor sigue funcionando.
let L2=null, baseLayers={}, overlays={}, ptsLayer=null, selMarker=null, curBase='pnoa';
const IGN_ATTR='<a href="https://www.ign.es">IGN</a> · PNOA';
function initLeaf(){
if(L2) return;
L2 = L.map('leaf',{zoomControl:true, attributionControl:true}).setView([40.0,-4.0],14);
const wmts=(layer,fmt)=>'https://www.ign.es/wmts/'+layer+
'?service=WMTS&request=GetTile&version=1.0.0&format='+fmt+
'&layer={lyr}&style=default&tilematrixset=GoogleMapsCompatible'+
'&TileMatrix={z}&TileRow={y}&TileCol={x}';
baseLayers.pnoa = L.tileLayer(
wmts('pnoa-ma','image/jpeg').replace('{lyr}','OI.OrthoimageCoverage'),
{maxZoom:19, attribution:IGN_ATTR});
baseLayers.mtn = L.tileLayer(
wmts('mapa-raster','image/jpeg').replace('{lyr}','MTN'),
{maxZoom:19, attribution:IGN_ATTR});
baseLayers.osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',
{maxZoom:19, attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'});
baseLayers[curBase].addTo(L2);
overlays.cat = L.tileLayer.wms('https://ovc.catastro.meh.es/Cartografia/WMS/ServidorWMS.aspx',
{layers:'Catastro', format:'image/png', transparent:true, maxZoom:19,
attribution:'Dirección General del Catastro'});
overlays.n2k = L.tileLayer.wms('https://geoserver.iepnb.es/geoserver/RN2000/rn2000/wms',
{layers:'rn2000', format:'image/png', transparent:true, opacity:.45, maxZoom:19,
attribution:'MITECO · Red Natura 2000'});
overlays.enp = L.tileLayer.wms('https://geoserver.iepnb.es/geoserver/ENP/enp/wms',
{layers:'enp', format:'image/png', transparent:true, opacity:.45, maxZoom:19,
attribution:'MITECO · ENP'});
if(el('ovN2k').checked) overlays.n2k.addTo(L2);
if(el('ovEnp').checked) overlays.enp.addTo(L2);
ptsLayer = L.layerGroup();
for(const c of C){
const m = L.circleMarker([c[K.lat],c[K.lon]],{
radius: c[K.estrellas]>=4?6:4, color:'#0a0d12', weight:1.5,
fillColor:SCOL[c[K.estrellas]], fillOpacity:.95});
m.bindTooltip(`${''.repeat(c[K.estrellas])} ${c[K.municipio]||''}`,{direction:'top'});
m.on('click',()=>select(c));
ptsLayer.addLayer(m);
}
if(el('ovPts').checked) ptsLayer.addTo(L2);
L.control.scale({imperial:false}).addTo(L2);
// El punto exacto, siempre encima de todo.
selMarker = L.circleMarker([0,0],{radius:9,color:'#ff7a45',weight:3,
fillColor:'#ff7a45',fillOpacity:.28}).addTo(L2);
selMarker.setStyle({opacity:0,fillOpacity:0});
}
function goCerca(c){
document.body.classList.add('cerca');
el('bEsp').setAttribute('aria-pressed','false');
el('bCerca').setAttribute('aria-pressed','true');
initLeaf();
document.body.classList.toggle('sinsel', !c);
setTimeout(()=>{
L2.invalidateSize();
if(c){
L2.setView([c[K.lat],c[K.lon]], 16);
selMarker.setLatLng([c[K.lat],c[K.lon]]).setStyle({opacity:1,fillOpacity:.28});
selMarker.bringToFront();
}
},30);
}
function goEspana(){
document.body.classList.remove('cerca');
el('bEsp').setAttribute('aria-pressed','true');
el('bCerca').setAttribute('aria-pressed','false');
resize();
}
el('bCerca').onclick=()=>goCerca(selected);
el('bEsp').onclick=goEspana;
el('bVerCerca').onclick=()=>{ if(selected) goCerca(selected); };
document.querySelectorAll('input[name=base]').forEach(r=>{
r.onchange=()=>{ if(!L2) return;
L2.removeLayer(baseLayers[curBase]); curBase=r.value;
baseLayers[curBase].addTo(L2);
// los fondos van por debajo de todo lo demás
baseLayers[curBase].bringToBack(); };
});
const ovMap={ovCat:'cat',ovN2k:'n2k',ovEnp:'enp'};
Object.keys(ovMap).forEach(id=>{
el(id).onchange=()=>{ if(!L2) return;
const l=overlays[ovMap[id]];
if(el(id).checked){ l.addTo(L2); if(selMarker) selMarker.bringToFront(); }
else L2.removeLayer(l); };
});
el('ovPts').onchange=()=>{ if(!L2) return;
if(el('ovPts').checked) ptsLayer.addTo(L2); else L2.removeLayer(ptsLayer); };
// ---- controles ----
document.querySelectorAll('#starf button').forEach(b=>{
b.onclick=()=>{ minStars=+b.dataset.s;
document.querySelectorAll('#starf button').forEach(o=>
o.setAttribute('aria-pressed', o===b ? 'true':'false'));
apply(); };
});
const sliders = [['fmad','vmad',v=>v>=700?'sin límite':v+' km'],
['fson','vson',v=>v], ['fagu','vagu',v=>v],
['farb','varb',v=>v], ['facc','vacc',v=>v]];
sliders.forEach(([id,out,fmt])=>{
const i=el(id); i.oninput=()=>{ el(out).textContent=fmt(+i.value); apply(); };
});
el('bRel').onclick=e=>{show.rel=!show.rel; e.currentTarget.setAttribute('aria-pressed',show.rel); draw();};
el('bIso').onclick=e=>{show.iso=!show.iso; e.currentTarget.setAttribute('aria-pressed',show.iso); draw();};
el('bProt').onclick=e=>{show.prot=!show.prot; e.currentTarget.setAttribute('aria-pressed',show.prot); draw();};
el('bFit').onclick=fit;
addEventListener('resize',resize);
// El canvas también cambia de tamaño sin que cambie la ventana (p. ej. al
// abrirse el panel lateral), así que observamos su caja además del resize.
if(window.ResizeObserver) new ResizeObserver(()=>resize()).observe(document.getElementById('main'));
// comunidades
[...new Set(C.map(c=>c[K.comunidad]).filter(Boolean))].sort()
.forEach(r=>{const o=document.createElement('option');o.value=o.textContent=r;el('reg').appendChild(o);});
el('legend').innerHTML = [5,4,3,2,1].map(s=>
`<span><i style="background:${SCOL[s]}"></i>${s}</span>`).join("")+
`<span style="color:var(--muted);border-left:1px solid var(--line);padding-left:12px">`+
`<i style="background:#c43f2f;opacity:.75"></i>espacio protegido</span>`;
Promise.all([loadImg('rel',"__RELIEF__"),loadImg('prot',"__PROT__"),loadImg('iso',"__ISO__")])
.then(()=>{ resize(); fit(); apply(); });
apply(); resize();
</script>
</body>
</html>
"""
if __name__ == "__main__":
main()

95
src/config.py Normal file
View file

@ -0,0 +1,95 @@
"""Configuración compartida del pipeline de aislamiento.
Malla de trabajo: EPSG:3035 (ETRS89-LAEA Europa). Es equiárea y está en metros,
así que una distancia euclídea en la malla es una distancia real en el terreno,
que es justo lo que necesitamos para "a cuántos metros está la casa más cercana".
Cubrimos península + Baleares. Canarias queda fuera: en 3035 se deforma mucho y
además no es un destino al que se llegue conduciendo.
"""
from pathlib import Path
import numpy as np
from pyproj import CRS, Transformer
ROOT = Path(__file__).resolve().parent.parent
RAW = ROOT / "data" / "raw"
INTERIM = ROOT / "data" / "interim"
OUT = ROOT / "out"
for _d in (RAW, INTERIM, OUT):
_d.mkdir(parents=True, exist_ok=True)
PBF = RAW / "spain-latest.osm.pbf"
# Ventana geográfica: península + Baleares, con un pequeño margen.
LON_MIN, LON_MAX = -9.60, 4.40
LAT_MIN, LAT_MAX = 35.85, 43.90
CRS_WGS84 = CRS.from_epsg(4326)
CRS_GRID = CRS.from_epsg(3035)
to_grid = Transformer.from_crs(CRS_WGS84, CRS_GRID, always_xy=True).transform
to_wgs = Transformer.from_crs(CRS_GRID, CRS_WGS84, always_xy=True).transform
RES = 100.0 # metros por celda
def _bounds():
"""Bbox en coordenadas de malla, muestreando el borde (la proyección curva)."""
lons = np.linspace(LON_MIN, LON_MAX, 200)
lats = np.linspace(LAT_MIN, LAT_MAX, 200)
edge_lon = np.concatenate([lons, lons, np.full(200, LON_MIN), np.full(200, LON_MAX)])
edge_lat = np.concatenate([np.full(200, LAT_MIN), np.full(200, LAT_MAX), lats, lats])
x, y = to_grid(edge_lon, edge_lat)
return x.min(), y.min(), x.max(), y.max()
_x0, _y0, _x1, _y1 = _bounds()
X_MIN = np.floor(_x0 / RES) * RES
Y_MIN = np.floor(_y0 / RES) * RES
X_MAX = np.ceil(_x1 / RES) * RES
Y_MAX = np.ceil(_y1 / RES) * RES
WIDTH = int(round((X_MAX - X_MIN) / RES))
HEIGHT = int(round((Y_MAX - Y_MIN) / RES))
# Transform estilo rasterio: origen arriba-izquierda, y decreciente.
from rasterio.transform import from_origin # noqa: E402
TRANSFORM = from_origin(X_MIN, Y_MAX, RES, RES)
def window_polygon():
"""La ventana de análisis lat/lon como polígono en coordenadas de malla.
Es la misma ventana con la que se filtran los puntos en extract_osm, y por
eso hay que recortar la máscara del país con ella: en 3035 los bordes son
curvos, así que la rejilla rectangular abarca terreno fuera de la ventana.
"""
from shapely.geometry import Polygon
n = 400
lons = np.linspace(LON_MIN, LON_MAX, n)
lats = np.linspace(LAT_MIN, LAT_MAX, n)
ring_lon = np.concatenate([lons, np.full(n, LON_MAX), lons[::-1], np.full(n, LON_MIN)])
ring_lat = np.concatenate([np.full(n, LAT_MIN), lats, np.full(n, LAT_MAX), lats[::-1]])
x, y = to_grid(ring_lon, ring_lat)
return Polygon(zip(x, y))
def xy_to_rowcol(x, y):
"""Coordenadas de malla -> (fila, columna) en int32. Sin comprobar límites."""
col = ((x - X_MIN) / RES).astype(np.int32)
row = ((Y_MAX - y) / RES).astype(np.int32)
return row, col
def inside(row, col):
return (row >= 0) & (row < HEIGHT) & (col >= 0) & (col < WIDTH)
if __name__ == "__main__":
print(f"malla EPSG:3035 {WIDTH} x {HEIGHT} celdas de {RES:.0f} m")
print(f" x: {X_MIN:,.0f} .. {X_MAX:,.0f}")
print(f" y: {Y_MIN:,.0f} .. {Y_MAX:,.0f}")
print(f" celdas: {WIDTH * HEIGHT / 1e6:,.1f} M ({WIDTH * HEIGHT * 4 / 1e9:.2f} GB por capa float32)")

98
src/dem_terrain.py Normal file
View file

@ -0,0 +1,98 @@
"""Mosaico del DEM sobre la malla de trabajo + derivadas del terreno.
Reproyecta tesela a tesela directamente sobre la malla destino en vez de
construir un mosaico intermedio en 4326: evita un array gigante y es más rápido.
Derivadas:
slope_deg pendiente, para descartar laderas donde no se puede montar nada.
tpi_2km posición topográfica: cota menos la cota media en 2 km. Negativo =
hondonada. Es la métrica que de verdad importa para una rave: en un
hueco el sonido queda encerrado y las luces no se ven desde fuera.
"""
import glob
import sys
import time
import numpy as np
import rasterio
from rasterio.warp import Resampling, reproject
from rasterio.transform import from_origin
from scipy.ndimage import uniform_filter
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
def build_mosaic():
dst = np.full((C.HEIGHT, C.WIDTH), np.nan, dtype=np.float32)
files = sorted(glob.glob(str(C.RAW / "dem" / "*.tif")))
t0 = time.time()
for i, f in enumerate(files):
with rasterio.open(f) as src:
b = src.bounds
# Esquinas + bordes de la tesela a la malla, para acotar la ventana
# destino (la proyección curva los lados, no basta con 4 puntos).
lo = np.concatenate([np.linspace(b.left, b.right, 50)] * 2 +
[np.full(50, b.left), np.full(50, b.right)])
la = np.concatenate([np.full(50, b.bottom), np.full(50, b.top)] +
[np.linspace(b.bottom, b.top, 50)] * 2)
x, y = C.to_grid(lo, la)
c0 = int(np.floor((x.min() - C.X_MIN) / C.RES)) - 2
c1 = int(np.ceil((x.max() - C.X_MIN) / C.RES)) + 2
r0 = int(np.floor((C.Y_MAX - y.max()) / C.RES)) - 2
r1 = int(np.ceil((C.Y_MAX - y.min()) / C.RES)) + 2
c0, r0 = max(c0, 0), max(r0, 0)
c1, r1 = min(c1, C.WIDTH), min(r1, C.HEIGHT)
if c1 <= c0 or r1 <= r0:
continue
win = np.full((r1 - r0, c1 - c0), np.nan, dtype=np.float32)
win_tr = from_origin(C.X_MIN + c0 * C.RES, C.Y_MAX - r0 * C.RES,
C.RES, C.RES)
reproject(
source=rasterio.band(src, 1),
destination=win,
src_transform=src.transform, src_crs=src.crs,
dst_transform=win_tr, dst_crs=C.CRS_GRID,
dst_nodata=np.nan,
resampling=Resampling.average,
)
sub = dst[r0:r1, c0:c1]
m = ~np.isnan(win)
sub[m] = win[m]
if (i + 1) % 25 == 0:
print(f" {i+1}/{len(files)} teselas {time.time()-t0:.0f}s", flush=True)
return dst
def main():
print("mosaico del DEM…", flush=True)
dem = build_mosaic()
cover = np.isfinite(dem).mean()
print(f" cobertura {cover*100:.1f}% de la malla", flush=True)
np.save(C.INTERIM / "dem.npy", dem)
filled = np.where(np.isfinite(dem), dem, 0).astype(np.float32)
print("pendiente…", flush=True)
gy, gx = np.gradient(filled, C.RES)
slope = np.degrees(np.arctan(np.hypot(gx, gy))).astype(np.float32)
slope[~np.isfinite(dem)] = np.nan
np.save(C.INTERIM / "slope_deg.npy", slope)
del gy, gx
print("TPI 2 km…", flush=True)
k = int(round(2000 / C.RES)) | 1 # ventana impar de ~2 km
tpi = (filled - uniform_filter(filled, size=k, mode="nearest")).astype(np.float32)
tpi[~np.isfinite(dem)] = np.nan
np.save(C.INTERIM / "tpi_2km.npy", tpi)
ok = np.isfinite(dem)
print(f"cota min {np.nanmin(dem):.0f} max {np.nanmax(dem):.0f} m")
print(f"pendiente mediana {np.nanmedian(slope[ok]):.1f}°")
print(f"TPI p5/p95 {np.nanpercentile(tpi[ok],5):.1f} / "
f"{np.nanpercentile(tpi[ok],95):.1f} m")
if __name__ == "__main__":
main()

28
src/dl_dem.sh Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Descarga las teselas del DEM Copernicus GLO-90 que cubren península + Baleares.
# El bucket es público (open data en AWS), no hace falta autenticación.
# Las teselas que caen enteras en el mar no existen y devuelven 404: se ignoran.
set -u
DEST=/home/sito/RAVE_SCOUT/data/raw/dem
mkdir -p "$DEST"
BASE=https://copernicus-dem-90m.s3.amazonaws.com
gen_urls() {
for lat in $(seq 35 43); do
for lon in $(seq 1 10); do
printf '%s/Copernicus_DSM_COG_30_N%02d_00_W%03d_00_DEM/Copernicus_DSM_COG_30_N%02d_00_W%03d_00_DEM.tif\n' \
"$BASE" "$lat" "$lon" "$lat" "$lon"
done
for lon in $(seq 0 4); do
printf '%s/Copernicus_DSM_COG_30_N%02d_00_E%03d_00_DEM/Copernicus_DSM_COG_30_N%02d_00_E%03d_00_DEM.tif\n' \
"$BASE" "$lat" "$lon" "$lat" "$lon"
done
done
}
gen_urls | xargs -P 8 -I{} sh -c '
f=$(basename "{}")
curl -sfL --retry 3 -o "'"$DEST"'/$f.part" "{}" && mv "'"$DEST"'/$f.part" "'"$DEST"'/$f" || rm -f "'"$DEST"'/$f.part"
'
echo "teselas descargadas: $(ls -1 "$DEST"/*.tif 2>/dev/null | wc -l)"
du -sh "$DEST"

89
src/dl_orthos.py Normal file
View file

@ -0,0 +1,89 @@
"""Descarga la ortofoto del PNOA de cada candidato para incrustarla en el visor.
El PNOA es del Instituto Geográfico Nacional, gratuito y reutilizable citando la
fuente. Se pide por WMS un recorte de 640 m de lado centrado en cada punto, se
reescala a 320 px y se recomprime: así el visor enseña la foto aérea real al
seleccionar una localización, al instante y sin conexión.
Se piden a 384 px y se bajan a 320: reescalar desde algo más grande da bastante
mejor resultado que pedir directamente el tamaño final.
"""
import io
import pickle
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from PIL import Image
from pyproj import Transformer
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
WMS = "https://www.ign.es/wms-inspire/pnoa-ma"
FETCH_PX = 384
OUT_PX = 320
MPP = 2.0 # metros por píxel del recorte final
QUALITY = 72
WORKERS = 4 # suave con los servidores del IGN
to3857 = Transformer.from_crs(4326, 3857, always_xy=True).transform
DEST = C.INTERIM / "ortho"
def fetch(cd):
path = DEST / f"{cd['id']}.jpg"
if path.exists() and path.stat().st_size > 2000:
return "cache"
x, y = to3857(cd["lon"], cd["lat"])
half = OUT_PX * MPP / 2
params = {
"SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
"LAYERS": "OI.OrthoimageCoverage", "STYLES": "", "CRS": "EPSG:3857",
"BBOX": f"{x-half},{y-half},{x+half},{y+half}",
"WIDTH": FETCH_PX, "HEIGHT": FETCH_PX, "FORMAT": "image/jpeg",
}
for attempt in range(4):
try:
r = requests.get(WMS, params=params, timeout=120)
r.raise_for_status()
if not r.headers.get("content-type", "").startswith("image"):
raise ValueError("respuesta no es imagen")
im = Image.open(io.BytesIO(r.content)).convert("RGB")
im = im.resize((OUT_PX, OUT_PX), Image.LANCZOS)
im.save(path, "JPEG", quality=QUALITY, optimize=True, progressive=True)
return "ok"
except Exception as e:
if attempt == 3:
return f"fallo: {type(e).__name__}"
time.sleep(2 * (attempt + 1))
def main():
DEST.mkdir(parents=True, exist_ok=True)
with open(C.INTERIM / "cands.pkl", "rb") as fh:
cands = pickle.load(fh)
print(f"{len(cands)} ortofotos ({OUT_PX}px, {OUT_PX*MPP:.0f} m de lado)", flush=True)
t0 = time.time()
done = {"ok": 0, "cache": 0}
fails = []
with ThreadPoolExecutor(WORKERS) as ex:
for i, res in enumerate(ex.map(fetch, cands), 1):
if res in done:
done[res] += 1
else:
fails.append(res)
if i % 50 == 0:
print(f" {i}/{len(cands)} {time.time()-t0:.0f}s", flush=True)
total = sum(f.stat().st_size for f in DEST.glob("*.jpg"))
print(f"nuevas={done['ok']} ya estaban={done['cache']} fallos={len(fails)}")
if fails:
print(" " + "; ".join(sorted(set(fails))[:3]))
print(f"total en disco: {total/1e6:.1f} MB en {time.time()-t0:.0f}s")
if __name__ == "__main__":
main()

117
src/dl_protected.py Normal file
View file

@ -0,0 +1,117 @@
"""Descarga los espacios protegidos oficiales desde los servicios de la EEA.
Dos conjuntos, porque no son el mismo y hacen falta los dos:
Natura 2000 ZEC/LIC + ZEPA, la red europea. Es lo que España notifica a
Bruselas, la misma cartografía que publica el MITECO.
NatDA espacios de designación nacional: parques naturales, reservas,
monumentos naturales, paisajes protegidos. Muchos NO están en
Natura 2000, y son justo los que preocupan.
Se descarga por teselas y con las geometrías generalizadas a ~50 m, que sobra
para una malla de 100 m y evita traerse cientos de MB de vértices. Si una tesela
llega truncada por el límite del servidor, se parte en cuatro y se reintenta.
Nota sobre TLS: los servidores del ministerio mandan la cadena incompleta; para
ellos se construye un bundle con el intermedio oficial de la FNMT. Los de la EEA
verifican con el almacén normal.
"""
import json
import sys
import time
from pathlib import Path
import requests
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
BASE = "https://bio.discomap.eea.europa.eu/arcgis/rest/services/ProtectedSites"
SETS = {
"natura2000": f"{BASE}/Natura2000Sites/MapServer/2/query",
"natda": f"{BASE}/NatDAv23_Dyna_WM/MapServer/4/query",
}
OFFSET = 0.0005 # ~50 m de generalización
DEST = C.RAW / "prot"
def fetch(url, bbox, depth=0):
"""Devuelve la lista de features del bbox, subdividiendo si viene truncado."""
params = {
"where": "1=1",
"geometry": ",".join(f"{v:.4f}" for v in bbox),
"geometryType": "esriGeometryEnvelope",
"inSR": "4326", "outSR": "4326",
"spatialRel": "esriSpatialRelIntersects",
"outFields": "*",
"returnGeometry": "true",
"maxAllowableOffset": str(OFFSET),
"f": "geojson",
}
for attempt in range(4):
try:
r = requests.get(url, params=params, timeout=300)
r.raise_for_status()
d = r.json()
break
except Exception as e:
if attempt == 3:
print(f" [fallo] {bbox} {type(e).__name__}", flush=True)
return []
time.sleep(3 * (attempt + 1))
feats = d.get("features") or []
truncated = d.get("properties", {}).get("exceededTransferLimit") or \
d.get("exceededTransferLimit")
if truncated and depth < 3:
x0, y0, x1, y1 = bbox
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
out = []
for sub in ((x0, y0, mx, my), (mx, y0, x1, my),
(x0, my, mx, y1), (mx, my, x1, y1)):
out += fetch(url, sub, depth + 1)
return out
return feats
def main():
DEST.mkdir(parents=True, exist_ok=True)
step = 2.0
tiles = []
y = C.LAT_MIN
while y < C.LAT_MAX:
x = C.LON_MIN
while x < C.LON_MAX:
tiles.append((x, y, min(x + step, C.LON_MAX), min(y + step, C.LAT_MAX)))
x += step
y += step
for name, url in SETS.items():
out_path = DEST / f"{name}.geojson"
if out_path.exists() and out_path.stat().st_size > 1000:
print(f"{name}: ya descargado ({out_path.stat().st_size/1e6:.1f} MB)")
continue
print(f"=== {name}: {len(tiles)} teselas ===", flush=True)
seen, feats = set(), []
t0 = time.time()
for i, b in enumerate(tiles, 1):
got = fetch(url, b)
new = 0
for f in got:
# las teselas solapan en los bordes: deduplicamos por id de sitio
p = f.get("properties", {})
key = (p.get("SITECODE") or p.get("CDDA_ID") or
p.get("SITE_CODE") or p.get("OBJECTID") or json.dumps(p)[:80])
if key in seen:
continue
seen.add(key)
feats.append(f)
new += 1
print(f" [{i:>2}/{len(tiles)}] {b[0]:6.1f},{b[1]:5.1f} "
f"+{new:<5} total={len(feats):,} {time.time()-t0:5.0f}s", flush=True)
with open(out_path, "w") as fh:
json.dump({"type": "FeatureCollection", "features": feats}, fh)
print(f" -> {out_path.name} {len(feats):,} espacios "
f"{out_path.stat().st_size/1e6:.1f} MB", flush=True)
if __name__ == "__main__":
main()

93
src/extract_areas.py Normal file
View file

@ -0,0 +1,93 @@
"""Pasada 2 sobre el PBF: polígonos (exclusiones + límites administrativos).
Se usa with_areas() porque muchos parques nacionales y todos los límites
administrativos están en OSM como relaciones multipolígono, no como ways
cerrados: sin ensamblar relaciones la máscara se dejaría fuera justo los
espacios más grandes.
El límite de España (admin_level=2) no es opcional. El extracto de Geofabrik
está recortado al país, así que al otro lado de la frontera no hay edificios
en los datos aunque los haya en el terreno. Sin recortar, todo el borde
portugués y pirenaico saldría como "aislamiento perfecto" por un artefacto.
Los niveles 4 y 8 (comunidad y municipio) sirven para etiquetar candidatos.
"""
import pickle
import sys
import time
import osmium
import osmium.geom
from shapely import wkb as shapely_wkb
from shapely.ops import transform as shp_transform
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
WKB = osmium.geom.WKBFactory()
ADMIN_LEVELS = {"2": "country", "4": "region", "8": "municipality"}
def classify(tags):
if tags.get("boundary") == "administrative":
lvl = ADMIN_LEVELS.get(tags.get("admin_level", ""))
return ("admin_" + lvl) if lvl else None
if tags.get("landuse") == "military" or "military" in tags:
return "military"
if (tags.get("boundary") in ("protected_area", "national_park")
or tags.get("leisure") == "nature_reserve"
or "protect_class" in tags):
return "protected"
if tags.get("natural") == "water" or tags.get("landuse") == "reservoir":
return "water"
if tags.get("landuse") in ("residential", "industrial", "commercial", "retail"):
return "urban"
return None
def main():
keys = osmium.filter.KeyFilter(
"landuse", "military", "boundary", "leisure", "protect_class", "natural")
fp = osmium.FileProcessor(str(C.PBF)).with_areas().with_filter(keys)
cats = ["admin_country", "admin_region", "admin_municipality",
"military", "protected", "water", "urban"]
out = {c: [] for c in cats}
t0 = time.time()
n = nbad = 0
for obj in fp:
if not obj.is_area():
continue
cat = classify(obj.tags)
if cat is None:
continue
try:
geom = shapely_wkb.loads(WKB.create_multipolygon(obj), hex=True)
except Exception:
nbad += 1
continue
if geom.is_empty:
continue
n += 1
if n % 20000 == 0:
print(f" {n:,} áreas {time.time()-t0:.0f}s", flush=True)
out[cat].append({"geom": geom, "name": obj.tags.get("name", "")})
print(f"total {n:,} áreas ({nbad:,} descartadas) en {time.time()-t0:.0f}s", flush=True)
for cat, items in out.items():
# Reproyectamos a la malla antes de guardar para que el rasterizado
# posterior sea directo.
recs = []
for it in items:
try:
g = shp_transform(lambda xx, yy: C.to_grid(xx, yy), it["geom"])
except Exception:
continue
recs.append({"geom": g, "name": it["name"]})
with open(C.INTERIM / f"area_{cat}.pkl", "wb") as fh:
pickle.dump(recs, fh, protocol=4)
print(f" area_{cat:20s} {len(recs):>8,} polígonos", flush=True)
if __name__ == "__main__":
main()

140
src/extract_extra.py Normal file
View file

@ -0,0 +1,140 @@
"""Capas adicionales para el modelo de estrellas.
Pasada A (líneas y nodos): cursos de agua y pistas clasificadas por calidad.
Pasada B (áreas): arbolado, roca desnuda, matorral y cultivo.
La calidad de la pista importa tanto como su existencia: una grade1 compactada
admite una furgoneta cargada, una grade5 embarrada en un repecho no. OSM lo
etiqueta con tracktype y surface, así que se separan en dos capas en vez de
tratar todas las pistas por igual como hacía el primer modelo.
"""
import pickle
import sys
import time
from array import array
import numpy as np
import osmium
import osmium.geom
from shapely import wkb as shapely_wkb
from shapely.ops import transform as shp_transform
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
from extract_osm import Lines, Pts # reutilizamos los acumuladores
WKB = osmium.geom.WKBFactory()
WATER_LINE = {"river", "stream", "canal"}
GOOD_TRACK = {"grade1", "grade2"}
GOOD_SURF = {"paved", "asphalt", "concrete", "compacted", "gravel",
"fine_gravel", "pebblestone"}
BAD_SURF = {"mud", "sand", "grass", "ground", "dirt", "earth"}
def area_class(tags):
n, lu = tags.get("natural"), tags.get("landuse")
if n == "wood" or lu == "forest":
return "forest"
if n in ("bare_rock", "scree", "shingle", "cliff"):
return "rock"
if n in ("scrub", "heath", "grassland"):
return "scrub"
if lu in ("farmland", "orchard", "vineyard"):
return "farmland"
return None
def pass_lines(pbf, suffix):
water = Lines()
track_good, track_bad = Lines(), Lines()
springs = Pts()
keys = osmium.filter.KeyFilter("waterway", "highway", "natural")
fp = (osmium.FileProcessor(pbf, osmium.osm.NODE | osmium.osm.WAY)
.with_locations("flex_mem").with_filter(keys))
t0 = time.time()
for obj in fp:
tags = obj.tags
if obj.is_node():
if tags.get("natural") == "spring" and obj.location.valid():
springs.add(obj.location.lon, obj.location.lat)
continue
ww, hw = tags.get("waterway"), tags.get("highway")
if ww is None and hw != "track":
continue
try:
lons = [nd.lon for nd in obj.nodes]
lats = [nd.lat for nd in obj.nodes]
except osmium.InvalidLocationError:
continue
if len(lons) < 2:
continue
if ww in WATER_LINE:
water.add(lons, lats)
elif hw == "track":
tt, sf = tags.get("tracktype"), tags.get("surface")
good = (tt in GOOD_TRACK) or (sf in GOOD_SURF)
bad = (sf in BAD_SURF) or (tt in ("grade4", "grade5"))
(track_bad if (bad and not good) else track_good).add(lons, lats)
print(f" pasada de líneas {time.time()-t0:.0f}s", flush=True)
def save(name, lon, lat):
lon, lat = np.asarray(lon), np.asarray(lat)
m = ((lon >= C.LON_MIN) & (lon <= C.LON_MAX) &
(lat >= C.LAT_MIN) & (lat <= C.LAT_MAX))
x, y = C.to_grid(lon[m], lat[m])
np.save(C.INTERIM / f"{name}{suffix}.npy",
np.stack([x, y]).astype(np.float32))
print(f" {name+suffix:20s} {int(m.sum()):>12,} pts", flush=True)
save("line_water", *water.densify())
save("line_track_good", *track_good.densify())
save("line_track_bad", *track_bad.densify())
save("springs", *springs.arrays())
def pass_areas(pbf, suffix):
keys = osmium.filter.KeyFilter("natural", "landuse")
fp = osmium.FileProcessor(pbf).with_areas().with_filter(keys)
out = {"forest": [], "rock": [], "scrub": [], "farmland": []}
t0 = time.time()
n = 0
for obj in fp:
if not obj.is_area():
continue
cat = area_class(obj.tags)
if cat is None:
continue
try:
g = shapely_wkb.loads(WKB.create_multipolygon(obj), hex=True)
except Exception:
continue
if g.is_empty:
continue
n += 1
out[cat].append(g)
print(f" pasada de áreas: {n:,} en {time.time()-t0:.0f}s", flush=True)
for cat, geoms in out.items():
recs = []
for g in geoms:
try:
recs.append(shp_transform(lambda xx, yy: C.to_grid(xx, yy), g))
except Exception:
continue
with open(C.INTERIM / f"area_{cat}{suffix}.pkl", "wb") as fh:
pickle.dump(recs, fh, protocol=4)
print(f" area_{cat+suffix:18s} {len(recs):>10,}", flush=True)
if __name__ == "__main__":
pbf = sys.argv[1] if len(sys.argv) > 1 else str(C.PBF)
suf = sys.argv[2] if len(sys.argv) > 2 else ""
print(f"== {pbf} ==", flush=True)
pass_lines(pbf, suf)
pass_areas(pbf, suf)

207
src/extract_osm.py Normal file
View file

@ -0,0 +1,207 @@
"""Pasada 1 sobre el PBF: extrae puntos y líneas de interés.
Salida: .npy con coordenadas ya proyectadas a EPSG:3035 en data/interim/.
Las líneas (carreteras, pistas) se densifican a <=60 m para que al rasterizarlas
en la malla de 100 m no queden huecos entre vértices: OSM guarda tramos rectos
largos con solo dos nodos, y sin densificar una autovía aparecería como una
ristra de puntos sueltos en vez de como una barrera continua.
"""
import sys
import time
from array import array
import numpy as np
import osmium
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
STEP_M = 60.0
# Clasificación de viales. La distinción que importa para una rave no es
# "carretera vs no", sino: por dónde pasa gente (malo) y por dónde puedo meter
# un coche cargado (imprescindible).
MAJOR = {"motorway", "trunk", "primary", "secondary",
"motorway_link", "trunk_link", "primary_link", "secondary_link"}
MINOR = {"tertiary", "unclassified", "residential", "living_street",
"tertiary_link", "road"}
TRACK = {"track", "service"}
PATH = {"path", "footway", "bridleway", "cycleway", "steps"}
PLACE_KEEP = {"city", "town", "village", "hamlet", "isolated_dwelling",
"farm", "suburb", "borough", "quarter", "neighbourhood"}
class Lines:
"""Acumula vértices de varias polilíneas en arrays planos."""
def __init__(self):
self.lon = array("d")
self.lat = array("d")
self.counts = array("i")
def add(self, lons, lats):
if len(lons) < 2:
return
self.lon.extend(lons)
self.lat.extend(lats)
self.counts.append(len(lons))
def densify(self):
"""Devuelve puntos a <=STEP_M de separación, vectorizado."""
if not self.counts:
return np.zeros(0), np.zeros(0)
lon = np.frombuffer(self.lon, dtype=np.float64)
lat = np.frombuffer(self.lat, dtype=np.float64)
counts = np.frombuffer(self.counts, dtype=np.int32).astype(np.int64)
ends = np.cumsum(counts) - 1 # último vértice de cada línea
keep = np.ones(lon.size, dtype=bool)
keep[ends] = False # no arranca segmento
i0 = np.flatnonzero(keep)
i1 = i0 + 1
lon0, lat0 = lon[i0], lat[i0]
lon1, lat1 = lon[i1], lat[i1]
# Longitud métrica aproximada (suficiente para decidir cuántos pasos).
mlat = 111320.0
mlon = mlat * np.cos(np.radians(0.5 * (lat0 + lat1)))
seg = np.hypot((lon1 - lon0) * mlon, (lat1 - lat0) * mlat)
nstep = np.maximum(1, np.ceil(seg / STEP_M).astype(np.int64))
total = int(nstep.sum())
# t = 0, 1/n, 2/n ... por segmento, generado sin bucles de Python.
rep = np.repeat(np.arange(nstep.size), nstep)
offs = np.arange(total) - np.repeat(np.cumsum(nstep) - nstep, nstep)
t = offs / nstep[rep]
out_lon = lon0[rep] + (lon1[rep] - lon0[rep]) * t
out_lat = lat0[rep] + (lat1[rep] - lat0[rep]) * t
# Añadimos los vértices finales para no perder los extremos.
return (np.concatenate([out_lon, lon[ends]]),
np.concatenate([out_lat, lat[ends]]))
class Pts:
def __init__(self):
self.lon = array("d")
self.lat = array("d")
def add(self, lo, la):
self.lon.append(lo)
self.lat.append(la)
def arrays(self):
return (np.frombuffer(self.lon, dtype=np.float64),
np.frombuffer(self.lat, dtype=np.float64))
def main(pbf=None, suffix=""):
pbf = pbf or str(C.PBF)
buildings = Pts()
lamps = Pts()
places = {k: Pts() for k in PLACE_KEEP}
lines = {"major": Lines(), "minor": Lines(), "track": Lines(),
"path": Lines(), "rail": Lines()}
# El filtro por clave se aplica en C++, así que los ~200 M de nodos sin
# etiquetas ni siquiera llegan a Python. Las localizaciones se indexan
# antes del filtro, así que las geometrías de los ways siguen resolviendo.
keys = osmium.filter.KeyFilter(
"building", "highway", "place", "railway", "building:part")
fp = (osmium.FileProcessor(pbf, osmium.osm.NODE | osmium.osm.WAY)
.with_locations("flex_mem")
.with_filter(keys))
t0 = time.time()
n = 0
n_bad_geom = 0
for obj in fp:
n += 1
if n % 5_000_000 == 0:
print(f" {n/1e6:5.1f} M objetos {time.time()-t0:6.0f}s "
f"edificios={len(buildings.lon):,}", flush=True)
tags = obj.tags
if obj.is_node():
loc = obj.location
if not loc.valid():
continue
if tags.get("highway") == "street_lamp":
lamps.add(loc.lon, loc.lat)
p = tags.get("place")
if p in PLACE_KEEP:
places[p].add(loc.lon, loc.lat)
continue
# --- ways ---
b = tags.get("building")
hw = tags.get("highway")
rw = tags.get("railway")
if b is None and hw is None and rw is None:
continue
try:
lons = [nd.lon for nd in obj.nodes]
lats = [nd.lat for nd in obj.nodes]
except osmium.InvalidLocationError:
n_bad_geom += 1
continue
if not lons:
continue
if b is not None and b != "no":
# Centroide del contorno: para "a qué distancia está la casa más
# cercana" el centro de la planta es precisión de sobra.
buildings.add(sum(lons) / len(lons), sum(lats) / len(lats))
continue
if hw is not None:
if hw in MAJOR:
lines["major"].add(lons, lats)
elif hw in MINOR:
lines["minor"].add(lons, lats)
elif hw in TRACK:
lines["track"].add(lons, lats)
elif hw in PATH:
lines["path"].add(lons, lats)
elif rw in ("rail", "light_rail", "narrow_gauge"):
lines["rail"].add(lons, lats)
print(f"pasada completa: {n:,} objetos en {time.time()-t0:.0f}s "
f"({n_bad_geom:,} ways sin geometría)", flush=True)
def save(name, lon, lat):
lon = np.asarray(lon)
lat = np.asarray(lat)
m = ((lon >= C.LON_MIN) & (lon <= C.LON_MAX) &
(lat >= C.LAT_MIN) & (lat <= C.LAT_MAX))
x, y = C.to_grid(lon[m], lat[m])
arr = np.stack([x, y]).astype(np.float32)
np.save(C.INTERIM / f"{name}{suffix}.npy", arr)
print(f" {name+suffix:20s} {arr.shape[1]:>12,} pts", flush=True)
print("proyectando y guardando…", flush=True)
save("buildings", *buildings.arrays())
save("lamps", *lamps.arrays())
for k, v in places.items():
lo, la = v.arrays()
if len(lo):
save(f"place_{k}", lo, la)
for k, v in lines.items():
lo, la = v.densify()
save(f"line_{k}", lo, la)
if __name__ == "__main__":
# Sin argumentos: España. Con argumentos: <ruta.pbf> <sufijo>, para los
# países vecinos, cuyos edificios hacen falta para no inflar el aislamiento
# a este lado de la frontera.
if len(sys.argv) > 1:
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "")
else:
main()

133
src/render_map.py Normal file
View file

@ -0,0 +1,133 @@
"""Renderiza el mapa de aislamiento a PNG (versión clara y oscura).
Se dibuja solo España porque el análisis solo es válido dentro de España: los
datos de los vecinos entran para calcular distancias correctas en la frontera,
pero sus celdas no se puntúan ni se muestran.
Dos versiones de color porque un PNG no se adapta al tema de quien lo mira, y
el informe .
"""
import csv
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFont
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
F = 8 # factor de reducción -> ~1645 px de ancho
# Rampa secuencial azul (100 -> 700), magnitud continua, un solo tono.
RAMP = ["#cde2fb", "#b7d3f6", "#9ec5f4", "#86b6ef", "#6da7ec", "#5598e7",
"#3987e5", "#2a78d6", "#256abf", "#1c5cab", "#184f95", "#104281",
"#0d366b"]
# El fondo de cada versión es el mismo que el de la página, para que el mapa no
# lleve marco y se funda con el papel.
#
# En claro la rampa va de claro a oscuro: más aislado = azul más denso, que
# destaca sobre papel claro. En oscuro se invierte. No es un volteo automático
# del gradiente sino la misma rampa recorrida al revés, porque lo que tiene que
# conservarse es el significado: lo aislado siempre es lo que más resalta. Con
# la rampa sin invertir, los vacíos —justo lo que buscamos— se disolverían en
# el fondo negro.
THEMES = {
"light": dict(surface="#e9edf2", ring="#ffffff", label="#0f1620",
halo="#ffffff", reverse=False),
"dark": dict(surface="#0b0f14", ring="#0b0f14", label="#e8edf3",
halo="#0b0f14", reverse=True),
}
MARKER = {"light": "#d4541f", "dark": "#f08a5a"}
def hex2rgb(h):
return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5))
def ramp_lut(reverse=False):
cols = np.array([hex2rgb(h) for h in (RAMP[::-1] if reverse else RAMP)],
dtype=np.float32)
xs = np.linspace(0, 255, len(cols))
lut = np.zeros((256, 3), dtype=np.uint8)
for ch in range(3):
lut[:, ch] = np.interp(np.arange(256), xs, cols[:, ch]).astype(np.uint8)
return lut
def block_reduce(a, f, how="mean"):
h = (a.shape[0] // f) * f
w = (a.shape[1] // f) * f
a = a[:h, :w].reshape(h // f, f, w // f, f)
return a.mean(axis=(1, 3)) if how == "mean" else a.max(axis=(1, 3))
def get_font(size):
for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def main():
iso = np.load(C.INTERIM / "iso_pure.npy")
spain = np.load(C.INTERIM / "mask_spain.npy").astype(bool)
vals = iso[spain & np.isfinite(iso)]
lo, hi = np.percentile(vals, 2), np.percentile(vals, 99.5)
print(f"escala del mapa: {lo:.0f} .. {hi:.0f} m")
filled = np.where(np.isfinite(iso), iso, 0.0)
small = block_reduce(filled, F, "mean")
land = block_reduce(spain.astype(np.float32), F, "mean") > 0.35
norm = np.clip((small - lo) / (hi - lo), 0, 1)
idx = (norm * 255).astype(np.uint8)
with open(C.OUT / "candidatos_estrellas.csv") as fh:
cands = [c for c in csv.DictReader(fh) if int(c["estrellas"]) >= 3]
cands.sort(key=lambda c: -float(c["score"]))
for i, c in enumerate(cands, 1):
c["rank"] = i
for theme, col in THEMES.items():
rgb = ramp_lut(col["reverse"])[idx]
img_arr = np.empty(rgb.shape, dtype=np.uint8)
img_arr[:] = hex2rgb(col["surface"])
img_arr[land] = rgb[land]
img = Image.fromarray(img_arr, "RGB")
d = ImageDraw.Draw(img)
f_lbl = get_font(15)
for cd in cands:
x, y = C.to_grid(float(cd["lon"]), float(cd["lat"]))
px = (x - C.X_MIN) / C.RES / F
py = (C.Y_MAX - y) / C.RES / F
rank = int(cd["rank"])
r = 6 if rank <= 10 else 4
# anillo del color de la superficie: separa el punto del fondo
d.ellipse([px - r - 2, py - r - 2, px + r + 2, py + r + 2],
fill=hex2rgb(col["ring"]))
d.ellipse([px - r, py - r, px + r, py + r], fill=hex2rgb(MARKER[theme]))
if rank <= 10:
t = str(rank)
tx, ty = px + r + 5, py - 9
for ox, oy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
d.text((tx + ox, ty + oy), t, font=f_lbl,
fill=hex2rgb(col["halo"]))
d.text((tx, ty), t, font=f_lbl, fill=hex2rgb(col["label"]))
out = C.OUT / f"mapa_{theme}.png"
# Paleta de 200 colores: la rampa es continua pero el ojo no distingue
# más pasos, y el PNG baja de ~1,5 MB a ~400 KB, que importa porque va
# incrustado en el informe.
img.convert("P", palette=Image.ADAPTIVE, colors=200).save(out, optimize=True)
print(f" {out.name} {img.size[0]}x{img.size[1]} "
f"{out.stat().st_size/1024:.0f} KB")
if __name__ == "__main__":
main()

113
src/render_relief.py Normal file
View file

@ -0,0 +1,113 @@
"""Mapa base de relieve de España: tinta hipsométrica + sombreado del terreno.
Se genera desde el DEM de Copernicus ya remuestreado a la malla, así que el
relieve coincide exactamente con las capas del modelo y con las coordenadas que
usa el visor para colocar los marcadores.
La paleta va deliberadamente desaturada: el mapa es el fondo, y lo que tiene que
destacar encima son los candidatos.
"""
import sys
import numpy as np
from PIL import Image
from scipy.ndimage import uniform_filter
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
F = 5 # factor de reducción -> ~2631 x 2152 px
# Tinta hipsométrica: cota (m) -> color
HYPSO = [
(0, (0xd7, 0xdf, 0xcd)), (150, (0xc6, 0xd0, 0xb4)),
(400, (0xd5, 0xcd, 0xa5)), (800, (0xcd, 0xb4, 0x88)),
(1200, (0xbc, 0x9a, 0x72)), (1700, (0xa5, 0x84, 0x6c)),
(2200, (0x99, 0x86, 0x80)), (2800, (0xc2, 0xbc, 0xba)),
(3500, (0xee, 0xed, 0xec)),
]
def block_mean(a, f):
h, w = (a.shape[0] // f) * f, (a.shape[1] // f) * f
return a[:h, :w].reshape(h // f, f, w // f, f).mean(axis=(1, 3))
def hypso_lut():
lut = np.zeros((3501, 3), dtype=np.float32)
elevs = [e for e, _ in HYPSO]
for ch in range(3):
lut[:, ch] = np.interp(np.arange(3501), elevs,
[c[ch] for _, c in HYPSO])
return lut
def hillshade(z, res, az=315.0, alt=45.0):
"""Sombreado estándar. z ya viene suavizado para que no granule."""
gy, gx = np.gradient(z, res)
slope = np.arctan(np.hypot(gx, gy))
aspect = np.arctan2(-gx, gy)
a, zn = np.radians(alt), np.radians(az)
hs = (np.sin(a) * np.cos(slope) +
np.cos(a) * np.sin(slope) * np.cos(zn - aspect))
return np.clip(hs, 0, 1)
def main():
dem = np.load(C.INTERIM / "dem.npy")
spain = np.load(C.INTERIM / "mask_spain.npy").astype(bool)
z = block_mean(np.where(np.isfinite(dem), dem, 0.0), F)
land = block_mean(spain.astype(np.float32), F) > 0.35
res = C.RES * F
# Exageración vertical: sin ella la meseta sale plana y no se lee nada.
hs = hillshade(uniform_filter(z, 3, mode="nearest") * 2.2, res)
hs = 0.55 + 0.45 * hs # no aplastar a negro los umbríos
lut = hypso_lut()
zi = np.clip(z, 0, 3500).astype(np.int32)
rgb = lut[zi] * hs[..., None]
# RGBA con el mar transparente: así el visor puede ponerle el fondo que
# quiera sin que aparezca un rectángulo de color alrededor del país.
rgba = np.zeros((*z.shape, 4), dtype=np.uint8)
rgba[..., :3][land] = np.clip(rgb[land], 0, 255).astype(np.uint8)
rgba[..., 3][land] = 255
out = C.OUT / "relieve.png"
Image.fromarray(rgba, "RGBA").save(out, optimize=True)
print(f" {out.name} {z.shape[1]}x{z.shape[0]} "
f"{out.stat().st_size/1024:.0f} KB")
# --- capa de espacios protegidos ---
prot = block_mean(
np.load(C.INTERIM / "mask_protected_official.npy").astype(np.float32), F)
ov = np.zeros((*z.shape, 4), dtype=np.uint8)
m = land & (prot > 0.25)
ov[..., 0][m], ov[..., 1][m], ov[..., 2][m] = 0xc4, 0x3f, 0x2f
ov[..., 3][m] = (np.clip(prot[m], 0, 1) * 150).astype(np.uint8)
p_out = C.OUT / "protegidos.png"
Image.fromarray(ov, "RGBA").save(p_out, optimize=True)
print(f" {p_out.name} {p_out.stat().st_size/1024:.0f} KB "
f"({m.mean()*100:.1f}% de la imagen)")
# --- capa de aislamiento ---
iso = block_mean(np.nan_to_num(np.load(C.INTERIM / "iso_pure.npy")), F)
lo, hi = np.percentile(iso[land], 2), np.percentile(iso[land], 99)
t = np.clip((iso - lo) / (hi - lo), 0, 1)
io = np.zeros((*z.shape, 4), dtype=np.uint8)
io[..., 0][land] = (0x14 + t[land] * 0x20).astype(np.uint8)
io[..., 1][land] = (0x3a + t[land] * 0x60).astype(np.uint8)
io[..., 2][land] = (0x7a + t[land] * 0x70).astype(np.uint8)
io[..., 3][land] = (t[land] * 205).astype(np.uint8)
i_out = C.OUT / "aislamiento_ov.png"
Image.fromarray(io, "RGBA").save(i_out, optimize=True)
print(f" {i_out.name} {i_out.stat().st_size/1024:.0f} KB")
# Geometría que necesita el visor para situar los marcadores.
print(f" origen x={C.X_MIN} y={C.Y_MAX} escala={res} m/px")
if __name__ == "__main__":
main()

417
src/report.py Normal file
View file

@ -0,0 +1,417 @@
"""Genera el informe HTML a partir del CSV de estrellas y los mapas."""
import base64
import csv
import html
import sys
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
def b64(path):
return base64.b64encode(path.read_bytes()).decode("ascii")
def esc(s):
return html.escape(str(s))
def km(m):
return f"{int(m)/1000:.1f}".replace(".", ",")
def main():
import json
ST = json.load(open(C.OUT / "stats.json"))
rows = list(csv.DictReader(open(C.OUT / "candidatos_estrellas.csv")))
rows.sort(key=lambda r: -float(r["score"]))
dist = {s: sum(1 for r in rows if int(r["estrellas"]) == s) for s in range(1, 6)}
top = [r for r in rows if int(r["estrellas"]) >= 4]
trs = []
for i, r in enumerate(top, 1):
lat, lon = r["lat"], r["lon"]
s = int(r["estrellas"])
gm = f"https://www.google.com/maps/@{lat},{lon},1500m/data=!3m1!1e3"
sv = f"https://www.google.com/maps/@?api=1&map_action=pano&viewpoint={lat},{lon}"
trs.append(f"""<tr>
<td class="rk">{i}</td>
<td class="st" data-s="{s}">{''*s}</td>
<td class="co">{esc(lat)}, {esc(lon)}</td>
<td>{esc(r['municipio']) or ''}</td>
<td class="rg">{esc(r['comunidad'])}</td>
<td class="nu">{esc(r['db_en_casa'])}</td>
<td class="nu">{km(r['d_edificio'])}</td>
<td class="nu">{esc(r['d_pista'])}</td>
<td class="nu">{km(r['d_agua'])}</td>
<td class="nu">{esc(r['arbolado_pct'])}</td>
<td class="nu">{km(r['d_protegido'])}</td>
<td class="nu">{esc(r['km_madrid'])}</td>
<td class="lk"><a href="{gm}" target="_blank" rel="noopener">sat</a>
<a href="{sv}" target="_blank" rel="noopener">sv</a></td>
</tr>""")
mil = lambda v: f"{v:,}".replace(",", ".")
doc = TEMPLATE.format(
map_light=b64(C.OUT / "mapa_light.png"),
map_dark=b64(C.OUT / "mapa_dark.png"),
rows="\n".join(trs),
n=len(rows), ntop=len(top),
d5=dist[5], d4=dist[4], d3=dist[3], d2=dist[2], d1=dist[1],
prot_pct=str(ST["prot_pct"]).replace(".", ","),
prot_km2=mil(ST["prot_km2"]),
eleg_pct=str(ST["elegibles_pct"]).replace(".", ","),
eleg_km2=mil(ST["elegibles_km2"]),
acus_km2=mil(ST["acustico_km2"]),
mejor_db=str(ST["mejor_db"]).replace(".", ","),
max_build=str(ST["max_d_build_km"]).replace(".", ","),
min_build=mil(ST["min_d_build"]), min_road=ST["min_d_road"],
max_spl=int(ST["max_spl"]), min_sep=ST["min_sep_km"],
max_acc=ST["max_d_access"], max_slope=int(ST["max_slope"]),
min_prot=ST["min_d_prot"],
)
out = C.OUT / "informe.html"
out.write_text(doc, encoding="utf-8")
print(f"{out} {out.stat().st_size/1e6:.2f} MB ({len(rows)} candidatos, {len(top)} en tabla)")
TEMPLATE = """<title>Dónde no hay nadie · aislamiento en España</title>
<style>
:root {{
color-scheme: light;
--ground:#e9edf2; --surface:#fbfcfd; --ink:#0f1620; --ink2:#46525f;
--muted:#78848f; --rule:#ccd6e0; --accent:#1c5cab; --signal:#c14a18;
--gold:#a07a12; --danger:#b3361f;
--hair:rgba(15,22,32,.09); --shade:rgba(15,22,32,.035);
--r0:#cde2fb; --r1:#6da7ec; --r2:#2a78d6; --r3:#184f95; --r4:#0d366b;
}}
@media (prefers-color-scheme: dark) {{
:root:not([data-theme="light"]) {{
color-scheme: dark;
--ground:#0b0f14; --surface:#121a23; --ink:#e8edf3; --ink2:#a7b4c2;
--muted:#6f7d8c; --rule:#243240; --accent:#77aeee; --signal:#f08a5a;
--gold:#ffd95e; --danger:#f0785a;
--hair:rgba(232,237,243,.10); --shade:rgba(232,237,243,.04);
--r0:#0d366b; --r1:#184f95; --r2:#2a78d6; --r3:#6da7ec; --r4:#cde2fb;
}}
}}
:root[data-theme="dark"] {{
color-scheme: dark;
--ground:#0b0f14; --surface:#121a23; --ink:#e8edf3; --ink2:#a7b4c2;
--muted:#6f7d8c; --rule:#243240; --accent:#77aeee; --signal:#f08a5a;
--gold:#ffd95e; --danger:#f0785a;
--hair:rgba(232,237,243,.10); --shade:rgba(232,237,243,.04);
--r0:#0d366b; --r1:#184f95; --r2:#2a78d6; --r3:#6da7ec; --r4:#cde2fb;
}}
*, *::before, *::after {{ box-sizing: border-box; }}
body {{
margin:0; background:var(--ground); color:var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size:17px; line-height:1.62; -webkit-font-smoothing:antialiased;
}}
.wrap {{ max-width:1180px; margin:0 auto; padding:0 24px 96px; }}
.col {{ max-width:68ch; }}
h1,h2,h3 {{ font-family: ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif;
font-weight:600; text-wrap:balance; letter-spacing:-.012em; margin:0; }}
h1 {{ font-size:clamp(2.6rem,6.5vw,4.4rem); line-height:1.02; letter-spacing:-.028em; }}
h2 {{ font-size:clamp(1.55rem,3vw,2.05rem); line-height:1.14; }}
h3 {{ font-size:1.12rem; line-height:1.3; }}
p {{ margin:0; }}
a {{ color:var(--accent); text-underline-offset:3px; text-decoration-thickness:1px; }}
a:focus-visible {{ outline:2px solid var(--accent); outline-offset:3px; border-radius:2px; }}
.eyebrow {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size:.705rem; letter-spacing:.15em; text-transform:uppercase; color:var(--muted); }}
header.masthead {{ padding:72px 0 0; display:flex; flex-direction:column; gap:22px; }}
.standfirst {{ font-size:1.24rem; line-height:1.5; color:var(--ink2); max-width:60ch; }}
.byline {{ display:flex; flex-wrap:wrap; gap:8px 20px; padding-top:6px;
border-top:1px solid var(--rule); color:var(--muted); font-size:.85rem; }}
section {{ display:flex; flex-direction:column; gap:20px; padding-top:64px; }}
.sec-head {{ display:flex; flex-direction:column; gap:9px; }}
.body-flow {{ display:flex; flex-direction:column; gap:16px; }}
.note {{ border-left:3px solid var(--danger); background:var(--shade);
padding:18px 22px; display:flex; flex-direction:column; gap:10px; max-width:68ch; }}
.note strong {{ color:var(--ink); }}
.stats {{ display:grid; gap:1px; background:var(--rule);
grid-template-columns:repeat(auto-fit,minmax(178px,1fr)); border:1px solid var(--rule); }}
.stat {{ background:var(--surface); padding:19px 20px; display:flex;
flex-direction:column; gap:5px; }}
.stat b {{ font-size:1.92rem; font-weight:600; line-height:1;
font-variant-numeric:tabular-nums; letter-spacing:-.02em; }}
.stat span {{ font-size:.83rem; color:var(--ink2); line-height:1.42; }}
figure {{ margin:0; display:flex; flex-direction:column; gap:14px; }}
.map img {{ width:100%; height:auto; display:block; }}
.map-dark {{ display:none; }}
@media (prefers-color-scheme: dark) {{
:root:not([data-theme="light"]) .map-light {{ display:none; }}
:root:not([data-theme="light"]) .map-dark {{ display:block; }}
}}
:root[data-theme="dark"] .map-light {{ display:none; }}
:root[data-theme="dark"] .map-dark {{ display:block; }}
figcaption {{ font-size:.88rem; color:var(--ink2); max-width:68ch; }}
.legend {{ display:flex; align-items:center; gap:12px; flex-wrap:wrap;
font-size:.78rem; color:var(--muted); }}
.bar {{ height:9px; width:210px; border-radius:1px;
background:linear-gradient(90deg,var(--r0),var(--r1),var(--r2),var(--r3),var(--r4)); }}
.dot {{ width:10px; height:10px; border-radius:50%; background:var(--signal);
display:inline-block; vertical-align:-1px; margin-right:6px; }}
.scroll {{ overflow-x:auto; border:1px solid var(--rule); background:var(--surface); }}
table {{ border-collapse:collapse; width:100%; font-size:.83rem; }}
th, td {{ text-align:left; padding:8px 11px; white-space:nowrap;
border-bottom:1px solid var(--hair); }}
thead th {{ position:sticky; top:0; background:var(--surface);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size:.655rem; letter-spacing:.08em; text-transform:uppercase;
color:var(--muted); font-weight:500; border-bottom:1px solid var(--rule); }}
tbody tr:hover {{ background:var(--shade); }}
.nu, .co, .rk {{ font-variant-numeric:tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
.nu {{ text-align:right; }}
.rk {{ color:var(--muted); }}
.st {{ color:var(--gold); letter-spacing:-1px; }}
.co {{ font-size:.76rem; }}
.rg {{ color:var(--ink2); }}
.lk a {{ font-size:.72rem; margin-right:7px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
.tall {{ max-height:600px; overflow-y:auto; }}
.rules {{ display:grid; gap:1px; background:var(--rule); border:1px solid var(--rule);
grid-template-columns:repeat(auto-fit,minmax(238px,1fr)); }}
.rule-item {{ background:var(--surface); padding:16px 18px; display:flex;
flex-direction:column; gap:6px; }}
.rule-item h3 {{ font-size:.95rem; }}
.rule-item span {{ font-size:.85rem; color:var(--ink2); line-height:1.45; }}
.rule-item code {{ font-size:.76rem; color:var(--signal);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
.starbars {{ display:flex; flex-direction:column; gap:7px; max-width:52ch; }}
.sb {{ display:grid; grid-template-columns:70px 1fr 52px; gap:11px; align-items:center;
font-size:.85rem; }}
.sb .g {{ height:11px; background:var(--shade); border-radius:2px; overflow:hidden; }}
.sb .g i {{ display:block; height:100%; background:var(--accent); border-radius:2px; }}
.sb .lab {{ color:var(--gold); letter-spacing:-1px; }}
.sb .v {{ text-align:right; color:var(--ink2); font-variant-numeric:tabular-nums;
font-family:ui-monospace,Menlo,monospace; font-size:.78rem; }}
ul.plain {{ margin:0; padding-left:1.15em; display:flex; flex-direction:column; gap:11px; }}
li::marker {{ color:var(--muted); }}
.lim h3 {{ margin-bottom:3px; }}
footer {{ margin-top:80px; padding-top:22px; border-top:1px solid var(--rule);
color:var(--muted); font-size:.82rem; display:flex; flex-direction:column; gap:8px; }}
code.inline {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size:.86em; background:var(--shade); padding:1px 5px; border-radius:2px; }}
@media (prefers-reduced-motion: reduce) {{ * {{ animation:none !important; transition:none !important; }} }}
</style>
<div class="wrap">
<header class="masthead">
<div class="eyebrow">Análisis geoespacial · malla de 100 m · península y Baleares</div>
<h1>Dónde no<br>hay nadie</h1>
<p class="standfirst">Un barrido de los 498.528 km² de la España peninsular y balear,
celda a celda, buscando sitios lejos de todo a los que además se pueda llegar con
los límites oficiales de espacios protegidos, no los aproximados.</p>
<div class="byline">
<span>12.506.345 edificios</span><span>1.409.826 km de viales</span>
<span>4.139 espacios protegidos</span><span>{n} localizaciones valoradas</span>
</div>
</header>
<section>
<div class="note">
<p><strong>Corrección sobre la primera versión.</strong> Los espacios protegidos se
tomaron primero de OpenStreetMap, que resultó cubrir solo el 15,3 % de España. Los
datos oficiales Red Natura 2000 y espacios de designación nacional, descargados de
los servicios de la Agencia Europea de Medio Ambiente cubren el <strong>29,0 %</strong>.
Eran <strong>87.480 km² protegidos que no se estaban viendo</strong>.</p>
<p>Con el límite bueno, <strong>33 de los 60 candidatos de la primera versión estaban
dentro de espacio protegido</strong>, incluidos los seis primeros. Ese ranking queda
anulado y sustituido por el de aquí abajo.</p>
</div>
</section>
<section>
<div class="sec-head">
<div class="eyebrow">Lo que sale</div>
<h2>Cinco cifras</h2>
</div>
<div class="stats">
<div class="stat"><b>{prot_pct} %</b><span>De España está protegido: {prot_km2} km²
entre Red Natura 2000 y figuras nacionales.</span></div>
<div class="stat"><b>{eleg_km2} km²</b><span>Pasan las eliminatorias de acceso, terreno
y legalidad: el {eleg_pct} % del país.</span></div>
<div class="stat"><b>{acus_km2} km²</b><span>Y de eso, lo que además baja de {max_spl} dB
en la casa más cercana. El sonido es el cuello de botella real.</span></div>
<div class="stat"><b>{max_build} km</b><span>La mayor distancia a un edificio que se
alcanza en España. Menos de lo que casi nadie supone.</span></div>
<div class="stat"><b>{n}</b><span>Localizaciones finales, valoradas de 1 a 5 estrellas
y separadas {min_sep} km entre .</span></div>
</div>
</section>
<section>
<div class="sec-head">
<div class="eyebrow">El mapa</div>
<h2>El vacío tiene forma de red</h2>
</div>
<figure>
<div class="map">
<img class="map-light" src="data:image/png;base64,{map_light}"
alt="Mapa de aislamiento de España. Las carreteras dibujan una malla clara sobre
el país; las zonas más aisladas aparecen en azul denso en Sierra Morena, Montes de
Toledo, La Serena, los Monegros, Doñana y el Pirineo.">
<img class="map-dark" src="data:image/png;base64,{map_dark}"
alt="Mapa de aislamiento de España en modo oscuro. Las zonas más aisladas
aparecen claras sobre fondo oscuro.">
</div>
<div class="legend">
<span>poblado</span><span class="bar"></span><span>vacío</span>
<span style="margin-left:14px"><span class="dot"></span>candidatos de 3 o más</span>
</div>
<figcaption>Cada píxel es la media geométrica de tres distancias: al edificio más
cercano, a la carretera más cercana y al núcleo de población más cercano. La red de
carreteras se dibuja sola en negativo. Lo que queda entre las mallas es lo que
buscamos. Galicia y la costa mediterránea, pese a la fama de una y el vacío aparente
de la otra, casi no tienen huecos: el poblamiento disperso llega a todas partes.</figcaption>
</figure>
</section>
<section>
<div class="sec-head">
<div class="eyebrow">Método</div>
<h2>Seis criterios y una eliminatoria</h2>
</div>
<div class="col body-flow">
<p>Primero se descarta. Una celda queda fuera, por buena que sea, si está dentro de un
espacio protegido oficial o a menos de {min_prot} m de su borde, en zona militar, en
agua, a menos de {min_build} m de un edificio, a menos de {min_road} m de una carretera,
a más de {max_acc} m de un vial por el que meter un coche, o con más de {max_slope}° de
pendiente. Sobreviven {eleg_km2} km², el {eleg_pct} % del país.</p>
<p>Encima de eso hay un umbral acústico: si el nivel estimado en la casa más cercana
supera los {max_spl} dB el límite nocturno típico en suelo rural el punto se descarta
aunque puntúe bien en todo lo demás. Ese filtro es el que de verdad corta: deja
{acus_km2} km² en toda España. El sonido, y no la soledad, es el cuello de botella.</p>
<p>Lo que sobrevive se puntúa en seis criterios independientes, y las estrellas salen
de la media ponderada penalizada por el eslabón más débil: un sitio perfecto al que no
se puede llegar no es un sitio de cuatro estrellas.</p>
</div>
<div class="rules">
<div class="rule-item"><h3>Sonido <code>30 %</code></h3>
<span>No es una nota abstracta: es propagación real. Divergencia esférica, absorción
atmosférica en frecuencias bajas, apantallamiento del relieve y absorción del
arbolado, partiendo de 130 dB a 1 m. El resultado es cuántos dB llegan a la casa más
cercana. El mejor punto del país se queda en {mejor_db} dB.</span></div>
<div class="rule-item"><h3>Soledad <code>22 %</code></h3>
<span>Lejanía de edificios, de carreteras y de núcleos, y cuánta construcción hay en
5 km a la redonda.</span></div>
<div class="rule-item"><h3>Acceso <code>18 %</code></h3>
<span>Pista en condiciones cerca. OSM distingue firme y grado, así que una pista
compactada no cuenta igual que una grade5 embarrada. Más llano y sin pedregal.</span></div>
<div class="rule-item"><h3>Agua <code>12 %</code></h3>
<span>Río, arroyo o fuente entre 120 y 900 m. Cerca, pero no encima.</span></div>
<div class="rule-item"><h3>Arbolado <code>10 %</code></h3>
<span>Entre el 20 y el 65 % de cobertura alrededor: sombra y pantalla visual sin que
sea selva por la que no se pasa.</span></div>
<div class="rule-item"><h3>Clima <code>8 %</code></h3>
<span>Máxima del mes más cálido y mínima del más frío, de WorldClim, corregidas por
altitud con el DEM de 100 m.</span></div>
</div>
<div class="col body-flow">
<p>Los candidatos no se eligen cogiendo los mejores del país, porque saldrían todos del
mismo rincón. Se divide España en bloques de 20 km y se coge el mejor punto elegible de
cada bloque; después se exige un mínimo de {min_sep} km entre los elegidos, porque dos
bloques vecinos pueden escoger cada uno una celda pegada a su frontera común y quedar a
cien metros. Salen {n} opciones repartidas por todas partes y con un abanico natural de
estrellas.</p>
</div>
<div class="starbars">
<div class="sb"><span class="lab"></span><span class="g"><i style="width:{d5w}%"></i></span><span class="v">{d5}</span></div>
<div class="sb"><span class="lab"></span><span class="g"><i style="width:{d4w}%"></i></span><span class="v">{d4}</span></div>
<div class="sb"><span class="lab"></span><span class="g"><i style="width:{d3w}%"></i></span><span class="v">{d3}</span></div>
<div class="sb"><span class="lab"></span><span class="g"><i style="width:{d2w}%"></i></span><span class="v">{d2}</span></div>
<div class="sb"><span class="lab"></span><span class="g"><i style="width:{d1w}%"></i></span><span class="v">{d1}</span></div>
</div>
</section>
<section>
<div class="sec-head">
<div class="eyebrow">Resultados</div>
<h2>Las {ntop} mejores</h2>
</div>
<div class="col body-flow">
<p>Todas las de cuatro y cinco estrellas. <b>dB</b> es el nivel estimado en la casa más
cercana; por debajo de 35 es ruido de fondo del campo de noche. <b>casa</b> y
<b>agua</b> en kilómetros, <b>pista</b> en metros, <b>árbol</b> en porcentaje de
cobertura, <b>prot</b> la distancia al espacio protegido más cercano.</p>
</div>
<div class="scroll tall">
<table>
<thead><tr>
<th>#</th><th>valor</th><th>coordenadas</th><th>municipio</th><th>comunidad</th>
<th>dB</th><th>casa km</th><th>pista m</th><th>agua km</th><th>árbol %</th>
<th>prot km</th><th>Madrid km</th><th>ver</th>
</tr></thead>
<tbody>
{rows}
</tbody>
</table>
</div>
</section>
<section class="lim">
<div class="sec-head">
<div class="eyebrow">Límites</div>
<h2>Lo que este análisis no sabe</h2>
</div>
<div class="col">
<ul class="plain">
<li><h3>No ve vallas ni propiedad</h3>Casi todo el suroeste que domina la lista es
dehesa privada, en fincas cerradas y cotos de caza. El modelo ve una pista que llega;
no ve la cadena y el candado a la entrada. Sigue siendo la mayor diferencia entre el
ranking y la realidad de campo.</li>
<li><h3>Los protegidos son de diciembre de 2024</h3>Son los límites oficiales, pero
las figuras autonómicas cambian y hay ordenanzas municipales que no están en ningún
mapa nacional. El margen de 300 m ayuda, no exime de comprobarlo.</li>
<li><h3>El modelo acústico es de manual</h3>Divergencia, absorción y apantallamiento
son buenas aproximaciones, pero una inversión térmica nocturna o el viento a favor
pueden llevar el bajo mucho más lejos de lo que dice el número.</li>
<li><h3>Ignora la estación</h3>Buena parte de estos sitios son monte mediterráneo en
riesgo extremo de incendio de junio a septiembre, con restricciones de acceso y
responsabilidad penal si algo prende. El mismo punto no es el mismo sitio en marzo
que en agosto.</li>
<li><h3>Distancia en línea recta, no tiempo de coche</h3>La columna de Madrid es
euclídea. Por carretera y luego pista, el tiempo real puede ser el doble.</li>
</ul>
</div>
</section>
<footer>
<p>Datos: OpenStreetMap (ODbL) para edificios, viales, agua, arbolado y roca;
Red Natura 2000 y espacios de designación nacional vía Agencia Europea de Medio Ambiente;
Copernicus DEM GLO-90 para el relieve; WorldClim 2.1 para el clima.
Malla EPSG:3035 a 100 m. Canarias, Ceuta y Melilla quedan fuera.</p>
<p>Visor local en <code class="inline">out/visor.html</code>, tabla completa en
<code class="inline">out/candidatos_estrellas.csv</code>, pipeline en
<code class="inline">src/</code>.</p>
</footer>
</div>
"""
if __name__ == "__main__":
# anchura de las barras del reparto de estrellas
import re
_orig = TEMPLATE
rows = list(csv.DictReader(open(C.OUT / "candidatos_estrellas.csv")))
dist = {s: sum(1 for r in rows if int(r["estrellas"]) == s) for s in range(1, 6)}
mx = max(dist.values())
for s in range(1, 6):
TEMPLATE = TEMPLATE.replace("{d%dw}" % s, f"{dist[s]/mx*100:.1f}")
main()

170
src/revisar.py Normal file
View file

@ -0,0 +1,170 @@
"""Revisión de coherencia de todo el resultado.
Comprueba invariantes sobre los datos finales, no sobre el código: que ningún
candidato incumpla una eliminatoria, que las cifras del CSV coincidan con los
rásters de los que salieron, y que el visor y el informe cuenten lo mismo.
"""
import csv
import json
import re
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
import stars as S
FAIL = []
WARN = []
def check(cond, msg, warn=False):
if cond:
print(f" OK {msg}")
else:
(WARN if warn else FAIL).append(msg)
print(f" {'AVISO' if warn else 'FALLO'} {msg}")
def main():
rows = list(csv.DictReader(open(C.OUT / "candidatos_estrellas.csv")))
n = len(rows)
print(f"=== {n} candidatos ===\n")
lat = np.array([float(r["lat"]) for r in rows])
lon = np.array([float(r["lon"]) for r in rows])
rr = np.array([int(r["row"]) for r in rows])
cc = np.array([int(r["col"]) for r in rows])
print("-- eliminatorias --")
spain = np.load(C.INTERIM / "mask_spain.npy").astype(bool)
prot = np.load(C.INTERIM / "mask_protected_official.npy").astype(bool)
mil = np.load(C.INTERIM / "mask_military.npy").astype(bool)
wat = np.load(C.INTERIM / "mask_water.npy").astype(bool)
check(spain[rr, cc].all(), "todos dentro de España")
check(not prot[rr, cc].any(), "ninguno dentro de espacio protegido oficial")
check(not mil[rr, cc].any(), "ninguno en zona militar")
check(not wat[rr, cc].any(), "ninguno sobre lámina de agua")
d_prot = np.load(C.INTERIM / "d_protected_official.npy")
check(d_prot[rr, cc].min() >= S.MIN_D_PROT,
f"margen al protegido >= {S.MIN_D_PROT} m (mín real "
f"{d_prot[rr,cc].min():.0f} m)")
d_build = np.load(C.INTERIM / "d_build.npy")
check(d_build[rr, cc].min() >= S.MIN_D_BUILD,
f"distancia a edificio >= {S.MIN_D_BUILD} m (mín real "
f"{d_build[rr,cc].min():.0f} m)")
d_major = np.load(C.INTERIM / "d_major.npy")
d_minor = np.load(C.INTERIM / "d_minor.npy")
d_road = np.minimum(d_major, d_minor)
check(d_road[rr, cc].min() >= S.MIN_D_ROAD,
f"distancia a carretera >= {S.MIN_D_ROAD} m (mín real "
f"{d_road[rr,cc].min():.0f} m)")
d_tg = np.load(C.INTERIM / "d_track_good.npy")
d_tb = np.load(C.INTERIM / "d_track_bad.npy")
d_acc = np.minimum(np.minimum(d_major, d_minor), np.minimum(d_tg, d_tb))
check(d_acc[rr, cc].max() <= S.MAX_D_ACCESS,
f"acceso <= {S.MAX_D_ACCESS} m (máx real {d_acc[rr,cc].max():.0f} m)")
slope = np.load(C.INTERIM / "slope_deg.npy")
check(slope[rr, cc].max() <= S.MAX_SLOPE,
f"pendiente <= {S.MAX_SLOPE}° (máx real {slope[rr,cc].max():.1f}°)")
print("\n-- coherencia CSV vs rásters --")
csv_db = np.array([float(r["d_edificio"]) for r in rows])
check(np.abs(csv_db - d_build[rr, cc]).max() < 1.5,
"columna d_edificio coincide con el ráster")
csv_prot = np.array([float(r["d_protegido"]) for r in rows])
check(np.abs(csv_prot - d_prot[rr, cc]).max() < 1.5,
"columna d_protegido coincide con el ráster")
# el nivel sonoro debe reproducirse con la fórmula declarada
tpi = np.load(C.INTERIM / "tpi_2km.npy")
ff = np.load(C.INTERIM / "frac_forest.npy")
d = np.maximum(d_build[rr, cc], 1.0)
spl = (S.L0 - 20 * np.log10(d) - S.ALPHA_KM * d / 1000.0
- ff[rr, cc] * S.MAX_FOREST_ATT
- np.clip(-tpi[rr, cc] / 4.0, 0, S.MAX_TERRAIN_ATT))
csv_spl = np.array([float(r["db_en_casa"]) for r in rows])
check(np.abs(csv_spl - spl).max() < 0.15,
"columna db_en_casa reproduce el modelo acústico")
print("\n-- coordenadas y etiquetas --")
check(np.isfinite(lat).all() and np.isfinite(lon).all(), "sin coordenadas NaN")
check((lat > 35.9).all() and (lat < 44).all() and
(lon > -9.6).all() and (lon < 4.4).all(), "coordenadas dentro de la ventana")
sin_mun = sum(1 for r in rows if not r["municipio"])
check(sin_mun == 0, f"todos con municipio ({sin_mun} sin asignar)",
warn=sin_mun < n * 0.02)
sin_com = sum(1 for r in rows if not r["comunidad"])
check(sin_com == 0, f"todos con comunidad ({sin_com} sin asignar)",
warn=sin_com < n * 0.02)
print("\n-- valores fuera de rango --")
for col, lo, hi in (("c_sonido", 0, 100), ("c_soledad", 0, 100),
("c_agua", 0, 100), ("c_arbolado", 0, 100),
("c_acceso", 0, 100), ("c_clima", 0, 100),
("estrellas", 1, 5), ("score", 0, 100),
("arbolado_pct", 0, 100), ("roca_pct", 0, 100)):
v = np.array([float(r[col]) for r in rows])
check(bool((v >= lo).all() and (v <= hi).all()),
f"{col} dentro de [{lo},{hi}] (real {v.min():.0f}..{v.max():.0f})")
print("\n-- separación entre candidatos --")
x, y = C.to_grid(lon, lat)
from scipy.spatial import cKDTree
t = cKDTree(np.c_[x, y])
dd, _ = t.query(np.c_[x, y], k=2)
check(dd[:, 1].min() > 1000,
f"sin duplicados pegados (separación mínima {dd[:,1].min()/1000:.1f} km)")
print("\n-- estrellas --")
st = np.array([int(r["estrellas"]) for r in rows])
sc = np.array([float(r["score"]) for r in rows])
ok_mono = all(sc[st == a].min() >= sc[st == b].max() - 1e-6
for a, b in ((5, 4), (4, 3), (3, 2), (2, 1))
if (st == a).any() and (st == b).any())
check(ok_mono, "las estrellas son monótonas respecto al score")
for s in (5, 4, 3, 2, 1):
print(f" {''*s:<5} {(st==s).sum():>5}")
print("\n-- ficheros de salida --")
v = (C.OUT / "visor.html")
h = v.read_text(encoding="utf-8")
m = re.search(r'const DATA = (\{.*?\});\nconst NT', h, re.S)
D = json.loads(m.group(1))
check(len(D["rows"]) == n, f"el visor lleva los mismos {n} candidatos")
check("Leaflet 1.9.4" in h, "Leaflet incrustado (sin CDN)")
check(not re.search(r'<script[^>]+src=|<link[^>]+href="http', h),
"el visor no carga recursos externos")
ter = re.search(r'atob\("([A-Za-z0-9+/=]+)"\)', h).group(1)
import base64
check(len(base64.b64decode(ter)) == n * 48 * 48, "recortes de relieve completos")
inf = (C.OUT / "informe.html").read_text(encoding="utf-8")
check(f"{n}" in inf.replace(".", ""), "el informe cita el mismo total",
warn=True)
for f in ("visor.html", "informe.html", "candidatos_estrellas.csv",
"score_rave.tif", "relieve.png"):
check((C.OUT / f).exists(), f"existe out/{f}")
print("\n" + "=" * 58)
if FAIL:
print(f"{len(FAIL)} FALLOS:")
for m_ in FAIL:
print(" -", m_)
else:
print("Sin fallos.")
if WARN:
print(f"{len(WARN)} avisos:")
for m_ in WARN:
print(" -", m_)
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())

201
src/score.py Normal file
View file

@ -0,0 +1,201 @@
"""Combina las capas en dos rankings distintos y extrae candidatos.
1) AISLAMIENTO PURO: ¿qué punto de España está más lejos de cualquier rastro de
civilización? Es la pregunta interesante, pero su respuesta suele ser una
cara norte a 2.400 m sin forma de llegar.
2) APTITUD PARA RAVE: aislamiento acotado por lo que la fiesta necesita de
verdad. Acceso rodado, suelo llano, fuera de espacio protegido y militar, y
a ser posible en una hondonada, que encierra el sonido y tapa las luces.
Las dos restricciones tiran en sentidos opuestos: cuanto más aislado, peor
acceso. El sitio bueno no es el máximo de ninguna de las dos, sino el máximo de
una con la otra acotada, que es justo lo que hace el modelo.
"""
import pickle
import sys
import numpy as np
import rasterio
from shapely.geometry import Point
from shapely.strtree import STRtree
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
# --- parámetros del modelo (todo en metros) ---
MIN_D_BUILD = 1500 # umbral duro: por debajo, te oyen desde casa
MAX_D_ACCESS = 600 # más lejos de un vial, no metes el equipo
MIN_D_PROT = 500 # margen al borde del espacio protegido (zona periférica)
MAX_SLOPE = 8.0 # grados; por encima no se monta ni se aparca
SAT_BUILD = 5000 # a partir de aquí el aislamiento ya no suma más
SAT_PLACE = 8000
SAT_ROAD = 5000
N_CAND = 60
MIN_SEP = 15000 # separación mínima entre candidatos, para no devolver
# 60 celdas del mismo valle
MADRID = C.to_grid(-3.7038, 40.4168)
def load(name):
return np.load(C.INTERIM / f"{name}.npy")
def norm(a, sat):
return np.clip(a / sat, 0, 1)
def main():
print("cargando capas…", flush=True)
d_build = load("d_build")
d_major = load("d_major")
d_minor = load("d_minor")
d_track = load("d_track")
d_place = load("d_place_any")
dens = load("dens_build_5km")
slope = load("slope_deg")
tpi = load("tpi_2km")
spain = load("mask_spain").astype(bool)
protected = load("mask_protected").astype(bool)
military = load("mask_military").astype(bool)
water = load("mask_water").astype(bool)
d_access = np.minimum(np.minimum(d_major, d_minor), d_track)
# ---------- 1) aislamiento puro ----------
# Media geométrica de las tres distancias que definen "lejos de todo".
# Geométrica y no aritmética: así un sitio a 8 km de una casa pero a 200 m
# de una nacional no puntúa; hay que estar lejos de las tres cosas a la vez.
iso = np.cbrt(np.maximum(d_build, 1) *
np.maximum(np.minimum(d_major, d_minor), 1) *
np.maximum(d_place, 1)).astype(np.float32)
iso[~spain] = np.nan
np.save(C.INTERIM / "iso_pure.npy", iso)
valid_iso = spain & np.isfinite(iso)
print(f"\n=== AISLAMIENTO PURO ===")
print(f" celdas válidas: {valid_iso.sum():,}")
for q in (50, 90, 99, 99.9):
print(f" p{q:<5} {np.nanpercentile(iso[valid_iso], q):7.0f} m")
print(f" máximo {np.nanmax(iso):7.0f} m")
print(f" distancia máxima a un edificio en España: "
f"{np.nanmax(np.where(spain, d_build, np.nan))/1000:.1f} km")
print(f" distancia máxima a una carretera: "
f"{np.nanmax(np.where(spain, np.minimum(d_major, d_minor), np.nan))/1000:.1f} km")
# ---------- 2) aptitud para rave ----------
d_prot = load("d_protected")
d_mil = load("d_military")
ok = (spain & ~protected & ~military & ~water &
(d_build >= MIN_D_BUILD) &
(d_access <= MAX_D_ACCESS) &
(slope <= MAX_SLOPE) &
(d_prot >= MIN_D_PROT) &
np.isfinite(slope))
print(f"\n=== APTITUD PARA RAVE ===")
print(f" celdas que pasan todas las restricciones: {ok.sum():,} "
f"({ok.sum()/spain.sum()*100:.2f}% del país, "
f"{ok.sum()*0.01:,.0f} km2)")
score = (0.34 * norm(d_build, SAT_BUILD) +
0.16 * norm(d_place, SAT_PLACE) +
0.20 * norm(np.minimum(d_major, d_minor), SAT_ROAD) +
0.12 * (1 - np.clip(dens / 200.0, 0, 1)) +
0.10 * np.clip(-tpi / 30.0, 0, 1) + # hondonada
0.08 * (1 - np.clip(slope / MAX_SLOPE, 0, 1)))
score = score.astype(np.float32)
score[~ok] = np.nan
np.save(C.INTERIM / "score_rave.npy", score)
with rasterio.open(
C.OUT / "score_rave.tif", "w", driver="GTiff",
height=C.HEIGHT, width=C.WIDTH, count=1, dtype="float32",
crs=C.CRS_GRID, transform=C.TRANSFORM, nodata=np.nan,
compress="deflate", tiled=True) as dst:
dst.write(score, 1)
print(f" ráster guardado en out/score_rave.tif")
# ---------- extracción de candidatos ----------
print(f"\nextrayendo {N_CAND} candidatos con {MIN_SEP/1000:.0f} km de separación…",
flush=True)
with open(C.INTERIM / "area_admin_municipality.pkl", "rb") as fh:
munis = pickle.load(fh)
with open(C.INTERIM / "area_admin_region.pkl", "rb") as fh:
regs = [r for r in pickle.load(fh) if r["geom"].area > 1e6]
muni_tree = STRtree([m["geom"] for m in munis])
reg_tree = STRtree([r["geom"] for r in regs])
work = np.where(np.isfinite(score), score, -1.0).astype(np.float32)
rad = int(MIN_SEP / C.RES)
rows = []
for i in range(N_CAND):
idx = int(np.argmax(work))
r, c = divmod(idx, C.WIDTH)
if work[r, c] <= 0:
break
x = C.X_MIN + (c + 0.5) * C.RES
y = C.Y_MAX - (r + 0.5) * C.RES
lon, lat = C.to_wgs(x, y)
pt = Point(x, y)
def lookup(tree, recs):
for j in tree.query(pt):
if recs[j]["geom"].contains(pt):
return recs[j]["name"]
return ""
rows.append({
"rank": i + 1,
"lat": round(float(lat), 5),
"lon": round(float(lon), 5),
"municipio": lookup(muni_tree, munis),
"comunidad": lookup(reg_tree, regs),
"score": round(float(score[r, c]), 4),
"d_edificio_m": int(d_build[r, c]),
"d_carretera_m": int(min(d_major[r, c], d_minor[r, c])),
"d_acceso_m": int(d_access[r, c]),
"d_nucleo_m": int(d_place[r, c]),
"d_protegido_m": int(d_prot[r, c]),
"d_militar_m": int(d_mil[r, c]),
"cota_m": int(np.nan_to_num(load_dem_at(r, c))),
"pendiente_deg": round(float(slope[r, c]), 1),
"tpi_m": round(float(tpi[r, c]), 1),
"edif_5km": int(dens[r, c]),
"km_madrid": round(float(np.hypot(x - MADRID[0], y - MADRID[1]) / 1000), 1),
})
# suprimimos un disco alrededor para el siguiente candidato
r0, r1 = max(0, r - rad), min(C.HEIGHT, r + rad + 1)
c0, c1 = max(0, c - rad), min(C.WIDTH, c + rad + 1)
yy, xx = np.ogrid[r0:r1, c0:c1]
work[r0:r1, c0:c1][((yy - r) ** 2 + (xx - c) ** 2) <= rad * rad] = -1.0
import csv
cols = list(rows[0].keys())
with open(C.OUT / "candidatos.csv", "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=cols)
w.writeheader()
w.writerows(rows)
print(f" {len(rows)} candidatos -> out/candidatos.csv")
for r_ in rows[:15]:
print(f" #{r_['rank']:<3} {r_['lat']:8.4f},{r_['lon']:9.4f} "
f"{r_['municipio'][:26]:26s} {r_['comunidad'][:18]:18s} "
f"casa={r_['d_edificio_m']:>6}m ctra={r_['d_carretera_m']:>5}m "
f"acc={r_['d_acceso_m']:>4}m")
_dem = None
def load_dem_at(r, c):
global _dem
if _dem is None:
_dem = np.load(C.INTERIM / "dem.npy", mmap_mode="r")
v = _dem[r, c]
return 0.0 if not np.isfinite(v) else float(v)
if __name__ == "__main__":
main()

307
src/stars.py Normal file
View file

@ -0,0 +1,307 @@
"""Modelo multicriterio y generación de candidatos con valoración 1-5 estrellas.
Seis criterios independientes, cada uno 0-100:
sonido nivel estimado que llega a la casa más cercana, en dB. No es una
puntuación abstracta: es propagación real (divergencia esférica +
absorción atmosférica + apantallamiento del relieve + absorción del
arbolado) partiendo de un equipo de 130 dB a 1 m. Es el criterio que
de verdad decide si te dan el aviso.
soledad lejanía de edificios, núcleos y densidad de construcción alrededor.
agua río, arroyo o fuente cerca, pero no encima.
arbolado cobertura arbórea alrededor: sombra y pantalla visual sin que sea
selva impenetrable.
acceso pista en condiciones cerca, llano y sin pedregal.
clima máxima del mes más cálido y mínima del más frío.
Las estrellas salen de la media ponderada penalizada por el eslabón más débil:
un sitio perfecto salvo que no se puede llegar no es un sitio de cuatro
estrellas. Los criterios legales no puntúan, eliminan.
Los candidatos se eligen por bloques de 20 km cogiendo el mejor punto elegible
de cada bloque. Así salen cientos de opciones repartidas por todo el país y con
un abanico natural de estrellas, en vez de sesenta variantes del mismo barranco.
"""
import csv
import pickle
import sys
import numpy as np
from shapely.geometry import Point
from shapely.strtree import STRtree
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
# --- eliminatorias ---
MIN_D_BUILD = 2000
MIN_D_ROAD = 400 # a menos de esto te ve cada coche que pasa
MAX_D_ACCESS = 800
MAX_SLOPE = 10.0
MIN_D_PROT = 300 # margen al borde del espacio protegido oficial
MAX_SPL = 45.0 # dB estimados en la casa más cercana. 45 es el límite
# nocturno típico en suelo rural: por encima, el sitio
# no es viable por mucho que puntúe en lo demás.
MIN_SEP = 10000 # separación mínima entre candidatos
# --- modelo acústico ---
L0 = 130.0 # dB a 1 m de un equipo grande
ALPHA_KM = 0.5 # dB/km de absorción atmosférica en frecuencias bajas
MAX_FOREST_ATT = 6.0
MAX_TERRAIN_ATT = 12.0
BLOCK = 200 # celdas = 20 km
MADRID = C.to_grid(-3.7038, 40.4168)
def load(n):
return np.load(C.INTERIM / f"{n}.npy")
def ramp(x, lo, hi):
"""0 en lo, 100 en hi (o al revés si hi<lo)."""
return np.clip((x - lo) / (hi - lo), 0, 1) * 100
def band(x, bad_lo, good_lo, good_hi, bad_hi):
"""100 dentro de [good_lo, good_hi], cayendo a 0 en bad_lo / bad_hi."""
up = np.clip((x - bad_lo) / max(good_lo - bad_lo, 1e-6), 0, 1)
dn = np.clip((bad_hi - x) / max(bad_hi - good_hi, 1e-6), 0, 1)
return np.minimum(up, dn) * 100
def main():
print("cargando…", flush=True)
d_build = load("d_build")
d_major = load("d_major")
d_minor = load("d_minor")
d_place = load("d_place_any")
dens = load("dens_build_5km")
slope = load("slope_deg")
tpi = load("tpi_2km")
d_water = load("d_water")
d_spring = load("d_spring")
d_tg = load("d_track_good")
d_tb = load("d_track_bad")
frac_forest = load("frac_forest")
frac_rock = load("frac_rock")
tmax = load("tmax")
tmin = load("tmin")
dem = load("dem")
spain = load("mask_spain").astype(bool)
prot = load("mask_protected_official").astype(bool)
d_prot = load("d_protected_official")
military = load("mask_military").astype(bool)
water_a = load("mask_water").astype(bool)
d_access = np.minimum(np.minimum(d_major, d_minor), np.minimum(d_tg, d_tb))
# ---------- eliminatorias ----------
d_road = np.minimum(d_major, d_minor)
ok = (spain & ~prot & ~military & ~water_a &
(d_build >= MIN_D_BUILD) &
(d_road >= MIN_D_ROAD) &
(d_access <= MAX_D_ACCESS) &
(slope <= MAX_SLOPE) &
(d_prot >= MIN_D_PROT) &
np.isfinite(slope) & np.isfinite(tmax))
# El umbral acústico se aplica después de calcular el nivel, más abajo.
print(f"celdas elegibles: {ok.sum():,} ({ok.sum()*0.01:,.0f} km2, "
f"{ok.sum()/spain.sum()*100:.1f}% del país)", flush=True)
# ---------- criterio sonido ----------
d = np.maximum(d_build, 1.0)
att_forest = frac_forest * MAX_FOREST_ATT
att_terrain = np.clip(-tpi / 4.0, 0, MAX_TERRAIN_ATT)
spl = (L0 - 20.0 * np.log10(d) - ALPHA_KM * d / 1000.0
- att_forest - att_terrain).astype(np.float32)
# 45 dB es el límite nocturno típico en suelo rural; 30 dB ya es ruido de
# fondo del campo de noche, o sea inaudible en la práctica.
c_sonido = ramp(-spl, -48, -30)
ok_before_spl = int(ok.sum())
ok &= (spl <= MAX_SPL)
print(f" tras el umbral acústico de {MAX_SPL:.0f} dB: {ok.sum():,} celdas "
f"({ok.sum()*0.01:,.0f} km2)", flush=True)
# ---------- resto de criterios ----------
# La distancia a carretera pesa aquí de forma explícita: sin ella el modelo
# colocaba puntos de cinco estrellas sobre el propio asfalto, porque miraba
# solo lo lejos que estaba la casa más cercana. Da igual que no te oigan si
# te ve cada coche que pasa.
c_soledad = (0.40 * ramp(d_build, 1200, 6000) +
0.25 * ramp(d_road, MIN_D_ROAD, 4000) +
0.20 * ramp(d_place, 1500, 9000) +
0.15 * (100 - np.clip(dens / 150.0, 0, 1) * 100))
d_any_water = np.minimum(d_water, d_spring)
c_agua = band(d_any_water, 0, 120, 900, 4000)
c_arbolado = band(frac_forest * 100, 0, 20, 65, 100)
c_acceso = (0.55 * (100 - ramp(d_tg, 0, MAX_D_ACCESS)) +
0.25 * (100 - ramp(slope, 1.5, MAX_SLOPE)) +
0.20 * (100 - np.clip(frac_rock * 400, 0, 100)))
c_clima = (0.6 * (100 - ramp(tmax, 30, 40)) +
0.4 * (100 - ramp(-tmin, 1, 8)))
crit = {"sonido": c_sonido, "soledad": c_soledad, "agua": c_agua,
"arbolado": c_arbolado, "acceso": c_acceso, "clima": c_clima}
W = {"sonido": .30, "soledad": .22, "acceso": .18,
"agua": .12, "arbolado": .10, "clima": .08}
total = sum(W[k] * crit[k] for k in W).astype(np.float32)
# El eslabón más débil: sin esto salen sitios de 5 estrellas a los que no se
# puede llegar o donde te oyen desde el pueblo.
weakest = np.minimum.reduce([crit["sonido"], crit["acceso"], crit["soledad"]])
total = (0.75 * total + 0.25 * weakest).astype(np.float32)
total[~ok] = -1
# ---------- muestreo por bloques ----------
print(f"muestreando el mejor punto de cada bloque de {BLOCK*C.RES/1000:.0f} km…",
flush=True)
H = (C.HEIGHT // BLOCK) * BLOCK
Wd = (C.WIDTH // BLOCK) * BLOCK
view = total[:H, :Wd].reshape(H // BLOCK, BLOCK, Wd // BLOCK, BLOCK)
view = view.transpose(0, 2, 1, 3).reshape(-1, BLOCK * BLOCK)
best = view.argmax(axis=1)
bestval = view.max(axis=1)
keep = np.flatnonzero(bestval > 0)
nb_c = Wd // BLOCK
rows_out = []
for bi in keep:
br, bc = divmod(int(bi), nb_c)
lr, lc = divmod(int(best[bi]), BLOCK)
r, c = br * BLOCK + lr, bc * BLOCK + lc
rows_out.append((r, c, float(bestval[bi])))
rows_out.sort(key=lambda t: -t[2])
print(f" {len(rows_out)} tras el muestreo por bloques", flush=True)
# Un punto por bloque no garantiza separación: dos bloques vecinos pueden
# elegir cada uno una celda pegada a su frontera común y quedar a 100 m.
# Filtro voraz por score: nos quedamos con el mejor y descartamos todo lo
# que caiga a menos de MIN_SEP.
from scipy.spatial import cKDTree
xs = np.array([C.X_MIN + (c + 0.5) * C.RES for _, c, _ in rows_out])
ys = np.array([C.Y_MAX - (r + 0.5) * C.RES for r, _, _ in rows_out])
tree = cKDTree(np.c_[xs, ys])
dead = np.zeros(len(rows_out), dtype=bool)
kept = []
for i in range(len(rows_out)): # ya vienen ordenados por score
if dead[i]:
continue
kept.append(i)
for j in tree.query_ball_point([xs[i], ys[i]], MIN_SEP):
if j != i:
dead[j] = True
rows_out = [rows_out[i] for i in kept]
print(f" {len(rows_out)} tras exigir {MIN_SEP/1000:.0f} km de separación",
flush=True)
# ---------- etiquetado ----------
with open(C.INTERIM / "area_admin_municipality.pkl", "rb") as fh:
munis = pickle.load(fh)
with open(C.INTERIM / "area_admin_region.pkl", "rb") as fh:
regs = [x for x in pickle.load(fh) if x["geom"].area > 1e6]
with open(C.INTERIM / "prot_official.pkl", "rb") as fh:
prots = pickle.load(fh)
t_mu, t_rg = STRtree([m["geom"] for m in munis]), STRtree([x["geom"] for x in regs])
t_pr = STRtree([p["geom"] for p in prots])
def stars_of(v):
return 5 if v >= 72 else 4 if v >= 62 else 3 if v >= 52 else 2 if v >= 42 else 1
out = []
for i, (r, c, val) in enumerate(rows_out, 1):
x = C.X_MIN + (c + 0.5) * C.RES
y = C.Y_MAX - (r + 0.5) * C.RES
lon, lat = C.to_wgs(x, y)
p = Point(x, y)
def look(tree, recs):
for j in tree.query(p):
if recs[j]["geom"].contains(p):
return recs[j]["name"]
# Los límites municipales de OSM dejan alguna costura sin cubrir;
# en ese caso nos quedamos con el municipio más cercano.
near, nd_ = "", 1e9
for j in tree.query(p.buffer(4000)):
dd_ = recs[j]["geom"].distance(p)
if dd_ < nd_:
nd_, near = dd_, recs[j]["name"]
return near
near, nd = "", 1e9
for j in t_pr.query(p.buffer(12000)):
dd = prots[j]["geom"].distance(p)
if dd < nd:
nd, near = dd, f'{prots[j]["name"]} ({prots[j]["kind"]})'
out.append({
"id": i, "lat": round(float(lat), 5), "lon": round(float(lon), 5),
"estrellas": stars_of(val), "score": round(val, 1),
"municipio": look(t_mu, munis), "comunidad": look(t_rg, regs),
"c_sonido": round(float(c_sonido[r, c])),
"c_soledad": round(float(c_soledad[r, c])),
"c_agua": round(float(c_agua[r, c])),
"c_arbolado": round(float(c_arbolado[r, c])),
"c_acceso": round(float(c_acceso[r, c])),
"c_clima": round(float(c_clima[r, c])),
"db_en_casa": round(float(spl[r, c]), 1),
"d_edificio": int(d_build[r, c]),
"d_carretera": int(min(d_major[r, c], d_minor[r, c])),
"d_pista": int(d_tg[r, c]),
"d_agua": int(d_any_water[r, c]),
"d_protegido": int(d_prot[r, c]),
"protegido_cerca": near if nd < 12000 else "",
"km_protegido": round(nd / 1000, 1) if nd < 1e8 else None,
"arbolado_pct": round(float(frac_forest[r, c]) * 100),
"roca_pct": round(float(frac_rock[r, c]) * 100),
"pendiente": round(float(slope[r, c]), 1),
"tpi": round(float(tpi[r, c]), 1),
"cota": int(dem[r, c]) if np.isfinite(dem[r, c]) else 0,
"tmax": round(float(tmax[r, c]), 1),
"tmin": round(float(tmin[r, c]), 1),
"km_madrid": round(float(np.hypot(x - MADRID[0], y - MADRID[1]) / 1000), 1),
"row": r, "col": c,
})
# Las cifras del informe y del visor se leen de aquí, para que no se queden
# obsoletas al cambiar un umbral del modelo.
import json
stats = {
"n": len(out),
"elegibles_km2": round(float(ok_before_spl * 0.01)),
"elegibles_pct": round(float(ok_before_spl / spain.sum() * 100), 1),
"acustico_km2": round(float(ok.sum() * 0.01)),
"estrellas": {str(s): sum(1 for o in out if o["estrellas"] == s)
for s in range(1, 6)},
"mejor_db": round(min(o["db_en_casa"] for o in out), 1),
"max_d_build_km": round(float(np.nanmax(np.where(spain, d_build, np.nan))) / 1000, 1),
"prot_pct": round(float((prot & spain).sum() / spain.sum() * 100), 1),
"prot_km2": round(float((prot & spain).sum() * 0.01)),
"min_d_build": MIN_D_BUILD, "min_d_road": MIN_D_ROAD,
"max_spl": MAX_SPL, "min_sep_km": MIN_SEP // 1000,
"max_d_access": MAX_D_ACCESS, "max_slope": MAX_SLOPE,
"min_d_prot": MIN_D_PROT,
}
with open(C.OUT / "stats.json", "w") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=1)
with open(C.OUT / "candidatos_estrellas.csv", "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(out[0].keys()))
w.writeheader()
w.writerows(out)
with open(C.INTERIM / "cands.pkl", "wb") as fh:
pickle.dump(out, fh)
dist = {s: sum(1 for o in out if o["estrellas"] == s) for s in (5, 4, 3, 2, 1)}
print("\nreparto de estrellas:")
for s in (5, 4, 3, 2, 1):
print(f" {''*s:<5} {dist[s]:>4}")
print(f"\ntop 12:")
for o in out[:12]:
print(f" {''*o['estrellas']:<5} {o['score']:5.1f} {o['lat']:8.4f},{o['lon']:9.4f} "
f"{o['municipio'][:22]:22s} {o['db_en_casa']:5.1f}dB "
f"pista={o['d_pista']:>4}m agua={o['d_agua']:>5}m")
if __name__ == "__main__":
main()

74
src/validate_coverage.py Normal file
View file

@ -0,0 +1,74 @@
"""Control de sesgo: ¿está OSM igual de mapeado en toda España?
Importa porque el modelo mide aislamiento como "distancia al edificio más
cercano". Si una comunidad tiene los edificios a medio mapear, sus celdas
saldrán aisladas por un hueco en los datos y no por estar lejos de nada.
El import del Catastro cubre casi toda España pero no Navarra ni Euskadi, que
tienen catastro propio, así que el sesgo es esperable y hay que cuantificarlo.
Como no usamos datos externos de población, el contraste es interno: viales por
km2 (que en España están mapeados de forma homogénea) frente a edificios por
km2. Si la proporción se desploma en una región, es un agujero de datos.
"""
import pickle
import sys
import numpy as np
import rasterio.features
from shapely.geometry import mapping
sys.path.insert(0, str(__file__.rsplit("/", 1)[0]))
import config as C
STEP_M = 60.0 # espaciado de densificación usado en extract_osm
def main():
with open(C.INTERIM / "area_admin_region.pkl", "rb") as fh:
regs = [r for r in pickle.load(fh) if r["geom"].area > 1e6]
regs.sort(key=lambda r: -r["geom"].area)
rid = rasterio.features.rasterize(
[(mapping(r["geom"]), i + 1) for i, r in enumerate(regs)],
out_shape=(C.HEIGHT, C.WIDTH), transform=C.TRANSFORM,
fill=0, dtype=np.uint8, all_touched=False)
def counts_for(base):
a = np.load(C.INTERIM / f"{base}.npy")
r, c = C.xy_to_rowcol(a[0], a[1])
ok = C.inside(r, c)
ids = rid[r[ok], c[ok]]
return np.bincount(ids, minlength=len(regs) + 1)
b = counts_for("buildings")
v = counts_for("line_minor") + counts_for("line_track")
cells = np.bincount(rid.ravel(), minlength=len(regs) + 1)
print(f"{'comunidad':24s} {'km2':>8s} {'edif':>10s} {'edif/km2':>9s} "
f"{'vial km':>9s} {'edif/vial-km':>13s}")
print("-" * 78)
ratios = []
for i, r in enumerate(regs, start=1):
km2 = cells[i] * 0.01
if km2 < 100:
continue
vial_km = v[i] * STEP_M / 1000.0
ratio = b[i] / vial_km if vial_km else 0
ratios.append((ratio, r["name"]))
print(f"{r['name'][:24]:24s} {km2:8,.0f} {b[i]:10,} {b[i]/km2:9.1f} "
f"{vial_km:9,.0f} {ratio:13.2f}")
med = np.median([x for x, _ in ratios])
print("-" * 78)
print(f"mediana edif/vial-km: {med:.2f}")
print("\nregiones con menos de la mitad de la mediana "
"(sospecha de infra-mapeo de edificios):")
flagged = [n for x, n in ratios if x < 0.5 * med]
for n in flagged:
print(f" - {n}")
if not flagged:
print(" ninguna")
if __name__ == "__main__":
main()