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:
commit
d4b3b803e0
394 changed files with 7393 additions and 0 deletions
92
src/build_protected.py
Normal file
92
src/build_protected.py
Normal 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
168
src/build_rasters.py
Normal 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
119
src/build_rasters2.py
Normal 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
829
src/build_viewer.py
Normal 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
95
src/config.py
Normal 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
98
src/dem_terrain.py
Normal 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
28
src/dl_dem.sh
Executable 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
89
src/dl_orthos.py
Normal 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
117
src/dl_protected.py
Normal 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
93
src/extract_areas.py
Normal 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 sí 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
140
src/extract_extra.py
Normal 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
207
src/extract_osm.py
Normal 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
133
src/render_map.py
Normal 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 sí.
|
||||
"""
|
||||
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
113
src/render_relief.py
Normal 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
417
src/report.py
Normal 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 sí.</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
170
src/revisar.py
Normal 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
201
src/score.py
Normal 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
307
src/stars.py
Normal 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
74
src/validate_coverage.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue