Con Ctrl y la rueda del ratón también se acerca y se aleja el diagrama.
Antes de entrenar cualquier modelo hay que reunir los datos de El Valle, revisarlos y ponerlos a "hablar el mismo idioma": el mismo sistema de coordenadas, la misma zona y sin errores. Esta etapa junta seis fuentes muy distintas y las convierte en una sola base de datos geoespacial.
Cada fuente llega en su propio formato: la cobertura 4G en polígonos (archivos shapefile, el formato más común para mapas de puntos, líneas y polígonos), las radiobases en hojas de Excel, el relieve en un ráster GeoTIFF (una imagen cuyos píxeles guardan alturas en lugar de colores), la población en tablas de un PDF, la infraestructura en OpenStreetMap (un mapa libre y colaborativo) y el suelo en archivos de una herramienta de planificación de radio. Tampoco usan las mismas coordenadas: unas vienen en metros (UTM, una forma de "aplanar" la Tierra para medir en metros) y otras en grados de latitud y longitud. Si se cruzaran tal como llegan, una radiobase podría "caer" lejos de su sitio real o una comunidad quedar fuera de la parroquia.
Por eso la etapa sigue una receta fija: primero se hace la lista de todo lo que hay (1.1); luego se pasan a tabla los datos que solo existen en PDF o en formularios (1.2 y 1.3); se revisan a ojo en un programa de mapas (1.4); se procesan con código para que el resultado se pueda repetir (1.5); y, antes de darlo por bueno, se comprueba que todo cuadre (rombo de decisión).
| Fuente | Llega como | Qué aporta | Variables que alimenta (Etapa 2) |
|---|---|---|---|
| Mapas de cobertura 4G | Shapefile (polígonos) | Dónde hay señal 4G y con qué fuerza | deficit_4g, clase_4g |
| Radiobases 4G existentes | Formularios en Excel | Dónde están las antenas 4G y cómo son | dist_rb4g_km, n_rb4g_2km |
| Topografía | GeoTIFF y líneas | La altura del terreno cada 30 m | alt_media_m, alt_max_m, pendiente_deg, tpi_m, visibilidad_km2 |
| Población por comunidad | Tablas en un PDF | Cuánta gente vive y dónde | poblacion, densidad_hab_km2, pob_sin_4g |
| Infraestructura clave | OpenStreetMap y PDOT | Escuelas, salud, urbanizaciones y vías | n_escuelas, n_salud, n_urbanizaciones, dist_via_m |
| Suelo y límite | Archivos SUPERTEL y OSM | Tipo de suelo y borde de la parroquia | uso_suelo, conductividad_S_m |
Las 19 variables se calculan en la Etapa 2. Aquí solo se preparan las capas de las que
salen. La variable exclusion no sale de una sola fuente: combina la hidrografía de
OpenStreetMap (ríos, quebradas y lagunas), las áreas protegidas y la pendiente que se calcula con el
modelo digital de elevación (DEM, la malla de alturas del terreno).
# Esquema de la etapa (las funciones resumen lo que explica cada bloque)
inventario = inventariar(fuentes) # 1.1 una ficha por capa
comunidades = leer_tabla("poblacion_pdot_el_valle.csv") # 1.2 transcrita del PDOT (49 filas)
radiobases = extraer_lte(formularios, margen_m=5000) # 1.3 solo LTE, El Valle + 5 km
capas = revisar_en_qgis(capas_crudas, margen_m=1500) # 1.4 revisión visual y recorte
capas = procesar(capas, crs=32717) # 1.5 → "el_valle_etapa1_borrador.gpkg"
while not consistentes_y_completas(capas): # rombo de decisión (revisa el borrador)
capas = procesar(revisar_en_qgis(capas), crs=32717) # "No": corregir y volver a procesar
guardar_geopackage(capas, "el_valle_etapa1.gpkg") # "Sí": Producto 1 (el borrador aprobado)
Son los mapas que indican dónde llega la señal 4G de cada operadora y con qué fuerza. Son mapas predictivos: los elaboran las propias operadoras con sus herramientas de planificación y se los entregan a la ARCOTEL.
Cada operadora entrega una capa con tres polígonos, uno por nivel de señal: alta, media y baja. Lo que queda fuera de los tres polígonos se considera sin servicio. No son mediciones hechas en la calle: son el resultado de simular la propagación de la señal desde cada radiobase sobre el relieve.
.shp (las
geometrías), .shx (un índice), .dbf (la tabla de atributos) y
.prj (el sistema de coordenadas).Metodologia/Cobertura/OTECEL/3. LTE/1. Azuay/1. Azuay Cobertura LTE Semestre I 2026.shp
(Movistar, Semestre I de 2026),
Metodologia/Cobertura/CONECEL/LTE JULIO 2026/AZUAY LTE/AZUAY SHAPE/AZUAY LTE JUNIO 2026_region.shp
(Claro, junio de 2026) y Metodologia/Cobertura/CNT/4G/PROVINCIAS/1. AZUAY/SHP/01.shp
(CNT, 2025). El mapa de CNT es un año más antiguo que los otros dos: es el último que tiene el
proyecto, y la diferencia se declara en la memoria. Las carpetas también traen mapas de 2G y 3G, pero
no se usan: el diagrama trabaja solo con la red 4G. De estos mapas salen las variables
deficit_4g y clase_4g (0 sin servicio, 1 baja, 2 media, 3 alta). Además,
fijan el umbral de −120 dBm que define el déficit 4G y que, en la Etapa 4, marca la cobertura en
Radio Mobile.Paso de milivatios (mW) a dBm, donde \(P\) es la potencia recibida:
$$P_{\text{dBm}} = 10\,\log_{10}\!\left(\frac{P}{1\ \text{mW}}\right)$$RSRP: promedio de la potencia \(P_k\) de los \(N\) canalitos de 15 kHz que llevan la señal piloto dentro del canal medido (la norma los llama "elementos de recurso"):
$$\text{RSRP} = \frac{1}{N}\sum_{k=1}^{N} P_k$$\(P_k\) va en mW (escala lineal), no en dBm: primero se promedia y después el resultado se pasa a dBm con la fórmula anterior.
Clasificación en niveles (valores en dBm):
$$\text{nivel} = \begin{cases} \text{alta} & \text{RSRP} \geq -100 \\ \text{media} & -120 \leq \text{RSRP} \lt -100 \\ \text{baja} & -140 \leq \text{RSRP} \lt -120 \\ \text{sin servicio} & \text{RSRP} \lt -140 \end{cases}$$Colores de los niveles en la plataforma web; el número es el código de
clase_4g.
¿Cuánta potencia es −100 dBm? \(10^{-100/10} = 10^{-10}\) mW, es decir, una diezmilmillonésima de milivatio. En −120 dBm la potencia es \(10^{-12}\) mW (100 veces menos) y en −140 dBm, \(10^{-14}\) mW. Entre el límite de la señal alta y el de la baja hay 40 dB: 10 000 veces menos potencia.
¿Por qué números tan bajos? La RSRP mide la potencia de una subportadora de 15 kHz. Un canal LTE completo de 20 MHz (la "portadora") tiene 1 200 subportadoras, así que la potencia total que recibe el teléfono es de unos \(10\log_{10}(1\,200) \approx 30.8\) dB más. Una RSRP de −100 dBm corresponde, aproximadamente, a −69 dBm de potencia total.
Cálculo aproximado: supone que todas las subportadoras llegan con la misma potencia.
Tabla de atributos (.dbf) de cada archivo, con el área de cada polígono calculada con
GeoPandas sobre todo el Azuay:
| Movistar (OTECEL) | ||||
|---|---|---|---|---|
| LEGEND | THRESHOLD | COLOR | Prediction | Área (km²) |
| RSRP Level (DL) (dBm) >=-140 | −140 | 255 0 0 | 1. Azuay Cobertura LTE Semestre I 2026 | 1 639.8 |
| RSRP Level (DL) (dBm) >=-120 | −120 | 0 0 255 | 1. Azuay Cobertura LTE Semestre I 2026 | 1 281.5 |
| RSRP Level (DL) (dBm) >=-100 | −100 | 0 255 0 | 1. Azuay Cobertura LTE Semestre I 2026 | 175.2 |
| Claro (CONECEL) | ||
|---|---|---|
| Float | String | Área (km²) |
| −140 | (vacía) | 622.8 |
| −120 | (vacía) | 251.0 |
| −100 | (vacía) | 91.6 |
| CNT | ||
|---|---|---|
| LEYENDA | NIV_SE | Área (km²) |
| Nivel de señal bajo | −140 dBm ≤ Cobertura con niveles < −120 dBm | 553.5 |
| Nivel de señal medio | −120 dBm ≤ Cobertura con niveles < −100 dBm | 250.4 |
| Nivel de señal alto | Cobertura con niveles ≥ −100 dBm | 95.2 |
| Sin cobertura 4G | Sin cobertura 4G | 7 619.2 |
El detalle clave. La leyenda de Movistar dice ">=-140": ese polígono es toda la zona con señal igual o mayor que −140 dBm, así que contiene a los de −120 y −100 (polígonos anidados). Los polígonos de Claro, en cambio, no se solapan: cada uno es solo su franja (bandas disjuntas). Si se sumaran las áreas de Movistar tal cual, la misma zona se contaría dos o tres veces. Por eso en el paso 1.5 los tres mapas se convierten a bandas disjuntas.
CNT engaña. Su columna NIV_SE describe rangos que no se solapan
("−140 dBm ≤ … < −120 dBm"), pero las geometrías están anidadas, como las de
Movistar: el polígono "bajo" contiene por completo al medio y al alto. Además trae un cuarto
polígono, "Sin cobertura 4G", que no es un nivel de señal y se descarta, y 4 geometrías inválidas
que hay que reparar. El umbral no viene como número: se deduce del texto de LEYENDA.
Áreas reales de los archivos completos del Azuay (no solo de El Valle).
import geopandas as gpd
MV = "Metodologia/Cobertura/OTECEL/3. LTE/1. Azuay/1. Azuay Cobertura LTE Semestre I 2026.shp"
CL = "Metodologia/Cobertura/CONECEL/LTE JULIO 2026/AZUAY LTE/AZUAY SHAPE/AZUAY LTE JUNIO 2026_region.shp"
CN = "Metodologia/Cobertura/CNT/4G/PROVINCIAS/1. AZUAY/SHP/01.shp"
movistar = gpd.read_file(MV)
claro = gpd.read_file(CL)
cnt = gpd.read_file(CN)
print(movistar.crs) # EPSG:32717 (UTM 17 Sur, en metros)
print(movistar.drop(columns="geometry")) # LEGEND, THRESHOLD, COLOR, Prediction
print(claro.drop(columns="geometry")) # Float (umbral en dBm) y String (vacía)
print(cnt[["LEYENDA", "NIV_SE"]]) # el nivel solo como texto (4 filas)
print((~cnt.is_valid).sum()) # 4 geometrías inválidas
movistar["area_km2"] = (movistar.area / 1e6).round(1) # m² → km²
print(movistar["area_km2"].tolist()) # [1639.8, 1281.5, 175.2]
Es la lista oficial de las radiobases que ya están funcionando (de todas las tecnologías; aquí solo se usan las 4G): dónde está cada una y cómo está configurada. Las operadoras la reportan a la ARCOTEL en formularios de Excel con un formato fijo.
Cada operadora usa dos formularios que se complementan: uno con los datos de radio de cada sector (banda, tecnología y, en Claro, también potencia y antena) y otro con la ubicación de las estructuras (torres, mástiles, monopolos) donde están montadas las antenas. Se unen por el código de la estructura o del sitio.
| Operadora | Formulario | Una fila por… | Qué trae |
|---|---|---|---|
| Claro | RFE-1A | sector y banda | tecnología, banda, azimut, ganancia, altura, potencia, PIRE |
| Claro | RNE-1A (estructuras) | estructura | parroquia, dirección, latitud y longitud, altura |
| Movistar | SMA-RT-008.1 (hoja "Estructuras") | sitio | parroquia, dirección, latitud y longitud, altura |
| Movistar | SMA-RT-008.2 | sector y banda | banda, tecnología, sector, Cell ID y código de la estructura (sin azimut, ganancia ni potencia) |
| CNT | SMA-RT-008.1 (hoja "Hoja2") | estructura | parroquia, dirección, latitud y longitud, altura |
| CNT | SMA-RT-008.2 | sector y banda | banda, tecnología (solo LTE), sector, Cell ID y código de la estructura (sin azimut, ganancia ni potencia) |
dist_rb4g_km y n_rb4g_2km; (2) calibran la regla de etiquetado de
la Etapa 2, porque sus valores de pendiente, distancia a la vía y población fijan los umbrales de lo
que se considera un sitio viable; y (3) en el caso de Claro (RFE-1A) aportan los
parámetros de cada antena (banda, PIRE, azimut, ganancia y altura) para simular en Radio Mobile en la
Etapa 4. Los formularios de Movistar y de CNT solo dan la ubicación, la altura de la estructura, la
banda y la tecnología, así que en la Etapa 4 sus parámetros de antena se suponen (valores de partida)
y se declara que son supuestos. Un detalle práctico: en los formularios la parroquia figura como
"VALLE", no como "EL VALLE", y en otras provincias hay parroquias como "VALLE
HERMOSO". Buscar por nombre falla: por eso en 1.3 se selecciona por distancia.PIRE en la dirección principal de la antena:
$$\text{PIRE}_{\text{dBm}} = P_{TX,\ \text{dBm}} + G_{\text{dBi}} - L_{\text{dB}}$$Paso de dBm a vatios:
$$P_{\text{W}} = \frac{10^{\,P_{\text{dBm}}/10}}{1000}$$Un sector con potencia de 43 dBm, antena de 17.5 dBi y 2 dB de pérdidas en cables:
En su dirección principal, la antena "suena" como si una antena isotrópica emitiera 708 W, aunque el transmisor solo entregue 20 W: la diferencia la pone la ganancia de la antena.
Valores ilustrativos.
RFE-1A de Claro: varias filas por radiobase, una por cada sector y banda.
| 2) Nombre | 3) Banda | 4) Tecnología | 5) Sector | 8) Código estructura | Azimut [°] | Ganancia [dBi] | Altura base-antena [m] | Potencia [dBm] |
|---|---|---|---|---|---|---|---|---|
| SITIO_EJEMPLO | AWS-18312 | LTE | X | S0001 | 0 | 17.5 | 30 | 43 |
| SITIO_EJEMPLO | AWS-18312 | LTE | Y | S0001 | 120 | 17.5 | 30 | 43 |
| SITIO_EJEMPLO | 850 | LTE | Z | S0001 | 240 | 15.4 | 28 | 43 |
RNE-1A de Claro (y de forma parecida el SMA-RT-008.1 de Movistar y de CNT): una fila por estructura.
| 1) Código | 2) Sitio | 5) Parroquia | Lat (°) | (′) | (″) | dec. | S/N | Lon (°) | (′) | (″) | dec. | W | S.N.M. [m] | Base-cima [m] | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| S0001 | SITIO_EJEMPLO | VALLE | 02 | 55 | 30 | . | 000 | S | 78 | 58 | 12 | . | 000 | W | 2 600 | 30 |
Filas ilustrativas con la misma estructura de columnas que los formularios reales. Fíjate en que los segundos de las coordenadas vienen partidos en dos columnas (parte entera y parte decimal), separadas por una columna que solo contiene un punto. La parte decimal viene como texto con ceros a la izquierda: "020" = 0.020″ en el RNE-1A de Claro (3 cifras) y "01" = 0.01″ en el SMA-RT-008.1 de Movistar (2 cifras). Hay que leerla como texto: si se convierte en número, "020" pasa a ser 20 y la estructura se corre de sitio. CNT es la excepción: trae los segundos completos en una sola columna ("23.84"). Además, el RNE-1A de Claro empieza con una columna A vacía, que desplaza en uno todos los índices de columna.
import pandas as pd
RB = "Ubicaciones de Radiobase/CONECEL/"
rfe = pd.read_excel(RB + "Formato_RFE-1A Radiobases_Junio_2026_v2.xlsx",
header=None, skiprows=10, usecols=range(16)) # los datos empiezan en la fila 11
rfe.columns = ["no", "nombre", "banda", "tecnologia", "sector", "cell_id", "portadoras",
"cod_estructura", "bsc", "medio_tx", "cod_antena", "azimut",
"ganancia_dbi", "altura_m", "cod_equipo", "potencia_dbm"]
lte = rfe[rfe["tecnologia"] == "LTE"] # el formulario trae varias tecnologías: solo LTE
print(lte["banda"].value_counts()) # 700, 850, 1900 y AWS
por_estructura = lte.groupby("cod_estructura").agg(
filas=("sector", "size"), # sectores × bandas
bandas=("banda", "unique"),
)
Describe la forma del terreno de El Valle: qué tan alto está cada punto y dónde hay lomas y quebradas. Importa porque los cerros bloquean la señal de radio y porque una radiobase en una loma "ve" más territorio.
Metodologia/Topografia El Valle/dem_el_valle_utm17s_30m.tif: 379 × 413 píxeles de 30 m,
en EPSG:32717, recortado al límite de la parroquia más 1.5 km. Dentro de El Valle hay 47 814 píxeles,
con alturas de 2 482 a 3 103 m s. n. m. (media de 2 695 m). Las curvas de nivel están en la misma
carpeta: 616 líneas cada 10 m y 227 cada 25 m, con la altura en el atributo elev, y
coinciden con el DEM. De aquí salen las cinco variables de topografía: alt_media_m,
alt_max_m, pendiente_deg, tpi_m y visibilidad_km2.
En la Etapa 4, Radio Mobile usa su propio DEM (SRTM de 30 m).Pendiente de un píxel, a partir de cuánto cambia la altura \(z\) hacia el este (\(x\)) y hacia el norte (\(y\)):
$$\theta = \arctan\sqrt{\left(\frac{\partial z}{\partial x}\right)^{2} + \left(\frac{\partial z}{\partial y}\right)^{2}} \qquad \frac{\partial z}{\partial x} \approx \frac{z_{\text{este}} - z_{\text{oeste}}}{2 \cdot 30\ \text{m}}$$Pendiente a partir de las curvas, con \(\Delta z\) la equidistancia y \(d\) la distancia horizontal entre dos curvas:
$$\theta = \arctan\frac{\Delta z}{d}$$Comprobación del área con el número de píxeles dentro de la parroquia:
$$A = 47\,814 \times 900\ \text{m}^2 = 43\,032\,600\ \text{m}^2 \approx 43.03\ \text{km}^2$$Una ventana de 3 × 3 píxeles del DEM (alturas en metros; el norte arriba):
| 2 700 | 2 706 | 2 712 |
| 2 694 | 2 700 | 2 706 |
| 2 688 | 2 694 | 2 700 |
Para el píxel central: hacia el este \(\frac{2706 - 2694}{60} = 0.2\) y hacia el norte \(\frac{2706 - 2694}{60} = 0.2\). Entonces \(\theta = \arctan\sqrt{0.2^2 + 0.2^2} = \arctan(0.283) \approx 15.8^\circ\).
Con las curvas de 10 m: si en el mapa dos curvas están separadas 30 m, la pendiente es \(\arctan(10/30) \approx 18.4^\circ\); si están a 100 m, apenas \(\arctan(0.1) \approx 5.7^\circ\).
Números ilustrativos.
import numpy as np
import geopandas as gpd
import rasterio
from rasterio.features import geometry_mask
TOPO = "Metodologia/Topografia El Valle/"
limite = gpd.read_file(TOPO + "limite_el_valle_osm_utm17s.shp")
with rasterio.open(TOPO + "dem_el_valle_utm17s_30m.tif") as dem:
print(dem.crs, dem.res, dem.width, dem.height) # EPSG:32717 (30.0, 30.0) 379 413
z = dem.read(1).astype(float)
z[z == dem.nodata] = np.nan # −9999 = sin dato
dentro = geometry_mask(limite.geometry, out_shape=z.shape,
transform=dem.transform, invert=True) # True = píxel en El Valle
alturas = z[dentro]
print(alturas.size, alturas.min(), alturas.max(), alturas.mean())
# 47814 2481.96 3103.36 2695.28 (redondeado)
dz_dy, dz_dx = np.gradient(z, 30) # cambio de altura por metro
pendiente = np.degrees(np.arctan(np.hypot(dz_dx, dz_dy))) # grados, píxel por píxel
Dice cuánta gente vive en cada una de las 49 comunidades de El Valle y dónde está cada una. Sirve para medir el beneficio social: una radiobase nueva vale más donde hay más personas sin buena señal 4G.
densidad_hab_km2).Metodologia/Poblacion El Valle/PDOT-EL-Valle_completo.pdf). Las comunidades más pobladas
son Chilcapamba (1 736 hab., el 7.14 %), Cochapamba (1 389), El Valle Centro (1 362) y Quillopungo
(1 192). Según el PDOT, 3 comunidades son concentradas y reúnen el 18.45 % de la población, y las 46
restantes son dispersas (81.55 %). El PDOT no trae el contorno de cada comunidad en formato SIG: por
eso sus tablas se digitalizan (1.2) y, en la Etapa 2 (2.4), la población se reparte en las celdas con
un círculo de área equivalente. De aquí salen poblacion, densidad_hab_km2 y
pob_sin_4g.Densidad y peso de la comunidad \(i\), con \(P_i\) su población, \(A_i\) su área y \(P = 24\,314\):
$$d_i = \frac{P_i}{A_i} \qquad w_i = 100 \cdot \frac{P_i}{P}\ \%$$Centro de gravedad ponderado de la parroquia, como lo calcula el PDOT:
$$X_p = \sum_{i=1}^{49} \frac{X_i\,P_i}{P} \qquad Y_p = \sum_{i=1}^{49} \frac{Y_i\,P_i}{P}$$El PDOT obtiene \(X_p = 725\,717.14\) e \(Y_p = 9\,675\,125.97\). Sin ponderar por población, el centro es \(X = 725\,805.07\), \(Y = 9\,674\,885.24\): el ponderado se corre hacia el norte, donde se concentra la gente.
Chilcapamba: \(d = 1\,736 / 63.21 = 27.46\) hab/ha, es decir, 2 746 hab/km², y \(w = 1\,736 / 24\,314 = 7.14\,\%\).
Los Geranios: tiene 1 020 habitantes, pero en 489.63 ha: \(d = 2.08\) hab/ha. Tiene mucha gente, pero muy repartida. Por eso importan las dos variables: la población y la densidad.
| Comunidad | X (m) | Y (m) | Habitantes | Área (ha) | Densidad (hab/ha) |
|---|---|---|---|---|---|
| Baguanchi Cedillo | 728 247.99 | 9 677 445.03 | 381 | 112.61 | 3.38 |
| Castilla Cruz | 724 873.77 | 9 677 177.83 | 856 | 68.37 | 12.52 |
| Chilcapamba | 725 119.03 | 9 676 271.21 | 1 736 | 63.21 | 27.46 |
| Cochapamba | 726 860.13 | 9 674 095.26 | 1 389 | 113.96 | 12.19 |
| La Gloria Baja | 726 678.56 | 9 674 886.35 | 131 | 20.35 | 6.44 |
| La Pradera | 725 841.69 | 9 675 579.94 | 264 | 53.19 | 4.96 |
| Los Geranios | 725 773.04 | 9 677 206.08 | 1 020 | 489.63 | 2.08 |
| Victorio Centro | 724 859.89 | 9 674 288.28 | 250 | 51.96 | 4.81 |
| … 41 comunidades más | |||||
| Total (49) | 24 314 | 4 404.43 |
Datos reales de las Tablas 4-50 y 4-51 del PDOT. Ojo: las áreas de la Tabla 4-51 suman 4 404.43 ha (44.04 km²), algo más que los 43.03 km² del límite de OpenStreetMap, porque las dos fuentes no trazan el borde exactamente igual.
import pandas as pd
pob = pd.DataFrame({
"comunidad": ["Chilcapamba", "Cochapamba", "Castilla Cruz", "Baguanchi Cedillo"],
"x_utm": [725119.03, 726860.13, 724873.77, 728247.99],
"y_utm": [9676271.21, 9674095.26, 9677177.83, 9677445.03],
"habitantes": [1736, 1389, 856, 381],
"area_ha": [63.21, 113.96, 68.37, 112.61],
})
pob["densidad_hab_ha"] = (pob["habitantes"] / pob["area_ha"]).round(2)
pob["peso_pct"] = (100 * pob["habitantes"] / 24314).round(2)
print(pob[["comunidad", "habitantes", "densidad_hab_ha", "peso_pct"]])
# comunidad habitantes densidad_hab_ha peso_pct
# 0 Chilcapamba 1736 27.46 7.14
# 1 Cochapamba 1389 12.19 5.71
# 2 Castilla Cruz 856 12.52 3.52
# 3 Baguanchi Cedillo 381 3.38 1.57
Son los lugares que hacen más valiosa una nueva radiobase (escuelas, centros de salud y urbanizaciones) y las vías que permiten llegar a construirla y mantenerla.
clave=valor: por ejemplo, amenity=school para
una escuela o highway=track para un camino de tierra.n_escuelas, n_salud y
n_urbanizaciones (beneficio social) y dist_via_m (viabilidad).| Capa | Etiquetas de OSM | Geometría | Variable |
|---|---|---|---|
| Escuelas | amenity=school, amenity=kindergarten | punto o polígono → punto | n_escuelas |
| Salud | amenity=hospital, clinic o doctors; healthcare=* | punto o polígono → punto | n_salud |
| Urbanizaciones | landuse=residential, completado con el PDOT | polígono | n_urbanizaciones |
| Vías | highway=* (primary, secondary, tertiary, track, path…) | línea | dist_via_m |
Conteo de escuelas en una celda \(c\) (igual para salud y urbanizaciones), donde \(p_i\) es la ubicación de la escuela \(i\):
$$n_{\text{esc}}(c) = \sum_{i} \mathbf{1}\left[\,p_i \in c\,\right]$$donde \(\mathbf{1}[\cdot]\) vale 1 si lo que está entre corchetes se cumple y 0 si no, y \(p_i \in c\) se lee "la escuela \(i\) está dentro de la celda \(c\)": la suma cuenta las escuelas de la celda.
Distancia desde el centro de la celda a la vía más cercana:
$$d_{\text{vía}}(c) = \min_{v\, \in\, \text{vías}}\ \operatorname{dist}\big(\text{centro}(c),\, v\big)$$Una celda de 250 m con una escuela dentro, ningún centro de salud y un camino de tierra a 85 m de su centro: \(n_{\text{esc}} = 1\), \(n_{\text{salud}} = 0\) y \(d_{\text{vía}} = 85\) m.
Valores ilustrativos.
import geopandas as gpd
import osmnx as ox # descarga datos de OpenStreetMap
TOPO = "Metodologia/Topografia El Valle/"
limite = gpd.read_file(TOPO + "limite_el_valle_osm_utm17s.shp")
zona = limite.buffer(1500).to_crs(4326).union_all() # OSM trabaja en latitud y longitud
escuelas = ox.features_from_polygon(zona, tags={"amenity": ["school", "kindergarten"]})
salud = ox.features_from_polygon(zona, tags={"amenity": ["hospital", "clinic", "doctors"],
"healthcare": True})
vias = ox.features_from_polygon(zona, tags={"highway": True})
escuelas = escuelas.to_crs(32717)
escuelas["geometry"] = escuelas.representative_point() # un polígono de escuela → un punto
vias = vias[vias.geom_type == "LineString"].to_crs(32717)
# Distancia de cada centro de celda a la vía más cercana (se usa en la Etapa 2)
# centros = gpd.sjoin_nearest(centros, vias[["geometry"]], distance_col="dist_via_m")
Reúne dos capas de apoyo: las características eléctricas del suelo, que influyen en cómo se refleja la señal en el terreno, y el borde oficial de la parroquia, que marca hasta dónde llega el estudio.
Metodologia/Conductividad del suelo/: Ecuador_60m.geo es el modelo del terreno y
Ecuador_60m.sol la capa de clutter (más los archivos auxiliares .pal,
.PRO y .rim, fechados en 2013), con su manual "Módulo 1. Cartografía Digital
SUPERTEL". Radio Mobile usa por defecto una conductividad de 0.005 S/m y una permitividad relativa de
15 para todo el mapa; con esta capa, cada celda puede tener su propio valor. De aquí salen
uso_suelo y conductividad_S_m. La exclusion, en cambio, se arma
con la hidrografía de OpenStreetMap (29 tramos de ríos y quebradas y 2 lagunas, en
Metodologia/Topografia El Valle/hidrografia_*_el_valle_utm17s.shp), las áreas protegidas
(fuente aún por definir) y la pendiente. El límite está en Metodologia/Topografia El Valle/
como GeoJSON (en grados) y como shapefile (EPSG:32717). La parroquia mide 8.35 km de este a oeste y
9.35 km de norte a sur; con 1.5 km de margen la zona de trabajo sube a 95.04 km², y con 5 km, a
263.27 km².El suelo se describe con una permitividad compleja: un solo número que junta los dos parámetros, con \(f\) la frecuencia de la señal y \(c = 3 \times 10^{8}\) m/s la velocidad de la luz:
$$\varepsilon_c = \varepsilon_r - j\,60\,\sigma\,\lambda \qquad \lambda = \frac{c}{f}$$\(\lambda\) (la longitud de onda) es la distancia que recorre la onda en un ciclo: unos 16 cm a 1 900 MHz. \(j\) es la unidad imaginaria (\(j^2 = -1\)); aquí solo sirve para guardar en un mismo número dos efectos distintos: la parte sin \(j\) (\(\varepsilon_r\)) es el comportamiento de "aislante" del suelo y la parte con \(j\) (\(60\,\sigma\,\lambda\)), el de "conductor".
Con los valores por defecto de Radio Mobile (\(\sigma = 0.005\) S/m, \(\varepsilon_r = 15\)):
A las frecuencias de 4G el término de la conductividad es pequeño frente a \(\varepsilon_r\): el suelo se comporta casi como un aislante, y la conductividad pesa algo más en las bandas bajas. Si esta variable ayuda o no al modelo se verá en la importancia de variables (3.6).
import geopandas as gpd
TOPO = "Metodologia/Topografia El Valle/"
limite = gpd.read_file(TOPO + "limite_el_valle_osm.geojson") # en grados (EPSG:4326)
limite_utm = limite.to_crs(32717) # a metros
print(round(limite_utm.area.sum() / 1e6, 2)) # 43.03 km²
zona_15 = limite_utm.buffer(1500) # margen de trabajo
print(round(zona_15.area.sum() / 1e6, 2)) # 95.04 km²
# Suelo: el .sol se exporta antes a GeoTIFF ("suelo_clutter_60m.tif", una clase por píxel
# de 60 m), en 1.5 se remuestrea a 30 m ("suelo_clutter_30m.tif") y cada clase se traduce
# a σ y εr con una tabla armada a partir del manual:
# tabla = pd.read_csv("conductividad_por_clase.csv") # clase, sigma_S_m, eps_r
conductividad_por_clase.csv y el límite que recorta todas las demás capas.Antes de procesar nada se hace una ficha de cada capa: qué es, en qué formato viene, en qué coordenadas, con cuánto detalle y de qué fecha. Es la lista de control que evita sorpresas después.
exclusion).| Capa | Fuente | Formato | Sistema de referencia | Resolución o detalle | Fecha |
|---|---|---|---|---|---|
| Cobertura 4G Movistar | OTECEL, entregada a la ARCOTEL | Shapefile, 3 polígonos anidados | EPSG:32717 | predicción en malla de 20 m | Semestre I 2026 |
| Cobertura 4G Claro | CONECEL, entregada a la ARCOTEL | Shapefile, 3 polígonos disjuntos | EPSG:32717 | predicción en malla de 30 m | Junio 2026 |
| Cobertura 4G CNT | CNT, entregada a la ARCOTEL | Shapefile, 3 polígonos anidados + "Sin cobertura 4G"; 4 geometrías inválidas | EPSG:32717 | predicción vectorial, escala 1:250 000 | Enero 2025 (metadatos de julio 2025) |
| Radiobases Claro | RFE-1A y RNE-1A | Excel (.xlsx) | WGS 84, grados-minutos-segundos | un punto por estructura | Junio 2026 |
| Radiobases Movistar | SMA-RT-008.1 y .2 | Excel (.xlsx y .xlsb) | WGS 84, grados-minutos-segundos | un punto por sitio | Junio 2026 |
| Radiobases CNT | SMA-RT-008.1 y .2 | Excel (.xlsx) | WGS 84, grados-minutos-segundos | un punto por estructura | Abril-junio 2026 |
| DEM | Copernicus GLO-30 | GeoTIFF (float32) | EPSG:32717 | 30 m (379 × 413 píxeles) | Descarga: julio 2026 |
| Curvas de nivel | Trazadas del DEM | Shapefile y GeoJSON (líneas) | EPSG:32717 | cada 10 y 25 m | Julio 2026 |
| Población | PDOT El Valle | PDF (Tablas 4-50 y 4-51) | UTM, X e Y en metros | un punto por comunidad (49) | Datos 2015, actualización 2020 |
| Infraestructura | OpenStreetMap y PDOT | OSM y PDF | EPSG:4326 | objetos individuales | Fecha de descarga |
Hidrografía (para exclusion) | OpenStreetMap | GeoJSON y Shapefile (líneas y polígonos) | EPSG:4326 y EPSG:32717 | 29 tramos de ríos y quebradas, 2 lagunas | Julio 2026 |
Áreas protegidas (para exclusion) | pendiente de definir | — | — | polígonos | — |
| Suelo | SUPERTEL | .geo y .sol | proyección propia | 60 m | Archivos de 2013 |
| Límite parroquial | OSM, relación 3693191 | GeoJSON y Shapefile | EPSG:4326 y EPSG:32717 | polígono | Julio 2026 |
Datos tomados de los archivos del proyecto. Para la población se asume WGS 84 / UTM 17 Sur;
en 1.4 se comprueba que los 49 puntos caigan dentro de la parroquia. La hidrografía está en
Metodologia/Topografia El Valle/; para las áreas protegidas el proyecto aún no tiene una
capa: si ninguna toca la zona de trabajo, la capa queda vacía.
La resolución decide cuántos píxeles de una capa caen dentro de cada celda de las mallas de la Etapa 2, con \(\ell\) el lado de la celda y \(r\) el tamaño del píxel:
$$n_{\text{px}} = \left(\frac{\ell}{r}\right)^{2}$$| Celda | Píxeles del DEM (30 m) | Píxeles del suelo (60 m) |
|---|---|---|
| 100 m | 11.1 | 2.8 |
| 250 m | 69.4 | 17.4 |
| 500 m | 277.8 | 69.4 |
En una celda de 100 m el suelo tiene menos de 3 píxeles: su valor es casi el de un solo píxel. Ese tipo de límite queda anotado desde el inventario.
import geopandas as gpd
import pandas as pd
import rasterio
TOPO = "Metodologia/Topografia El Valle/"
vectores = {
"Cobertura 4G Movistar": "Metodologia/Cobertura/OTECEL/3. LTE/1. Azuay/1. Azuay Cobertura LTE Semestre I 2026.shp",
"Cobertura 4G Claro": "Metodologia/Cobertura/CONECEL/LTE JULIO 2026/AZUAY LTE/AZUAY SHAPE/AZUAY LTE JUNIO 2026_region.shp",
"Cobertura 4G CNT": "Metodologia/Cobertura/CNT/4G/PROVINCIAS/1. AZUAY/SHP/01.shp",
"Curvas de nivel 10 m": TOPO + "curvas_nivel_10m_el_valle.shp",
"Hidrografía (líneas)": TOPO + "hidrografia_lineas_el_valle_utm17s.shp",
"Límite El Valle": TOPO + "limite_el_valle_osm.geojson",
}
filas = []
for capa, ruta in vectores.items():
g = gpd.read_file(ruta)
filas.append({"capa": capa, "formato": ruta.rsplit(".", 1)[-1], "crs": g.crs.to_string(),
"geometria": g.geom_type.iloc[0], "elementos": len(g)})
with rasterio.open(TOPO + "dem_el_valle_utm17s_30m.tif") as dem:
filas.append({"capa": "DEM Copernicus GLO-30", "formato": "tif", "crs": dem.crs.to_string(),
"geometria": "ráster", "resolucion_m": dem.res[0],
"elementos": dem.width * dem.height}) # 379 × 413 = 156 527 píxeles
inventario = pd.DataFrame(filas)
inventario.to_csv("inventario_fuentes.csv", index=False)
# Movistar y Claro: shp, EPSG:32717, MultiPolygon, 3 · CNT: shp, EPSG:32717, MultiPolygon, 4
# Curvas: LineString, 616
# Hidrografía: shp, EPSG:32717, LineString, 29 · Límite: geojson, EPSG:4326, 1
# DEM: tif, EPSG:32717, 30.0 m
El PDOT trae la población en tablas dentro de un PDF, que un programa no puede usar tal cual. Aquí se copian a una tabla ordenada de 49 filas, una por comunidad, y se comprueba que no haya errores de copia.
Controles de la transcripción, con \(P_i\) los habitantes, \(A_i\) el área y \(d_i^{\,\text{PDOT}}\) la densidad que publica el PDOT:
$$\sum_{i=1}^{49} P_i = 24\,314 \qquad \left|\,\frac{P_i}{A_i} - d_i^{\,\text{PDOT}}\right| \leq 0.01$$Círculo de área equivalente (se usa en 2.4): como no hay contorno de cada comunidad, se dibuja un círculo con su misma área, centrado en su punto X, Y:
$$r_i = \sqrt{\frac{A_i}{\pi}}$$Cada celda recibe la parte de la población proporcional al área del círculo que cae dentro de ella, contando solo la parte del círculo que está dentro de El Valle (reparto propuesto):
$$P_{\text{celda}} = \sum_{i} P_i \cdot \frac{A\left(\text{círculo}_i \cap \text{celda}\right)}{A\left(\text{círculo}_i \cap \text{El Valle}\right)}$$Con ese denominador, cada comunidad reparte el 100 % de su gente entre las celdas y la suma de todas las celdas da exactamente 24 314 habitantes. En 2.4, "El Valle" es la unión de las celdas de la malla, que prácticamente coincide con la parroquia.
Chilcapamba: \(A = 63.21\) ha \(= 632\,100\) m², así que \(r = \sqrt{632\,100 / \pi} \approx 448.6\) m. Otros radios: La Gloria Baja, 254.5 m; Los Geranios, 1 248.4 m.
Si el círculo de Chilcapamba (entero dentro de la parroquia) cae en cuatro celdas de 500 m con el 40 %, 30 %, 20 % y 10 % de su área, esas celdas reciben 694, 521, 347 y 174 habitantes (suman 1 736).
¿Por qué ese denominador? Baguanchi Cedillo tiene su punto a unos 60 m del límite: solo el 63.4 % de su círculo cae dentro de El Valle. Si se dividiera entre el círculo entero, sus celdas recibirían apenas el 63.4 % de sus 381 habitantes y se perderían unos 139. El caso extremo es Los Cipreses, con casi la mitad de su círculo fuera (solo el 50.8 % dentro). En total, 13 de las 49 comunidades tienen parte del círculo fuera, y dividir entre el círculo entero perdería unos 1 087 habitantes (el 4.5 %).
Radios, fracciones dentro de El Valle y habitantes perdidos: cálculo con las 49 filas del PDOT y el límite de OSM. Los porcentajes del reparto de Chilcapamba son ilustrativos.
| comunidad | x_utm | y_utm | habitantes | area_ha | densidad_pdot | radio_m |
|---|---|---|---|---|---|---|
| Chilcapamba | 725119.03 | 9676271.21 | 1736 | 63.21 | 27.46 | 448.6 |
| La Gloria Baja | 726678.56 | 9674886.35 | 131 | 20.35 | 6.44 | 254.5 |
| Los Geranios | 725773.04 | 9677206.08 | 1020 | 489.63 | 2.08 | 1248.4 |
| … |
Estructura de poblacion_pdot_el_valle.csv (49 filas) con datos reales del
PDOT; radio_m se calcula.
import numpy as np
import pandas as pd
import geopandas as gpd
pob = pd.read_csv("poblacion_pdot_el_valle.csv") # transcrita de las Tablas 4-50 y 4-51
# Controles de la transcripción
assert len(pob) == 49
assert pob["habitantes"].sum() == 24314
densidad = pob["habitantes"] / pob["area_ha"]
print(pob[(densidad - pob["densidad_pdot"]).abs() > 0.01]) # filas con posible error de copia
# Un punto por comunidad, en UTM 17 Sur
comunidades = gpd.GeoDataFrame(pob, crs=32717,
geometry=gpd.points_from_xy(pob["x_utm"], pob["y_utm"]))
comunidades["radio_m"] = np.sqrt(comunidades["area_ha"] * 10_000 / np.pi).round(1)
comunidades.to_file("comunidades_pdot.gpkg", driver="GPKG")
De las miles de filas de los formularios de todo el país se quedan solo las radiobases 4G que pueden dar señal en El Valle: las que están dentro de la parroquia y las que están hasta 5 km alrededor.
tecnologia == "LTE".dist_rb4g_km y
n_rb4g_2km en las celdas del borde, y también dan señal en El Valle cuando se simula en
Radio Mobile.De grados, minutos y segundos a grados decimales (negativo en el sur y en el oeste):
$$\varphi = -\left(g + \frac{m}{60} + \frac{s}{3600}\right)$$Zona de selección, con \(d\) la distancia al límite de la parroquia:
$$Z_{5} = \left\{\, p \;:\; d\left(p,\ \text{El Valle}\right) \leq 5\ \text{km} \,\right\}$$Una estructura en 2°55′30.0″ S y 78°58′12.0″ W:
Un segundo de arco equivale a unos 31 m sobre el terreno. Si se lee mal la parte decimal de los segundos, o se olvida el signo del oeste, la radiobase aparece en otro sitio (o en otro continente). Ejemplo real: en el RNE-1A de Claro la parte decimal viene como texto ("020" = 0.020″); si se deja que pandas la convierta en el número 20, unos segundos de 16 y "020" se leen como 16.20″ en lugar de 16.020″. Así, 267 de las 3 375 estructuras se corren hasta 29 m.
Coordenadas del ejemplo ilustrativas; la conversión a UTM se hizo con pyproj. El conteo de 267 estructuras sale del formulario real.
| operadora | cod_estructura | banda | sector | azimut | ganancia_dbi | altura_m | potencia_dbm | x_utm | y_utm | dentro_el_valle |
|---|---|---|---|---|---|---|---|---|---|---|
| Claro | S0001 | AWS-18312 | X | 0 | 17.5 | 30 | 43 | 725643.1 | 9676492.8 | True |
| Claro | S0001 | 850 | Z | 240 | 15.4 | 28 | 43 | 725643.1 | 9676492.8 | True |
| Movistar | S0002 | 1900 | A | — | — | — | — | 722410.0 | 9679950.0 | False |
| CNT | S0003 | 700 | 1 | — | — | — | — | 724180.0 | 9675310.0 | True |
Filas ilustrativas: una fila por sector y banda, con la ubicación de su estructura. Movistar y CNT no reportan azimut, ganancia, altura de la antena ni potencia en el SMA-RT-008: esas columnas quedan vacías (de la altura solo se conoce la de la estructura, en el SMA-RT-008.1).
import pandas as pd
import geopandas as gpd
RB = "Ubicaciones de Radiobase/CONECEL/"
rfe = pd.read_excel(RB + "Formato_RFE-1A Radiobases_Junio_2026_v2.xlsx",
header=None, skiprows=10, usecols=range(16))
rfe.columns = ["no", "nombre", "banda", "tecnologia", "sector", "cell_id", "portadoras",
"cod_estructura", "bsc", "medio_tx", "cod_antena", "azimut",
"ganancia_dbi", "altura_m", "cod_equipo", "potencia_dbm"]
lte = rfe[rfe["tecnologia"] == "LTE"] # 1) solo 4G
est = pd.read_excel(RB + "Formulario_RNE-1A_ESTRUCTURAS_Junio_2026.xlsx",
header=None, skiprows=7, usecols=range(1, 23),
dtype={12: str, 18: str}) # decimales de los segundos como texto: "020" = 0.020″
est.columns = ["cod_estructura", "nombre_sitio", "provincia", "canton", "parroquia",
"localidad", "direccion", "lat_g", "lat_m", "lat_s", "lat_punto", "lat_sdec",
"lat_hem", "lon_g", "lon_m", "lon_s", "lon_punto", "lon_sdec", "lon_hem",
"tipo", "altura_snm_m", "altura_estructura_m"]
est = est.dropna(subset=["lat_g", "lon_g"])
def gms_a_decimal(g, m, s, s_dec, hem):
"""Grados, minutos y segundos (la parte decimal de los segundos viene aparte, como texto)."""
dec = "0" if pd.isna(s_dec) else str(s_dec).strip() # "020" → 16.020, no 16.20
segundos = float(f"{int(float(s))}.{dec}")
valor = float(g) + float(m) / 60 + segundos / 3600
return -valor if str(hem).strip().upper() in ("S", "W") else valor
est["lat"] = [gms_a_decimal(*f) for f in est[["lat_g", "lat_m", "lat_s", "lat_sdec", "lat_hem"]].values]
est["lon"] = [gms_a_decimal(*f) for f in est[["lon_g", "lon_m", "lon_s", "lon_sdec", "lon_hem"]].values]
con_lte = est[est["cod_estructura"].isin(lte["cod_estructura"])] # 2) estructuras con LTE
sitios = gpd.GeoDataFrame(con_lte, crs=4326,
geometry=gpd.points_from_xy(con_lte["lon"], con_lte["lat"]))
sitios = sitios.to_crs(32717) # 3) y 4) a UTM 17 Sur
limite = gpd.read_file("Metodologia/Topografia El Valle/limite_el_valle_osm_utm17s.shp")
zona_5km = limite.buffer(5000).union_all()
sitios = sitios[sitios.within(zona_5km)].copy() # 5) El Valle + 5 km
sitios["dentro_el_valle"] = sitios.within(limite.union_all())
rb_claro = sitios.merge(lte, on="cod_estructura") # una fila por sector y banda, con su punto
# Movistar: coordenadas en "SMA-RT-008 1 Junio 2026.xlsx" (hoja "Estructuras"; decimales de los
# segundos con 2 cifras, también como texto) y tecnología en "SMA-RT-008 2 Junio 2026.xlsb"
# (se lee con engine="pyxlsb"); se unen por el código del sitio.
# CNT: coordenadas en "CNT/SMA-RT-008 1 4G Abr-Jun2026.xlsx" (hoja "Hoja2"; segundos completos en una
# sola columna) y celdas LTE en "CNT/SMA-RT-008.2 4G Abr-Jun2026.xlsx"; se unen por el código.
# Al juntar las tres, la clave es (operadora, cod_estructura): los códigos "S###" se repiten entre ellas.
Con todas las capas abiertas a la vez en un programa de mapas, se revisa a ojo que cada cosa esté donde debe, se reparan los polígonos defectuosos y se recorta todo a El Valle con un margen de 1.5 km (salvo las radiobases 4G, que conservan la zona de 5 km del paso 1.3).
| Capa | Qué se revisa | Problema típico |
|---|---|---|
| Mapas 4G | que las franjas cubran El Valle y sigan el relieve | polígonos inválidos, fragmentos diminutos |
| Radiobases | que caigan sobre una estructura en la imagen satelital | signo de la longitud o segundos mal leídos |
| Comunidades del PDOT | que los 49 puntos caigan dentro del límite, en su barrio | un dígito cambiado al transcribir |
| DEM y curvas | que las curvas coincidan con el sombreado del relieve | píxeles sin dato |
| Vías y equipamientos | que las vías sigan las calles de la imagen | objetos repetidos (punto y polígono) |
| Límite | que el borde se parezca al del PDOT | diferencias de trazado |
native:fixgeometries).native:clip).native:buffer). Es la zona de trabajo de todas las capas salvo las
radiobases 4G, que se quedan con su zona de 5 km (1.3) porque las de fuera también dan señal en El
Valle.visibilidad_km2) se extiende hacia afuera y la vía o la radiobase más
cercana pueden estar fuera de la parroquia. El DEM ya viene recortado a esa misma zona. Con el margen,
los 43.03 km² de El Valle pasan a 95.04 km² de zona de trabajo. Los mapas 4G necesitan esta revisión:
el polígono de −140 dBm de Claro tiene 12 880 partes y los de Movistar entre 5 600 y 7 300 cada uno,
casi todas diminutas. Los cuatro polígonos de CNT son inválidos (bordes que se cruzan), aunque los
defectos caen fuera de la zona de trabajo: tras el recorte ninguno queda inválido. Igual se reparan
antes de recortar, para no depender de esa suerte. Además, a este paso vuelve la flecha "No" del rombo
de decisión.Zona de trabajo y recorte de una capa \(C\):
$$Z_{1.5} = \left\{\, p \;:\; d\left(p,\ \text{El Valle}\right) \leq 1.5\ \text{km} \,\right\} \qquad C_{\text{recortada}} = C \cap Z_{1.5}$$Área de un polígono con vértices \((x_i, y_i)\) (fórmula del "cordón de zapato"):
$$A = \frac{1}{2}\left|\sum_{i=1}^{n} \left(x_i\,y_{i+1} - x_{i+1}\,y_i\right)\right|$$Un polígono con vértices (0, 0), (2, 2), (2, 0) y (0, 2), en metros, tiene sus bordes cruzados: forma un "lazo" de dos triángulos de 1 m² cada uno. La fórmula suma \(0 - 4 + 4 + 0 = 0\): su área calculada es 0 m², aunque cubre 2 m².
Al repararlo, se convierte en dos triángulos separados (un multipolígono) y su área pasa a ser 2 m². Con polígonos de cobertura inválidos, las áreas por nivel de señal de la Etapa 2 saldrían mal.
Ejemplo ilustrativo.
Las mismas herramientas se pueden lanzar desde la consola de Python de QGIS:
import processing # consola de Python de QGIS
TOPO = "Metodologia/Topografia El Valle/"
CN = "Metodologia/Cobertura/CNT/4G/PROVINCIAS/1. AZUAY/SHP/01.shp" # la capa con 4 polígonos inválidos
zona = processing.run("native:buffer", {
"INPUT": TOPO + "limite_el_valle_osm_utm17s.shp",
"DISTANCE": 1500, "SEGMENTS": 16, "DISSOLVE": True,
"OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
reparada = processing.run("native:fixgeometries", {
"INPUT": CN, "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
processing.run("native:clip", {
"INPUT": reparada, "OVERLAY": zona, "OUTPUT": "cnt_lte_el_valle.gpkg"})
# Claro y Movistar pasan por las mismas dos herramientas, aunque sus polígonos ya son válidos.
Y el ejemplo del lazo, con Shapely:
from shapely.geometry import Polygon
from shapely.validation import explain_validity, make_valid
lazo = Polygon([(0, 0), (2, 2), (2, 0), (0, 2)])
print(lazo.is_valid, explain_validity(lazo)) # False Self-intersection[1 1]
print(lazo.area) # 0.0 (mal)
print(make_valid(lazo).area) # 2.0 (dos triángulos de 1 m²)
Lo que en QGIS se revisó a mano, aquí se hace con código, para que se pueda repetir exactamente igual: todo pasa al mismo sistema de coordenadas, los mapas 4G quedan como franjas que no se solapan y los rásteres se alinean en una misma malla de píxeles.
umbral_dbm) y a CNT se le quita el polígono "Sin cobertura 4G".el_valle_etapa1_borrador.gpkg, que revisa el rombo de control.geometry: cada fila es un objeto con
su forma y sus atributos.THRESHOLD (Movistar), en Float (Claro) o solo como texto en
LEYENDA (CNT). Una vez que las tres tienen la misma columna umbral_dbm, un
mismo algoritmo sirve para todas: ordenar los polígonos del más fuerte al más débil y a cada uno
restarle la unión de los anteriores. Con Claro no cambia nada; con Movistar y CNT, sí, y mucho, como
muestran las barras de abajo.Con \(P_{-100}\), \(P_{-120}\) y \(P_{-140}\) los polígonos originales y \(Z\) la zona de trabajo:
$$B_{\text{alta}} = P_{-100} \qquad B_{\text{media}} = P_{-120} \setminus P_{-100} \qquad B_{\text{baja}} = P_{-140} \setminus \left(P_{-100} \cup P_{-120}\right)$$ $$S_{\text{sin servicio}} = Z \setminus \left(P_{-100} \cup P_{-120} \cup P_{-140}\right)$$Comprobación rápida de UTM: El Valle tiene \(Y \approx 9\,675\,000\) m, así que está a \(10\,000\,000 - 9\,675\,000 = 325\) km al sur del ecuador, que equivalen a unos \(325 / 111 \approx 2.93^\circ\) de latitud sur.
En gris, los polígonos originales de Movistar; en color, las bandas disjuntas que resultan (km²).
Áreas en km² de todo el archivo del Azuay. Sumar los polígonos originales de Movistar daría \(1\,639.8 + 1\,281.5 + 175.2 = 3\,096.5\) km², pero la huella real es de solo 1 639.8 km²: casi la mitad se contaría dos veces. Las bandas disjuntas suman \(175.2 + 1\,106.3 + 358.3 = 1\,639.8\) km², justo la huella.
Con CNT pasa lo mismo: sus polígonos suman \(553.5 + 250.4 + 95.2 = 899.1\) km², pero las bandas (95.2, 155.2 y 303.0 km²) cubren justo su huella real de 553.5 km² (la décima que falta es redondeo). En Claro, las bandas quedan igual que los polígonos (91.6, 251.0 y 622.8 km²), porque ya eran disjuntos. Su huella es de 965.4 km².
Datos reales, calculados con el código de abajo
(Metodologia/pipeline/scripts/normalizar_cobertura_4g.py).
¿Por qué el suelo se remuestrea con el vecino más cercano? Si un píxel de 60 m es "urbano" (código 2) y su vecino es "vegetación" (código 5), una interpolación bilineal daría 3.5 en el medio: una clase que no existe. El vecino más cercano asigna 2 o 5. En cambio, entre dos alturas de 2 700 y 2 760 m, interpolar 2 730 m sí tiene sentido.
Códigos ilustrativos.
El código parte a propósito de los archivos originales: así todo el proceso se puede repetir desde
cero. Las correcciones que se detectaron en la revisión de QGIS (1.4) se aplican aquí (por ejemplo,
make_valid repara los polígonos inválidos de CNT) y el recorte a la zona de trabajo se
repite al final.
import geopandas as gpd
from shapely.ops import unary_union
from shapely import force_2d
from shapely.validation import make_valid
MV = "Metodologia/Cobertura/OTECEL/3. LTE/1. Azuay/1. Azuay Cobertura LTE Semestre I 2026.shp"
CL = "Metodologia/Cobertura/CONECEL/LTE JULIO 2026/AZUAY LTE/AZUAY SHAPE/AZUAY LTE JUNIO 2026_region.shp"
CN = "Metodologia/Cobertura/CNT/4G/PROVINCIAS/1. AZUAY/SHP/01.shp"
NIVEL = {-100: "alta", -120: "media", -140: "baja"}
LEYENDA_CNT = {"Nivel de señal alto": -100, "Nivel de señal medio": -120, "Nivel de señal bajo": -140}
# 1) Esquema común: una columna numérica umbral_dbm en las tres capas
mv = gpd.read_file(MV).assign(umbral_dbm=lambda g: g["THRESHOLD"].astype(int)) # Movistar
cl = gpd.read_file(CL).assign(umbral_dbm=lambda g: g["Float"].astype(int)) # Claro
cn = gpd.read_file(CN)
cn = cn[cn["LEYENDA"] != "Sin cobertura 4G"] # CNT: fuera el 4.º
cn = cn.assign(umbral_dbm=cn["LEYENDA"].map(LEYENDA_CNT)) # el umbral, del texto
def a_bandas(capa):
"""Polígonos anidados o disjuntos → bandas disjuntas (alta, media, baja)."""
capa = capa.to_crs(32717).sort_values("umbral_dbm", ascending=False) # del más fuerte al más débil
filas, mas_fuertes = [], None
for _, fila in capa.iterrows():
poligono = force_2d(make_valid(fila.geometry)) # repara (CNT) y quita la Z que trae Claro
banda = poligono if mas_fuertes is None else poligono.difference(mas_fuertes)
mas_fuertes = poligono if mas_fuertes is None else unary_union([mas_fuertes, poligono])
umbral = int(fila["umbral_dbm"])
filas.append({"nivel": NIVEL[umbral], "umbral_dbm": umbral, "geometry": banda})
return gpd.GeoDataFrame(filas, geometry="geometry", crs=32717)
bandas_mv, bandas_cl, bandas_cn = a_bandas(mv), a_bandas(cl), a_bandas(cn)
print((bandas_mv.area / 1e6).round(1).tolist()) # [175.2, 1106.3, 358.3]
print((bandas_cl.area / 1e6).round(1).tolist()) # [91.6, 251.0, 622.8]
print((bandas_cn.area / 1e6).round(1).tolist()) # [95.2, 155.2, 303.0]
limite = gpd.read_file("Metodologia/Topografia El Valle/limite_el_valle_osm_utm17s.shp")
zona = limite.buffer(1500)
bandas_mv = gpd.clip(bandas_mv, zona) # solo la zona de trabajo
bandas_cl = gpd.clip(bandas_cl, zona)
bandas_cn = gpd.clip(bandas_cn, zona)
# Borrador que revisa el rombo (el resto de capas se escribe igual; ver Producto 1)
BORRADOR = "el_valle_etapa1_borrador.gpkg"
bandas_mv.to_file(BORRADOR, layer="cobertura4g_movistar", driver="GPKG")
bandas_cl.to_file(BORRADOR, layer="cobertura4g_claro", driver="GPKG")
bandas_cn.to_file(BORRADOR, layer="cobertura4g_cnt", driver="GPKG")
Este bloque ya existe como script completo, con los controles del rombo incluidos:
Metodologia/pipeline/scripts/normalizar_cobertura_4g.py. Deja las tres capas en
Metodologia/pipeline/datos/normalizado/cobertura4g_el_valle.gpkg, con las columnas
operadora, fecha_corte, nivel y umbral_dbm.
Remuestreo del suelo a la malla del DEM:
import numpy as np
import rasterio
from rasterio.warp import reproject, Resampling
with rasterio.open("Metodologia/Topografia El Valle/dem_el_valle_utm17s_30m.tif") as dem:
malla = dem.profile # 30 m, EPSG:32717: la malla de referencia
SIN_DATO = 255 # la clase 0 existe: "sin dato" usa otro código
with rasterio.open("suelo_clutter_60m.tif") as src: # el .sol exportado a GeoTIFF
suelo_30m = np.full((malla["height"], malla["width"]), SIN_DATO, dtype="uint8")
reproject(source=rasterio.band(src, 1), destination=suelo_30m,
dst_transform=malla["transform"], dst_crs=malla["crs"], dst_nodata=SIN_DATO,
resampling=Resampling.nearest) # categorías: vecino más cercano
malla.update(count=1, dtype="uint8", nodata=SIN_DATO)
with rasterio.open("suelo_clutter_30m.tif", "w", **malla) as dst: # lo lee la Etapa 2 (2.4)
dst.write(suelo_30m, 1)
suelo_clutter_30m.tif), guardados en el borrador el_valle_etapa1_borrador.gpkg y listos para el control de calidad.Es el control de calidad de la etapa: si todas las capas están en el mismo sistema, completas y sin errores, se guardan como Producto 1. Si algo falla, se vuelve a QGIS a corregirlo y se procesa de nuevo.
Los rombos del diagrama son decisiones: de ellos salen dos caminos. En este:
dist_rb4g_km; dos bandas solapadas inflarían el área con
señal 4G. Todo eso afectaría a las variables, a las etiquetas y, al final, a las ubicaciones que
recomiende el modelo.| # | Comprobación | Cómo se mide | Debe dar |
|---|---|---|---|
| 1 | Mismo sistema | el CRS de cada capa | EPSG:32717 en todas |
| 2 | Geometrías válidas | is_valid de cada objeto | 100 % |
| 3 | Bandas 4G disjuntas | área de la intersección de cada par de bandas | 0 m² |
| 4 | Bandas completas | unión de las bandas frente a la huella original | misma área |
| 5 | Relieve sin huecos | píxeles sin dato dentro de la zona | 0 |
| 6 | Población completa | filas y suma de habitantes | 49 y 24 314 |
| 7 | Radiobases correctas | tecnología y ubicación | solo LTE, todas en El Valle + 5 km |
| 8 | Zona cubierta | extensión de cada capa frente a El Valle + 1.5 km | la cubre entera |
Basta con que falle una comprobación para que la respuesta sea "No".
| Ronda | Qué falló | Corrección en QGIS | Respuesta |
|---|---|---|---|
| 1 | Regla 2: 4 polígonos inválidos en la capa de CNT (se recortó sin reparar antes) | "Corregir geometrías" y recortar otra vez | No |
| 2 | Regla 6: la suma da 24 134 (se copió 1 556 en lugar de 1 736) | corregir el valor en la tabla de atributos | No |
| 3 | Nada: las 8 comprobaciones pasan | — | Sí |
Rondas ilustrativas.
import geopandas as gpd
GPKG = "el_valle_etapa1_borrador.gpkg" # salida de 1.5, aún sin aprobar
capas = {nombre: gpd.read_file(GPKG, layer=nombre) for nombre in gpd.list_layers(GPKG)["name"]}
# 1) y 2) Mismo sistema y geometrías válidas
assert all(c.crs.to_epsg() == 32717 for c in capas.values())
assert all(c.is_valid.all() for c in capas.values())
# 3) Bandas 4G disjuntas: el solape entre cada par debe ser nulo
for operadora in ["movistar", "claro", "cnt"]:
g = capas[f"cobertura4g_{operadora}"].geometry.tolist()
solape = sum(g[i].intersection(g[j]).area for i in range(len(g)) for j in range(i + 1, len(g)))
assert solape < 1.0, f"{operadora}: {solape:.1f} m² de solape"
# 6) Población completa
com = capas["comunidades_pdot"]
assert len(com) == 49 and com["habitantes"].sum() == 24314
print("Sí: capas consistentes y completas") # si algún assert falla, la respuesta es "No"
el_valle_etapa1_borrador.gpkg.Es el resultado de la etapa: un solo archivo, el GeoPackage de El Valle, con todas las capas limpias, recortadas y en EPSG:32717. De aquí toman los datos la Etapa 2 y la plataforma web.
.gpkg)el_valle_etapa1_borrador.gpkg) se guarda como el_valle_etapa1.gpkg: ese
archivo es el Producto 1.| Capa | Tipo | Contenido | Alimenta |
|---|---|---|---|
limite_el_valle | polígono | borde de OSM (43.03 km²) | el recorte de todo |
zona_1500m | polígono | límite + 1.5 km (95.04 km²) | variables del borde |
cobertura4g_movistar | polígonos | 3 bandas disjuntas | deficit_4g, clase_4g |
cobertura4g_claro | polígonos | 3 bandas disjuntas | deficit_4g, clase_4g |
cobertura4g_cnt | polígonos | 3 bandas disjuntas (mapa de 2025) | deficit_4g, clase_4g |
radiobases_lte | puntos | sectores LTE de Claro, Movistar y CNT, El Valle + 5 km (198 estructuras) | dist_rb4g_km, n_rb4g_2km, calibración del etiquetado |
comunidades_pdot | puntos | 49 comunidades con habitantes y área | poblacion, densidad_hab_km2, pob_sin_4g |
escuelas, salud | puntos | equipamientos de OSM y el PDOT | n_escuelas, n_salud |
urbanizaciones | polígonos | conjuntos de viviendas | n_urbanizaciones |
vias | líneas | red vial de OSM | dist_via_m |
hidrografia | líneas y polígonos | ríos, quebradas y lagunas de OSM (Metodologia/Topografia El Valle/hidrografia_*_el_valle_utm17s.shp) | exclusion |
areas_protegidas | polígonos | fuente pendiente de definir; si ninguna toca la zona, capa vacía | exclusion |
curvas_nivel_10m, curvas_nivel_25m | líneas | curvas de nivel | mapas y revisión |
DEM_30M | ráster | alturas cada 30 m (copia de dem_el_valle_utm17s_30m.tif) | alt_media_m, alt_max_m, pendiente_deg, tpi_m, visibilidad_km2 y la pendiente de exclusion |
SUELO_30M | ráster | clase de suelo remuestreada a 30 m (copia de suelo_clutter_30m.tif, de 1.5) | uso_suelo, conductividad_S_m |
Nombres de capa propuestos. Las áreas, el número de comunidades y los tramos de
hidrografía son datos reales. Los rásteres se guardan dentro del GeoPackage como copia; la Etapa 2
puede leerlos igual desde sus GeoTIFF (dem_el_valle_utm17s_30m.tif y
suelo_clutter_30m.tif), que tienen los mismos píxeles. La tabla
conductividad_por_clase.csv (clase, sigma_S_m, eps_r) acompaña al
ráster de suelo.
import shutil
import pandas as pd
import geopandas as gpd
import rasterio
import rasterio.shutil
TOPO = "Metodologia/Topografia El Valle/"
BORRADOR = "el_valle_etapa1_borrador.gpkg" # lo escribe 1.5 y lo revisa el rombo
GPKG = "el_valle_etapa1.gpkg" # Producto 1
# Al final de 1.5, cada capa se escribe en el borrador: una llamada por capa, todas en el
# mismo archivo. bandas_mv, bandas_cl y bandas_cn vienen del código de 1.5 y comunidades, de 1.2.
limite = gpd.read_file(TOPO + "limite_el_valle_osm_utm17s.shp") # EPSG:32717
limite.to_file(BORRADOR, layer="limite_el_valle", driver="GPKG")
bandas_mv.to_file(BORRADOR, layer="cobertura4g_movistar", driver="GPKG")
bandas_cl.to_file(BORRADOR, layer="cobertura4g_claro", driver="GPKG")
bandas_cn.to_file(BORRADOR, layer="cobertura4g_cnt", driver="GPKG")
comunidades.to_file(BORRADOR, layer="comunidades_pdot", driver="GPKG")
hidro = pd.concat([gpd.read_file(TOPO + "hidrografia_lineas_el_valle_utm17s.shp"),
gpd.read_file(TOPO + "hidrografia_poligonos_el_valle_utm17s.shp")],
ignore_index=True) # 29 líneas y 2 lagunas
hidro.to_file(BORRADOR, layer="hidrografia", driver="GPKG")
# ... radiobases_lte, escuelas, salud, urbanizaciones, vias, areas_protegidas, curvas de nivel
# Rásteres dentro del mismo GeoPackage (copias de los GeoTIFF)
rasterio.shutil.copy(TOPO + "dem_el_valle_utm17s_30m.tif", BORRADOR,
driver="GPKG", RASTER_TABLE="DEM_30M", APPEND_SUBDATASET="YES")
rasterio.shutil.copy("suelo_clutter_30m.tif", BORRADOR,
driver="GPKG", RASTER_TABLE="SUELO_30M", APPEND_SUBDATASET="YES")
# Si el rombo responde "Sí", el borrador aprobado pasa a ser el Producto 1
shutil.copy(BORRADOR, GPKG)
print(gpd.list_layers(GPKG)) # las capas vectoriales y su geometría
with rasterio.open(GPKG, table="DEM_30M") as dem:
print(dem.crs, dem.res) # EPSG:32717 (30.0, 30.0)
el_valle_etapa1_borrador.gpkg), una vez aprobado por el rombo "¿Capas consistentes y completas?".el_valle_etapa1.gpkg, que inicia la Etapa 2 en 2.1 (diagnóstico de la cobertura 4G).Aquí los mapas y tablas de la Etapa 1 se convierten en lo único que un modelo de inteligencia artificial sabe leer: una tabla. Primero se mide dónde falta 4G en El Valle y cuánta gente vive ahí. Después se corta la parroquia en cuadrados (celdas) y cada cuadrado se describe con 19 números y una etiqueta: 1 si es un buen sitio para una radiobase, 0 si no lo es.
Un algoritmo como Random Forest no "mira" un mapa: aprende de filas y columnas. Por eso el territorio se trata como una hoja de cálculo gigante. Cada fila es un pedazo de terreno y cada columna es algo que se puede medir en él: cuánta señal 4G le llega, qué tan alto y empinado es, cuánta gente vive ahí, si tiene una vía cerca... Con esa tabla, en la Etapa 3 el modelo aprende qué combinación de valores tiene un buen sitio.
| Grupo | N.º | Columnas de la tabla |
|---|---|---|
| Cobertura 4G | 4 | deficit_4g, clase_4g, dist_rb4g_km, n_rb4g_2km |
| Topografía | 5 | alt_media_m, alt_max_m, pendiente_deg, tpi_m, visibilidad_km2 |
| Beneficio social | 6 | poblacion, densidad_hab_km2, pob_sin_4g, n_escuelas, n_salud, n_urbanizaciones |
| Viabilidad | 4 | dist_via_m, uso_suelo, conductividad_S_m, exclusion |
| Total | 19 |
Cada una de las tres tablas del Producto 2 tiene esta forma (aquí, tres celdas de la malla de 250 m):
| id_celda | x_utm | y_utm | deficit_4g | alt_media_m | pendiente_deg | poblacion | dist_via_m | … | etiqueta |
|---|---|---|---|---|---|---|---|---|---|
| C0421 | 727 375 | 9 675 125 | 0.85 | 2 635 | 12.4 | 145 | 180 | … | 1 |
| C0422 | 727 625 | 9 675 125 | 0.10 | 2 643 | 4.1 | 380 | 60 | … | 0 |
| C0517 | 727 625 | 9 670 375 | 1.00 | 3 010 | 31.7 | 0 | 1 250 | … | 0 |
x_utm e y_utm son el centro de la celda, en metros: son
centros de celdas de 250 m dentro de El Valle, y las altitudes medias salen del DEM Copernicus en
esas celdas. El resto de valores es ilustrativo: sirven para ver la forma de la tabla.
for lado in (100, 250, 500): # 2.3: tres mallas
malla = crear_malla(limite, lado) # celdas de lado × lado metros
malla = calcular_variables(malla) # 2.4: 19 columnas por celda
malla = explorar_y_depurar(malla) # 2.5 y 2.6: fuera redundancias
perfil = perfil_sitio_viable(radiobases_4g, lado) # 2.7: umbrales de las radiobases reales
tabla = etiquetar(malla, perfil) # 2.7: una sola etiqueta, 1 o 0
tabla.to_parquet(f"tabla_{lado}m.parquet") # Producto 2: 3 archivos
Antes de proponer radiobases nuevas hay que saber cómo está El Valle hoy. Este paso responde con números: de los 43.03 km² de la parroquia, ¿cuántos tienen señal 4G alta, media o baja, y cuántos no tienen servicio? Se calcula por separado para Claro, Movistar y CNT.
THRESHOLD:
−140, −120 y −100; el de −140 contiene a los otros dos), Claro entrega bandas
disjuntas (columna Float) y CNT entrega cuatro polígonos
anidados: alto, medio, bajo y uno de "Sin cobertura 4G", que se descarta. Su
columna NIV_SE habla de rangos exclusivos, pero las geometrías se contienen unas a
otras. Si se midiera sin normalizar, el área de Movistar y de CNT se contaría dos o tres veces.
Por eso este paso usa las bandas del paso 1.5. Los mapas no tienen la misma fecha: Claro es de
junio de 2026 y Movistar del semestre I de 2026, pero el de CNT es de enero de 2025. El mapa 4G de Movistar
ya sugiere (Introducción de la memoria) que la señal alta se concentra en el norte y el centro de
El Valle, y que el sur, el este y la periferia tienen señal baja o media y amplias zonas sin
servicio. Este paso le pone cifras a esa observación.Potencia en dBm a partir de la potencia \(P\) en milivatios:
$$P_{\text{dBm}} = 10\,\log_{10}\!\left(\frac{P}{1\ \text{mW}}\right) \qquad -120\ \text{dBm} = 10^{-12}\ \text{mW} = 10^{-15}\ \text{W}$$Área y porcentaje de cada nivel \(n\) de la operadora \(o\), donde \(B_{o,n}\) es el polígono de la banda y \(V\) es El Valle:
$$A_{o,n} = \frac{\text{área}\big(B_{o,n} \cap V\big)}{10^{6}}\ \text{km}^2 \qquad \%_{o,n} = 100 \cdot \frac{A_{o,n}}{43.03}$$ $$A_{o,\ \text{sin servicio}} = 43.03 - \big(A_{o,\text{alta}} + A_{o,\text{media}} + A_{o,\text{baja}}\big)$$Como las bandas no se solapan, las cuatro áreas de cada operadora suman exactamente 43.03 km² y los porcentajes, 100 %. Si no suman eso, algo falla: bandas solapadas o geometrías inválidas.
| Nivel | Movistar (km²) | Movistar (%) | Claro (km²) | Claro (%) | CNT (km²) | CNT (%) |
|---|---|---|---|---|---|---|
| Alta | 6.45 | 15.0 | 2.15 | 5.0 | 5.16 | 12.0 |
| Media | 12.91 | 30.0 | 4.30 | 10.0 | 13.77 | 32.0 |
| Baja | 4.30 | 10.0 | 9.47 | 22.0 | 14.20 | 33.0 |
| Sin servicio | 19.37 | 45.0 | 27.11 | 63.0 | 9.90 | 23.0 |
| Total | 43.03 | 100.0 | 43.03 | 100.0 | 43.03 | 100.0 |
Valores ilustrativos: muestran la forma del resultado. Las cifras reales salen de este paso. La barra gris es la parte sin servicio.
import geopandas as gpd
import pandas as pd
GPKG = "el_valle_etapa1.gpkg" # Producto 1 (EPSG:32717)
valle = gpd.read_file(GPKG, layer="limite_el_valle")
A_VALLE = valle.area.sum() / 1e6 # 43.03 km²
filas = []
for op in ["movistar", "claro", "cnt"]:
bandas = gpd.read_file(GPKG, layer=f"cobertura4g_{op}") # 3 filas: nivel = alta, media, baja
dentro = gpd.overlay(bandas, valle[["geometry"]], how="intersection")
area = dentro.dissolve(by="nivel").area / 1e6 # km² por nivel
area["sin servicio"] = A_VALLE - area.sum()
for nivel, km2 in area.items():
filas.append({"operadora": op, "nivel": nivel,
"area_km2": round(km2, 2), "pct": round(100 * km2 / A_VALLE, 1)})
diagnostico = pd.DataFrame(filas)
print(diagnostico.pivot(index="nivel", columns="operadora", values="pct"))
diagnostico| operadora | nivel | area_km2 | pct |
|---|---|---|---|
| movistar | alta | 6.45 | 15.0 |
| movistar | media | 12.91 | 30.0 |
| … | … | … | … |
| cnt | sin servicio | 9.90 | 23.0 |
12 filas: 3 operadoras × 4 niveles. Valores ilustrativos.
El déficit 4G es la parte de El Valle donde la señal no alcanza: RSRP menor que −120 dBm (nivel bajo) o sin servicio. Aquí se dibuja esa zona en el mapa y se cuenta cuántas personas de cada comunidad viven dentro de ella.
deficit_4g.deficit_4g (2.4) es la fracción de
cada celda dentro de esta zona; la regla de etiquetado (2.7) y el filtro 3.10 piden déficit ≥ 50 %; y la
Etapa 4 mide con Radio Mobile cuánta área pasa a tener RSRP ≥ −120 dBm. Tomar como déficit la zona
donde ninguna operadora llega bien es una propuesta de partida: también podría trabajarse
con el déficit de una sola operadora. Con los mapas actuales, esa zona ocupa el 36.7 % de El Valle
(con solo Claro y Movistar sería el 39.2 %).Zona suficiente \(S_o\) y déficit \(D_o\) de la operadora \(o\), y déficit 4G de la parroquia \(D\), con \(V\) = El Valle:
$$S_o = B_{o,\text{alta}} \cup B_{o,\text{media}} \qquad (\text{RSRP} \geq -120\ \text{dBm})$$ $$D_o = V \setminus S_o \qquad D = V \setminus \big(S_{\text{Movistar}} \cup S_{\text{Claro}} \cup S_{\text{CNT}}\big) = D_{\text{Movistar}} \cap D_{\text{Claro}} \cap D_{\text{CNT}}$$Población afectada de la comunidad \(k\), con \(P_k\) su población (PDOT) y \(C_k\) su círculo de área equivalente:
$$P^{\,\text{déficit}}_k = P_k \cdot \frac{A(C_k \cap D)}{A(C_k \cap V)}$$| Punto | Movistar | Claro | CNT | ¿Déficit 4G? |
|---|---|---|---|---|
| 1 | media | baja | baja | No: Movistar llega con ≥ −120 dBm |
| 2 | baja | sin servicio | baja | Sí |
| 3 | sin servicio | alta | sin servicio | No: Claro llega con ≥ −100 dBm |
| 4 | sin servicio | baja | media | No: CNT llega con ≥ −120 dBm |
| 5 | sin servicio | sin servicio | sin servicio | Sí |
La Gloria Baja tiene 131 habitantes y 20.35 ha según el PDOT. Su círculo de área equivalente tiene radio \(r = \sqrt{203\,500 / \pi} \approx 254.5\) m. Si 8.14 ha de ese círculo (el 40 %) cayeran en la zona de déficit:
\(P^{\,\text{déficit}} = 131 \times \dfrac{8.14}{20.35} = 131 \times 0.40 \approx 52\) personas afectadas.
| Comunidad | Habitantes (PDOT) | Círculo en déficit | Afectados |
|---|---|---|---|
| Chilcapamba | 1 736 | 10 % | 174 |
| Cochapamba | 1 389 | 35 % | 486 |
| El Valle Centro | 1 362 | 5 % | 68 |
| Quillopungo | 1 192 | 60 % | 715 |
| La Gloria Baja | 131 | 40 % | 52 |
Habitantes, áreas y radio: datos reales del PDOT de El Valle. Los porcentajes en déficit son ilustrativos.
import numpy as np
import pandas as pd
import geopandas as gpd
GPKG = "el_valle_etapa1.gpkg" # Producto 1
V = gpd.read_file(GPKG, layer="limite_el_valle").union_all()
# 1) Zona con señal suficiente de cada operadora (alta + media: RSRP ≥ −120 dBm)
suficiente = []
for op in ["movistar", "claro", "cnt"]:
b = gpd.read_file(GPKG, layer=f"cobertura4g_{op}")
suficiente.append(b[b["nivel"].isin(["alta", "media"])].union_all())
# 2) Déficit 4G de la parroquia: donde ninguna de las tres llega bien
deficit = V.difference(gpd.GeoSeries(suficiente).union_all())
print(f"Déficit 4G: {deficit.area / 1e6:.2f} km² ({100 * deficit.area / V.area:.1f} %)")
gpd.GeoDataFrame(geometry=[deficit], crs=32717).to_file(GPKG, layer="deficit_4g")
# 3) Población afectada por comunidad (círculo de área equivalente)
com = pd.read_csv("poblacion_pdot_el_valle.csv") # 1.2: comunidad, x_utm, y_utm, habitantes, area_ha
r = np.sqrt(com["area_ha"] * 10_000 / np.pi)
circ = gpd.GeoSeries(gpd.points_from_xy(com["x_utm"], com["y_utm"]), crs=32717).buffer(r.to_numpy())
com["hab_sin_4g"] = com["habitantes"] * circ.intersection(deficit).area / circ.intersection(V).area
print(com.sort_values("hab_sin_4g", ascending=False).head(10))
com| comunidad | x_utm | y_utm | habitantes | area_ha | hab_sin_4g |
|---|---|---|---|---|---|
| Quillopungo | 724 806.19 | 9 673 252.31 | 1 192 | 243.21 | 715.2 |
| Cochapamba | 726 860.13 | 9 674 095.26 | 1 389 | 113.96 | 486.2 |
| La Gloria Baja | 726 678.56 | 9 674 886.35 | 131 | 20.35 | 52.4 |
Coordenadas, habitantes y áreas: datos reales del PDOT (Tablas 4-50 y 4-51). La
columna hab_sin_4g es ilustrativa.
deficit_4g (zona con RSRP < −120 dBm o sin servicio) y
la población afectada de cada comunidad. Pasa a 2.3.Para que el modelo pueda evaluar "cualquier lugar" de El Valle, la parroquia se cubre con cuadrados iguales, como un tablero de ajedrez puesto sobre el mapa. Cada cuadrado es un posible sitio para una radiobase. Se hacen tres tableros, con cuadrados de 100, 250 y 500 m, para ver qué tamaño funciona mejor.
id_celda) y se guardan
las coordenadas de su centro (x_utm, y_utm).\(n\) es el número aproximado de celdas y \(d_{\max}\) la distancia del centro a una esquina: el error máximo de decir "el sitio está en el centro de la celda".
| Malla | Área de una celda | Celdas (≈ 43.03 / ℓ²) | Píxeles del DEM por celda | Error máx. de ubicación | Antecedente |
|---|---|---|---|---|---|
| 100 m | 1 ha | 4 303 | ≈ 11 | 71 m | Niu et al. |
| 250 m | 6.25 ha | 688 | ≈ 69 | 177 m | Sakti et al. |
| 500 m | 25 ha | 172 | ≈ 278 | 354 m | Zhang et al. |
Píxeles del DEM, en promedio: \(\ell^2 / 30^2\). Por ejemplo, \(250^2 / 900 \approx 69\). Una celda concreta solo puede tener ciertos valores, porque por cada eje caben 3 o 4 centros de píxel en 100 m (8 o 9 en 250 m, 16 o 17 en 500 m): 9, 12 o 16 píxeles en la malla de 100 m; 64, 72 u 81 en la de 250 m; 256, 272 o 289 en la de 500 m.
import numpy as np
import geopandas as gpd
from shapely.geometry import box
V = gpd.read_file("el_valle_etapa1.gpkg", layer="limite_el_valle").union_all() # EPSG:32717
def crear_malla(V, lado):
xmin, ymin, xmax, ymax = V.bounds
x0 = np.floor(xmin / lado) * lado # origen alineado a múltiplos del lado
y0 = np.floor(ymin / lado) * lado
celdas = [box(x, y, x + lado, y + lado)
for x in np.arange(x0, xmax, lado)
for y in np.arange(y0, ymax, lado)]
malla = gpd.GeoDataFrame(geometry=celdas, crs=32717)
malla = malla[malla.centroid.within(V)].reset_index(drop=True) # centro dentro de El Valle
malla["x_utm"] = malla.centroid.x
malla["y_utm"] = malla.centroid.y
malla.insert(0, "id_celda", [f"C{i:04d}" for i in range(len(malla))])
return malla
for lado in (100, 250, 500):
malla = crear_malla(V, lado)
malla.to_file(f"malla_{lado}m.gpkg", driver="GPKG")
print(lado, len(malla))
# 100 4294
# 250 686
# 500 170
La salida es un cálculo preliminar con el límite de OSM: 4 294, 686 y 170 celdas, muy cerca de \(A_V/\ell^2\). Si se conservaran todas las celdas que tocan el límite serían 4 519, 775 y 213: la diferencia son celdas del borde que casi no tienen parroquia.
| id_celda | x_utm | y_utm | geometry |
|---|---|---|---|
| C2107 | 725 150 | 9 676 250 | POLYGON ((725100 9676200, 725200 9676200, …)) |
| C2108 | 725 150 | 9 676 350 | POLYGON ((725100 9676300, 725200 9676300, …)) |
Dos celdas ilustrativas de la malla de 100 m. Por ahora la tabla solo tiene la identidad y la forma de cada celda; las 19 variables se agregan en 2.4.
malla_100m, malla_250m y
malla_500m.La malla más fina: cuadrados de 100 m de lado, es decir, de una hectárea (casi una cancha y media de fútbol). Señala el sitio con mucha precisión, pero cada celda tiene poca información dentro y son muchas filas.
| Dato | Valor | De dónde sale |
|---|---|---|
| Área de una celda | 1 ha = 0.01 km² | \(100 \times 100\) m |
| Celdas | ≈ 4 303 | \(43.03 / 0.01\) |
| Píxeles del DEM (30 m) por celda | 9 a 16 (≈ 11) | \(100^2 / 30^2 = 11.1\) |
| Error máximo de ubicación | 70.7 m | \(\tfrac{\sqrt{2}}{2} \times 100\) |
| Celdas por bloque de 1 km (paso 2.8) | 100 | \((1000/100)^2\) |
| Habitantes promedio por celda | ≈ 5.7 | \(24\,314 / 4\,303\) |
| Celdas que toca el círculo de Chilcapamba | 83 | cálculo preliminar |
| Filas de entrenamiento / prueba | ≈ 3 012 / 1 291 | 70 % / 30 % de todas las celdas |
El conteo de Chilcapamba es un cálculo preliminar con los datos reales del PDOT y una malla alineada a múltiplos de 100 m. Las filas de entrenamiento y prueba siguen la regla de scikit-learn: el 30 % de prueba se redondea hacia arriba.
import pandas as pd
from rasterstats import zonal_stats
malla_100 = crear_malla(V, 100) # función del paso 2.3
px = zonal_stats(malla_100.geometry, "dem_el_valle_utm17s_30m.tif", stats=["count"])
print(pd.Series([p["count"] for p in px]).describe()) # entre 9 y 16 píxeles por celda
malla_100m.gpkg: unas 4 300 celdas, que dan la tabla
tabla_100m.parquet del Producto 2.La opción intermedia: cuadrados de 250 m de lado (6.25 ha). Cada celda resume bastante terreno para que sus variables sean estables, y todavía señala una zona pequeña.
| Dato | Valor | De dónde sale |
|---|---|---|
| Área de una celda | 6.25 ha = 0.0625 km² | \(250 \times 250\) m |
| Celdas | ≈ 688 | \(43.03 / 0.0625\) |
| Píxeles del DEM (30 m) por celda | 64 a 81 (≈ 69) | \(250^2 / 30^2 = 69.4\) |
| Error máximo de ubicación | 176.8 m | \(\tfrac{\sqrt{2}}{2} \times 250\) |
| Celdas por bloque de 1 km (paso 2.8) | 16 | \((1000/250)^2\) |
| Habitantes promedio por celda | ≈ 35.3 | \(24\,314 / 688\) |
| Celdas que toca el círculo de Chilcapamba | 18 | cálculo preliminar |
| Filas de entrenamiento / prueba | ≈ 481 / 207 | 70 % / 30 % de todas las celdas |
¿Por qué 171.7 habitantes? Una celda de 250 m que cae entera dentro del círculo de Chilcapamba recibe la fracción de la comunidad que corresponde a su área: \(1\,736 \times \dfrac{6.25\ \text{ha}}{63.21\ \text{ha}} = 171.7\).
Datos reales del PDOT; el conteo de celdas es un cálculo preliminar con una malla alineada a múltiplos de 250 m.
malla_250 = crear_malla(V, 250) # función del paso 2.3
# Bloque de 1 km al que pertenece cada celda (se usará en la validación espacial, 2.8)
bloque = ((malla_250["x_utm"] // 1000).astype(int).astype(str) + "_" +
(malla_250["y_utm"] // 1000).astype(int).astype(str))
print(len(malla_250), bloque.nunique(), bloque.value_counts().max()) # como mucho 16 por bloque
malla_250m.gpkg: unas 690 celdas, que dan la tabla
tabla_250m.parquet del Producto 2.La malla más gruesa: cuadrados de 500 m de lado (25 ha). Cada celda resume mucho terreno, así que sus números son muy estables, pero apunta a una zona amplia y deja pocas filas para aprender.
| Dato | Valor | De dónde sale |
|---|---|---|
| Área de una celda | 25 ha = 0.25 km² | \(500 \times 500\) m |
| Celdas | ≈ 172 | \(43.03 / 0.25\) |
| Píxeles del DEM (30 m) por celda | 256 a 289 (≈ 278) | \(500^2 / 30^2 = 277.8\) |
| Error máximo de ubicación | 353.6 m | \(\tfrac{\sqrt{2}}{2} \times 500\) |
| Celdas por bloque de 1 km (paso 2.8) | 4 | \((1000/500)^2\) |
| Habitantes promedio por celda | ≈ 141.4 | \(24\,314 / 172\) |
| Celdas que toca el círculo de Chilcapamba | 8 | cálculo preliminar |
| Filas de entrenamiento / prueba | ≈ 120 / 52 | 70 % / 30 % de todas las celdas |
deficit_4g toma muchos valores intermedios (0.3, 0.6...) y
menos celdas llegan al 50 % que pide la regla de etiquetado; y el sitio propuesto puede quedar
hasta 354 m del mejor punto real de la celda.import geopandas as gpd
malla_500 = crear_malla(V, 500) # función del paso 2.3
malla_100 = crear_malla(V, 100)
# ¿En qué celda de 500 m cae el centro de cada celda de 100 m?
hijas = gpd.GeoDataFrame(malla_100[["id_celda"]], geometry=malla_100.centroid, crs=32717)
madre = gpd.sjoin(hijas, malla_500[["id_celda", "geometry"]], predicate="within",
lsuffix="100", rsuffix="500")
print(madre.groupby("id_celda_500").size().value_counts()) # en el interior: 25 hijas por celda
malla_500m.gpkg: unas 170 celdas, que dan la tabla
tabla_500m.parquet del Producto 2.Cada celda recibe 19 números que la describen: cómo le llega el 4G, cómo es su relieve, cuánta gente y qué servicios hay, y si se puede construir ahí. Se hace con estadística zonal y operaciones entre capas, y se repite en las tres mallas: salen tres tablas.
Se "pasa" cada capa de la base de datos por encima de las celdas y se resume lo que cae dentro de cada una. Según el tipo de capa, el resumen es distinto:
| Operación | Qué hace | Variables | Herramienta |
|---|---|---|---|
| Estadística zonal (ráster) | media, máximo o moda de los píxeles de la celda (la media de un ráster 0/1 da una fracción) | alt_media_m, alt_max_m,pendiente_deg, tpi_m,uso_suelo, conductividad_S_m,exclusion | rasterstats |
| Proporción de área (vectorial) | qué fracción de la celda cubre un polígono | deficit_4g, clase_4g,poblacion, pob_sin_4g | geopandas.overlay |
| Conteo de puntos | cuántos elementos caen dentro de la celda | n_escuelas, n_salud,n_urbanizaciones | geopandas.sjoin |
| Distancia | del centro de la celda al elemento más cercano, o cuántos hay dentro de un radio | dist_rb4g_km, n_rb4g_2km,dist_via_m | distance, cKDTree |
| Cálculo desde el centro | cuenca visual desde una antena en la celda | visibilidad_km2 | GDAL (viewshed) |
| Cociente | una variable dividida para el área | densidad_hab_km2 | pandas |
id_celda, x_utm,
y_utm y la geometría.dem_el_valle_utm17s_30m.tif), la pendiente y el TPI que se derivan de él, y el
clutter de la SUPERTEL (60 m). Las capas vectoriales son las bandas 4G, la zona de déficit
(2.2), las radiobases 4G, los círculos de las 49 comunidades del PDOT, las escuelas, los centros de
salud, las urbanizaciones, las vías y las zonas de exclusión. Niu et al. también describieron cada
celda de su malla de 100 m con 19 variables. Esta representación en celdas es la que describe el
marco teórico (análisis de idoneidad con capas de un SIG, Malczewski), con una diferencia: aquí
los pesos de cada capa no los fija una persona, los aprende el modelo.Con \(P_c\) el conjunto de píxeles cuyo centro cae en la celda \(c\) y \(z_p\) el valor del píxel \(p\):
$$\bar{z}(c) = \frac{1}{|P_c|}\sum_{p \in P_c} z_p \qquad z_{\max}(c) = \max_{p \in P_c} z_p$$Proporción de la celda cubierta por una zona \(Z\) (por ejemplo, el déficit 4G):
$$f(c, Z) = \frac{A(c \cap Z)}{A(c)} \in [0, 1]$$Conteo de elementos \(Q\) en la celda y distancia desde el centro \(o_c\) al más cercano:
$$n(c) = \#\{\, q \in Q : q \in c \,\} \qquad d(c) = \min_{q \in Q} \lVert o_c - q \rVert$$El centro de 12 píxeles del DEM cae dentro de la celda (en una celda de 100 m caben 3 o 4 centros de píxel por eje, así que siempre son 9, 12 o 16). Sus altitudes (m): 2 701, 2 704, 2 706, 2 709, 2 703, 2 707, 2 711, 2 714, 2 708, 2 712, 2 716 y 2 710.
Suma = 32 501 → alt_media_m = 32 501 / 12 = 2 708.4 m;
alt_max_m = 2 716 m.
Altitudes ilustrativas, dentro del rango real de El Valle (2 482 a 3 103 m).
import geopandas as gpd
from rasterstats import zonal_stats
# Estadística zonal en su forma mínima: un diccionario por celda
malla = gpd.read_file("malla_100m.gpkg") # una malla del paso 2.3
z = zonal_stats(malla.geometry, "dem_el_valle_utm17s_30m.tif", stats=["count", "mean", "max"])
print(z[0]) # p. ej. {'count': 12, 'mean': 2708.42, 'max': 2716.0} (media redondeada)
VARIABLES = [
"deficit_4g", "clase_4g", "dist_rb4g_km", "n_rb4g_2km", # cobertura 4G
"alt_media_m", "alt_max_m", "pendiente_deg", "tpi_m", "visibilidad_km2", # topografía
"poblacion", "densidad_hab_km2", "pob_sin_4g",
"n_escuelas", "n_salud", "n_urbanizaciones", # beneficio social
"dist_via_m", "uso_suelo", "conductividad_S_m", "exclusion", # viabilidad
]
for lado in (100, 250, 500):
malla = gpd.read_file(f"malla_{lado}m.gpkg") # celdas del paso 2.3
malla = variables_cobertura(malla) # 4 columnas (ver "Cobertura 4G")
malla = variables_topografia(malla) # 5 columnas (ver "Topografía")
malla = variables_sociales(malla) # 6 columnas (ver "Beneficio social")
malla = variables_viabilidad(malla) # 4 columnas (ver "Viabilidad")
faltan = set(VARIABLES) - set(malla.columns)
assert not faltan, faltan # están las 19
malla.to_file(f"malla_{lado}m.gpkg", driver="GPKG") # tabla de esta malla: va a 2.5
print(lado, "m:", malla.shape)
| id_celda | x_utm | y_utm | deficit_4g | alt_media_m | pendiente_deg | poblacion | dist_via_m | exclusion | … |
|---|---|---|---|---|---|---|---|---|---|
| C0421 | 727 375 | 9 675 125 | 0.85 | 2 635 | 12.4 | 145.2 | 180 | 0 | … |
| C0422 | 727 625 | 9 675 125 | 0.10 | 2 643 | 4.1 | 380.6 | 60 | 0 | … |
Dos filas de la malla de 250 m: coordenadas y altitud media reales (DEM Copernicus); el resto, ilustrativo. Todavía no hay etiqueta: se agrega en 2.7.
Cuatro números que dicen cómo llega hoy el 4G a la celda: qué parte de ella tiene señal insuficiente, qué nivel de señal domina, a qué distancia está la radiobase 4G más cercana y cuántas hay en 2 km a la redonda.
deficit_4g (fracción baja +
sin servicio) y clase_4g (el nivel con más área).deficit_4gclase_4gdist_rb4g_kmn_rb4g_2kmCon \(D\) la zona de déficit (2.2), \(Z_n\) la zona donde el mejor nivel es \(n\), \(o_c\) el centro de la celda y \(R\) el conjunto de radiobases 4G:
$$\text{deficit\_4g}(c) = \frac{A(c \cap D)}{A(c)} = \frac{A(c \cap Z_0) + A(c \cap Z_1)}{A(c)}$$ $$\text{clase\_4g}(c) = \arg\max_{n\, \in\, \{0,1,2,3\}} A(c \cap Z_n)$$ $$\text{dist\_rb4g\_km}(c) = \frac{1}{1000}\,\min_{j \in R} \sqrt{(x_c - x_j)^2 + (y_c - y_j)^2}$$ $$\text{n\_rb4g\_2km}(c) = \#\{\, j \in R : d(o_c, j) \lt 2000\ \text{m} \,\}$$Una celda de 250 m (6.25 ha) queda así al cruzarla con el mapa combinado:
deficit_4g = (2.75 + 1.00) / 6.25 = 0.60clase_4g = 1 (baja: es el nivel con más área, 2.75 ha)Las radiobases 4G más cercanas a su centro están a 1.30, 1.90, 2.60 y 3.40 km:
dist_rb4g_km = 1.30n_rb4g_2km = 2 (las de 1.30 y 1.90 km)Valores ilustrativos.
import numpy as np
import geopandas as gpd
from scipy.spatial import cKDTree
GPKG = "el_valle_etapa1.gpkg" # Producto 1
NIVEL = {"alta": 3, "media": 2, "baja": 1} # 0 = sin servicio
mov = gpd.read_file(GPKG, layer="cobertura4g_movistar")
cla = gpd.read_file(GPKG, layer="cobertura4g_claro")
cnt = gpd.read_file(GPKG, layer="cobertura4g_cnt")
mov["n_mov"] = mov["nivel"].map(NIVEL)
cla["n_cla"] = cla["nivel"].map(NIVEL)
cnt["n_cnt"] = cnt["nivel"].map(NIVEL)
# 1) Mejor nivel de las tres operadoras en cada punto
mejor = gpd.overlay(mov[["n_mov", "geometry"]], cla[["n_cla", "geometry"]], how="union")
mejor = gpd.overlay(mejor, cnt[["n_cnt", "geometry"]], how="union")
mejor["n"] = mejor[["n_mov", "n_cla", "n_cnt"]].fillna(0).max(axis=1).astype(int)
# 2) Área de cada nivel dentro de cada celda
m = malla.set_index("id_celda")
trozos = gpd.overlay(malla[["id_celda", "geometry"]], mejor[["n", "geometry"]], how="intersection")
trozos["a"] = trozos.area
area = (trozos.pivot_table(index="id_celda", columns="n", values="a", aggfunc="sum")
.reindex(index=m.index, columns=[1, 2, 3]).fillna(0))
area[0] = (m.area - area.sum(axis=1)).clip(lower=0) # lo que no cubre ningún polígono
m["deficit_4g"] = (area[0] + area[1]) / m.area # RSRP < −120 dBm o sin servicio
m["clase_4g"] = area[[0, 1, 2, 3]].idxmax(axis=1) # nivel con más área
# 3) Radiobases 4G: sectores LTE de El Valle + 5 km (paso 1.3) → sitios
rb = gpd.read_file(GPKG, layer="radiobases_lte") # una fila por sector y banda
rb = rb.drop_duplicates(subset=["operadora", "cod_estructura"]) # una fila por sitio (estructura)
arbol = cKDTree(np.column_stack([rb.geometry.x, rb.geometry.y]))
centros = np.column_stack([m["x_utm"], m["y_utm"]])
d, _ = arbol.query(centros) # distancia en metros
m["dist_rb4g_km"] = d / 1000
m["n_rb4g_2km"] = [len(v) for v in arbol.query_ball_point(centros, r=2000)]
malla = m.reset_index()
| id_celda | deficit_4g | clase_4g | dist_rb4g_km | n_rb4g_2km |
|---|---|---|---|---|
| C0421 | 0.85 | 1 | 3.20 | 0 |
| C0422 | 0.10 | 3 | 3.00 | 0 |
| C0517 | 1.00 | 0 | 5.80 | 0 |
Valores ilustrativos. C0421 y C0422 son vecinas (sus centros están a 250 m), así que
su distancia a la radiobase 4G más cercana no puede diferir en más de 0.25 km.
deficit_4g y clase_4g miden casi lo mismo desde ángulos distintos: si
resultan casi iguales, el paso 2.5 lo detectará.
deficit_4g,
clase_4g, dist_rb4g_km y n_rb4g_2km.Cinco números que describen el relieve de la celda: qué tan alta es (en promedio y en su punto más alto), qué tan empinada, si es una loma o una hondonada, y cuánto territorio de El Valle se ve desde ahí.
En las frecuencias de 4G la señal viaja casi en línea recta: una loma entre la antena y el celular crea una "sombra" sin señal. Por eso las radiobases suelen ir en puntos altos, desde donde se domina la zona. A la vez, un terreno muy empinado encarece o impide la construcción. El marco teórico lo resume así: el terreno define si hay línea de vista entre la estación base y el usuario.
alt_media_malt_max_mpendiente_degtpi_mvisibilidad_km2Pendiente de cada píxel \((i, j)\) con diferencias centrales, \(\Delta = 30\) m:
$$\frac{\partial z}{\partial x} \approx \frac{z_{i,j+1} - z_{i,j-1}}{2\Delta} \qquad \frac{\partial z}{\partial y} \approx \frac{z_{i-1,j} - z_{i+1,j}}{2\Delta}$$ $$\theta = \frac{180}{\pi}\,\arctan\sqrt{\left(\frac{\partial z}{\partial x}\right)^{2} + \left(\frac{\partial z}{\partial y}\right)^{2}} \qquad \text{pendiente\_deg}(c) = \frac{1}{|P_c|}\sum_{p \in P_c} \theta_p$$TPI de cada píxel, con \(N_R(p)\) los píxeles a menos de \(R = 1\) km, y su media en la celda:
$$\text{TPI}(p) = z_p - \frac{1}{|N_R(p)|}\sum_{q \in N_R(p)} z_q \qquad \text{tpi\_m}(c) = \frac{1}{|P_c|}\sum_{p \in P_c} \text{TPI}(p)$$Visibilidad. Un píxel \(q\) se ve desde la antena en \(o\) (altura \(z_o + h\)) si ningún punto \(p\) intermedio del perfil queda por encima de la recta:
$$\frac{z_p - (z_o + h)}{d(o, p)} \lt \frac{(z_q + h_m) - (z_o + h)}{d(o, q)} \quad \text{para todo } p \text{ entre } o \text{ y } q$$ $$\text{visibilidad\_km2}(c) = \frac{900\ \text{m}^2 \times \#\{\, q \in V : q \text{ visible desde } o_c \,\}}{10^{6}}$$\(h\) = 30 m (antena), \(h_m\) = 1.5 m (celular). La curvatura de la Tierra baja el terreno lejano apenas unos 1.5 m a 5 km, casi nada, pero el cálculo la incluye.
Pendiente. Un píxel tiene al este 2 712 m y al oeste 2 700 m; al norte 2 715 m y al sur 2 706 m. Entonces \(\partial z/\partial x = 12/60 = 0.20\) y \(\partial z/\partial y = 9/60 = 0.15\). \(\sqrt{0.20^2 + 0.15^2} = 0.25\) y \(\arctan(0.25) = 14.0^\circ\): una ladera suave, dentro del perfil de sitio viable que se calibra en 2.7.
TPI. Una celda con altitud media de 2 790 m, rodeada por un vecindario que promedia 2 745 m: TPI = +45 m (loma). Otra con 2 610 m en un entorno de 2 650 m: TPI = −40 m (hondonada).
Visibilidad. Si desde la antena se ven 12 400 píxeles de El Valle: \(12\,400 \times 900 = 11\,160\,000\ \text{m}^2 = 11.16\ \text{km}^2\), el 26 % de la parroquia.
Valores ilustrativos.
import numpy as np
import geopandas as gpd
import rasterio
from rasterio.features import geometry_mask
from rasterstats import zonal_stats
from scipy.ndimage import convolve
from osgeo import gdal
DEM = "dem_el_valle_utm17s_30m.tif" # Copernicus GLO-30, EPSG:32717, El Valle + 1.5 km
# 1) Altitud media y máxima: estadística zonal directa
z = zonal_stats(malla.geometry, DEM, stats=["mean", "max"]) # usa el nodata (-9999) del archivo
malla["alt_media_m"] = [s["mean"] for s in z]
malla["alt_max_m"] = [s["max"] for s in z]
# 2) Rásteres derivados: pendiente (grados) y TPI (m)
with rasterio.open(DEM) as src:
dem = src.read(1).astype("float64")
dem[dem == src.nodata] = np.nan
afin, paso = src.transform, src.res[0] # paso = 30 m
dz_dy, dz_dx = np.gradient(dem, paso) # diferencias centrales
pendiente = np.degrees(np.arctan(np.hypot(dz_dx, dz_dy)))
R = 1000 # radio del vecindario (valor de partida)
n = int(R // paso)
yy, xx = np.mgrid[-n:n + 1, -n:n + 1]
disco = ((xx**2 + yy**2) * paso**2 <= R**2).astype(float)
suma = convolve(np.nan_to_num(dem), disco, mode="constant")
cuenta = convolve((~np.isnan(dem)).astype(float), disco, mode="constant")
tpi = dem - suma / cuenta # positivo = loma
for col, capa in [("pendiente_deg", pendiente), ("tpi_m", tpi)]:
z = zonal_stats(malla.geometry, capa, affine=afin, stats=["mean"], nodata=np.nan)
malla[col] = [s["mean"] for s in z]
# 3) Área visible (cuenca visual) desde una antena a 30 m en el centro de la celda
V = gpd.read_file("el_valle_etapa1.gpkg", layer="limite_el_valle").union_all()
dentro = geometry_mask([V], out_shape=dem.shape, transform=afin, invert=True)
gdal.UseExceptions()
banda = gdal.Open(DEM).GetRasterBand(1)
def area_visible(x, y, h_antena=30.0, h_movil=1.5):
vs = gdal.ViewshedGenerate(banda, "MEM", "", [], x, y, h_antena, h_movil,
255, 0, 0, 0, # visible, oculto, fuera de rango, sin dato
0.75, # curvatura con Tierra de radio 4/3 (ondas de radio)
gdal.GVM_Edge, 20000) # 20 km: cubre todo el DEM
visible = vs.GetRasterBand(1).ReadAsArray() == 255
return (visible & dentro).sum() * paso**2 / 1e6 # km² visibles dentro de El Valle
malla["visibilidad_km2"] = [area_visible(x, y) for x, y in zip(malla["x_utm"], malla["y_utm"])]
La cuenca visual es la variable más lenta: hay que calcularla una vez por celda (unas 4 300 veces en la malla de 100 m).
| id_celda | alt_media_m | alt_max_m | pendiente_deg | tpi_m | visibilidad_km2 |
|---|---|---|---|---|---|
| C0421 | 2 635.6 | 2 656 | 12.4 | 7.2 | 11.16 |
| C0422 | 2 643.9 | 2 665 | 4.1 | 8.4 | 2.35 |
| C0517 | 3 009.6 | 3 036 | 31.7 | 72.2 | 19.02 |
Altitudes y TPI: calculados con el DEM Copernicus en esas celdas de 250 m (TPI con vecindario de 1 km). Pendiente y visibilidad: ilustrativas. C0517 es una loma alta y ve mucho, pero su pendiente de 31.7° hace difícil construir: por eso importan todas las variables juntas y no una sola.
alt_media_m, alt_max_m,
pendiente_deg, tpi_m y visibilidad_km2. El ráster de
pendiente se reutiliza en "Viabilidad".Seis números que miden a cuánta gente serviría una radiobase en la celda: cuántas personas viven ahí y con qué densidad, cuántas de ellas no tienen buen 4G, y cuántas escuelas, centros de salud y urbanizaciones hay.
El PDOT de El Valle da la población de 49 comunidades, pero no trae sus polígonos en formato SIG. De cada comunidad se sabe solo dónde está (las coordenadas X, Y de la Tabla 4-50), cuánta gente tiene y cuánto mide (el área de la Tabla 4-51). En cambio, el modelo necesita la población de cada celda. La solución es dibujar cada comunidad como un círculo de área equivalente y repartir su gente entre las celdas en proporción al área (ponderación por área).
poblaciondensidad_hab_km2pob_sin_4gpoblacion.n_escuelas, n_saludamenity=school,
amenity=clinic) y del PDOT.n_urbanizacionesRadio del círculo de área equivalente de la comunidad \(k\), con \(A_k\) su área en m²:
$$\pi r_k^{2} = A_k \quad\Rightarrow\quad r_k = \sqrt{\frac{A_k}{\pi}}$$Población de la celda \(c\), sumando lo que le aporta cada círculo \(C_k\). El denominador es la parte del círculo que cae sobre la malla \(M\) (normalización propuesta):
$$\text{poblacion}(c) = \sum_{k=1}^{49} P_k \cdot \frac{A(C_k \cap c)}{A(C_k \cap M)}$$Así, cada comunidad reparte el 100 % de su gente entre las celdas y se cumple:
$$\sum_{c} \text{poblacion}(c) = \sum_{k=1}^{49} P_k = 24\,314$$Densidad, población sin 4G (con \(D\) la zona de déficit) y conteos (con \(E\) el conjunto de escuelas):
$$\text{densidad\_hab\_km2}(c) = \frac{\text{poblacion}(c)}{A(c)\ [\text{km}^2]} \qquad \text{pob\_sin\_4g}(c) = \sum_{k} P_k \cdot \frac{A(C_k \cap c \cap D)}{A(C_k \cap M)}$$ $$\text{n\_escuelas}(c) = \#\{\, e \in E : e \in c \,\}$$Si la gente se repartiera pareja dentro de la celda, \(\text{pob\_sin\_4g} \approx \text{poblacion} \times \text{deficit\_4g}\). La fórmula de arriba es más exacta porque recorta cada pedazo de círculo con la zona de déficit.
| Comunidad | Habitantes | Área (ha) | Radio (m) | Densidad (hab/ha) |
|---|---|---|---|---|
| Chilcapamba | 1 736 | 63.21 | 448.6 | 27.46 |
| Cochapamba | 1 389 | 113.96 | 602.3 | 12.19 |
| Los Geranios | 1 020 | 489.63 | 1 248.4 | 2.08 |
| La Gloria Baja | 131 | 20.35 | 254.5 | 6.44 |
| Maluay | 115 | 8.96 | 168.9 | 12.83 |
Habitantes, área y densidad: datos reales del PDOT (Tablas 4-50 y 4-51). El radio se calcula con \(r = \sqrt{A/\pi}\); por ejemplo, Chilcapamba: \(\sqrt{632\,100/\pi} = 448.6\) m. Los radios van de 168.9 m (Maluay) a 1 248.4 m (Los Geranios). El círculo conserva la densidad del PDOT: \(1\,736 / 63.21 = 27.46\) hab/ha.
La Gloria Baja: X = 726 678.56, Y = 9 674 886.35, 131 habitantes, 20.35 ha → \(r = 254.5\) m. Su círculo cae sobre 4 celdas de 500 m (se nombran por su esquina inferior izquierda):
| Celda (esquina X, Y) | Pedazo del círculo (ha) | Fracción | Habitantes que recibe |
|---|---|---|---|
| 726 500, 9 674 500 | 14.05 | 69.0 % | 90.4 |
| 726 500, 9 675 000 | 4.40 | 21.6 % | 28.3 |
| 726 000, 9 674 500 | 1.71 | 8.4 % | 11.0 |
| 726 000, 9 675 000 | 0.19 | 0.9 % | 1.2 |
| Total | 20.35 | 100 % | 131 |
Cálculo preliminar con los datos reales del PDOT y una malla alineada a múltiplos de 500 m. La celda resaltada contiene el centro de la comunidad y se lleva la mayor parte. Por ejemplo: \(131 \times 14.05 / 20.35 = 90.4\).
La celda de 250 m con esquina en (725 000, 9 676 000) toca cuatro círculos que se solapan: queda entera dentro de tres y en parte dentro del cuarto. Suma lo que le toca de cada uno:
poblacion = 171.7 + 39.2 + 3.3 + 6.6 = 220.8 habitantes, y
densidad_hab_km2 = 220.8 / 0.0625 = 3 533 hab/km².
Si el 20 % de esos pedazos cayera en la zona de déficit, pob_sin_4g ≈ 44.2.
Población: cálculo preliminar con los 49 círculos reales. El 20 % en déficit es ilustrativo.
Cifras de un cálculo preliminar con las 49 filas del PDOT y el límite de OSM.
import numpy as np
import pandas as pd
import geopandas as gpd
GPKG = "el_valle_etapa1.gpkg" # Producto 1
# 1) Un círculo de área equivalente por comunidad (49 filas del PDOT, paso 1.2)
com = pd.read_csv("poblacion_pdot_el_valle.csv") # comunidad, x_utm, y_utm, habitantes, area_ha
com["r_m"] = np.sqrt(com["area_ha"] * 10_000 / np.pi)
centros = gpd.GeoSeries(gpd.points_from_xy(com["x_utm"], com["y_utm"]), crs=32717)
circ = gpd.GeoDataFrame(com, geometry=centros.buffer(com["r_m"].to_numpy(), resolution=64))
# 2) Parte útil de cada círculo: la que cae sobre la malla (normalización)
M = malla.union_all()
circ["a_util"] = circ.intersection(M).area
# 3) Pedazos círculo ∩ celda y ponderación por área
trozos = gpd.overlay(malla[["id_celda", "geometry"]],
circ[["comunidad", "habitantes", "a_util", "geometry"]], how="intersection")
trozos["pob"] = trozos["habitantes"] * trozos.area / trozos["a_util"]
malla["poblacion"] = malla["id_celda"].map(trozos.groupby("id_celda")["pob"].sum()).fillna(0)
print(round(malla["poblacion"].sum())) # 24314: nadie se pierde ni se duplica
malla["densidad_hab_km2"] = malla["poblacion"] / (malla.area / 1e6)
# 4) Población sin cobertura 4G: los mismos pedazos, recortados con la zona de déficit (2.2)
deficit = gpd.read_file(GPKG, layer="deficit_4g")
sin4g = gpd.overlay(trozos, deficit[["geometry"]], how="intersection")
sin4g["pob"] = sin4g["habitantes"] * sin4g.area / sin4g["a_util"]
malla["pob_sin_4g"] = malla["id_celda"].map(sin4g.groupby("id_celda")["pob"].sum()).fillna(0)
# 5) Escuelas, centros de salud y urbanizaciones dentro de cada celda
for capa, col in [("escuelas", "n_escuelas"), ("salud", "n_salud"),
("urbanizaciones", "n_urbanizaciones")]:
pts = gpd.read_file(GPKG, layer=capa)
pts["geometry"] = pts.representative_point() # si es un polígono, un punto dentro
dentro = gpd.sjoin(pts[["geometry"]], malla[["id_celda", "geometry"]], predicate="within")
malla[col] = malla["id_celda"].map(dentro.groupby("id_celda").size()).fillna(0).astype(int)
| id_celda | poblacion | densidad_hab_km2 | pob_sin_4g | n_escuelas | n_salud | n_urbanizaciones |
|---|---|---|---|---|---|---|
| C0421 | 145.2 | 2 323 | 123.4 | 0 | 0 | 1 |
| C0422 | 380.6 | 6 090 | 38.1 | 1 | 1 | 2 |
| C0517 | 0.0 | 0 | 0.0 | 0 | 0 | 0 |
Valores ilustrativos de la malla de 250 m (área de celda 0.0625 km²).
poblacion, densidad_hab_km2,
pob_sin_4g, n_escuelas, n_salud y
n_urbanizaciones.Cuatro números que dicen si en la celda se puede construir de verdad una radiobase: qué tan lejos está la vía más cercana, qué hay sobre el suelo, qué conductividad tiene ese suelo y si la celda cae en una zona donde no se debe construir.
dist_via_muso_sueloEcuador_60m.geo) y la capa de clutter
(Ecuador_60m.sol), con píxeles de 60 m.conductividad_S_mexclusionMetodologia/Conductividad del suelo/Imagenes/) sugiere que en El Valle
predomina una sola clase, suelo abierto o cultivos. Si se confirma, uso_suelo y
conductividad_S_m casi no cambiarán de una celda a otra y el paso 2.5 lo mostrará.
Sakti et al. también incluyeron la cercanía a carreteras entre sus variables.Con \(o_c\) el centro de la celda, \(s_p\) la clase del píxel \(p\), \(\sigma(s)\) la conductividad de la clase \(s\) y \(X\) la unión de las zonas excluidas:
$$\text{dist\_via\_m}(c) = \min_{v\, \in\, \text{vías}} d(o_c, v)$$ $$\text{uso\_suelo}(c) = \operatorname{moda}\{\, s_p : p \in P_c \,\} \qquad \text{conductividad\_S\_m}(c) = \frac{1}{|P_c|}\sum_{p \in P_c} \sigma(s_p)$$ $$X = \text{agua} \cup \text{áreas protegidas} \cup \{\, p : \theta_p \gt 30^\circ \,\}$$ $$\text{exclusion}(c) = \begin{cases} 1 & \text{si } \dfrac{A(c \cap X)}{A(c)} \geq 0.5 \\ 0 & \text{si no} \end{cases}$$Los umbrales de 30° de pendiente y de la mitad de la celda (0.5) son valores de partida. En el código, \(X\) se pasa a un ráster de 0 y 1 con la rejilla del DEM, y la fracción \(A(c \cap X)/A(c)\) se calcula como la media de ese ráster en la celda.
Uso del suelo y conductividad. En una celda de 100 m caen 12 píxeles de la capa
remuestreada a 30 m: 9 de clase 0 (suelo abierto) y 3 de clase 5 (vegetación). La moda es la
clase 0, así que uso_suelo = 0. Si la clase 0 tuviera \(\sigma = 0.005\) S/m y
la clase 5, 0.010 S/m:
\(\text{conductividad} = \dfrac{9 \times 0.005 + 3 \times 0.010}{12} = \dfrac{0.075}{12} = 0.00625\)
S/m.
Exclusión. En una celda de 250 m caen 72 píxeles del DEM y 40 tienen más de 30°
de pendiente: \(40/72 = 0.56 \geq 0.5\), así que exclusion = 1.
Vía. Si el tramo de camino más cercano pasa a 180 m del centro,
dist_via_m = 180, aunque el extremo de ese tramo esté a 400 m.
Valores ilustrativos. Los códigos de clase 0 y 5 son los que aparecen en la lectura preliminar de la capa; la conductividad real de cada clase se asigna con la ITU-R P.527 (0.005 S/m es el valor por defecto de Radio Mobile).
import numpy as np
import pandas as pd
import geopandas as gpd
import rasterio
from rasterio.features import rasterize
from rasterstats import zonal_stats
GPKG = "el_valle_etapa1.gpkg" # Producto 1
centros = gpd.GeoSeries(gpd.points_from_xy(malla["x_utm"], malla["y_utm"]),
index=malla.index, crs=32717)
# 1) Distancia a la vía más cercana
vias = gpd.read_file(GPKG, layer="vias").union_all() # todas las vías en una sola geometría
malla["dist_via_m"] = centros.distance(vias) # distancia punto-línea, en metros
# 2) Uso del suelo: la clase que más se repite en la celda
SUELO = "suelo_clutter_30m.tif" # clutter SUPERTEL recortado y remuestreado en 1.5
z = zonal_stats(malla.geometry, SUELO, stats=["majority"])
malla["uso_suelo"] = [s["majority"] for s in z]
# 3) Conductividad: cada píxel toma la sigma de su clase y se promedia en la celda
tabla = pd.read_csv("conductividad_por_clase.csv") # clase, sigma_S_m, eps_r (ITU-R P.527)
with rasterio.open(SUELO) as src:
clase, afin = src.read(1), src.transform
sigma = np.full(clase.shape, np.nan)
for c, s in zip(tabla["clase"], tabla["sigma_S_m"]):
sigma[clase == c] = s
z = zonal_stats(malla.geometry, sigma, affine=afin, stats=["mean"], nodata=np.nan)
malla["conductividad_S_m"] = [s["mean"] for s in z]
# 4) Zona de exclusión: agua, áreas protegidas y pendiente extrema
with rasterio.open("dem_el_valle_utm17s_30m.tif") as src:
afin_dem, forma = src.transform, (src.height, src.width)
zonas = pd.concat([gpd.read_file(GPKG, layer="hidrografia").geometry,
gpd.read_file(GPKG, layer="areas_protegidas").geometry])
excl = rasterize(zonas, out_shape=forma, transform=afin_dem).astype(bool)
excl |= pendiente > 30 # ráster de pendiente de "Topografía"
f = zonal_stats(malla.geometry, excl.astype(float), affine=afin_dem, stats=["mean"], nodata=np.nan)
malla["exclusion"] = [int((s["mean"] or 0) >= 0.5) for s in f]
| id_celda | dist_via_m | uso_suelo | conductividad_S_m | exclusion |
|---|---|---|---|---|
| C0421 | 180 | 0 | 0.0050 | 0 |
| C0422 | 60 | 0 | 0.0063 | 0 |
| C0517 | 1 250 | 5 | 0.0100 | 1 |
Valores ilustrativos. La columna exclusion se vuelve a usar en el
filtro de candidatas de la Etapa 3 (3.10): una celda excluida nunca se recomienda, aunque tenga
buena nota.
dist_via_m, uso_suelo,
conductividad_S_m y exclusion. Con ellas se completan las 19
variables, que pasan a la exploración (2.5).Antes de enseñarle nada al modelo, se revisa cada tabla como quien revisa unos análisis de laboratorio: qué valores toma cada variable, si hay datos raros o vacíos y qué variables dicen prácticamente lo mismo.
Se toman las tres tablas del paso 2.4 (mallas de 100, 250 y 500 m, cada una con 19 columnas) y se revisan una por una. En este paso no se cambia nada: solo se diagnostica. Si aparece un error, se corrige en el cálculo de la variable y se vuelve a revisar.
uso_suelo). Esa matriz es la
que revisa el rombo siguiente.pendiente_deg o poblacion.NaN en pandas). Por
ejemplo, si una celda del borde queda muy pequeña al recortarla con el límite parroquial, puede
no contener el centro de ningún píxel del DEM y la estadística zonal le devuelve un vacío.
Ojo: vacío no es lo mismo que cero. Una celda sin habitantes tiene
poblacion = 0, no un faltante.uso_suelo, que es un código sin orden): son
\(18 \times 17 / 2 = 153\) pares, que se ordenan en una matriz de 18 × 18.clase_4g es un orden (0 sin servicio, 1 baja, 2 media, 3 alta), no una medida. El
Random Forest no necesita que las variables tengan forma de campana ni la misma escala, así que
esta exploración no sirve para transformar datos, sino para cazar errores y
detectar variables repetidas. Hay una excepción: uso_suelo es un
código de categoría sin orden (el número de la clase "bosque" no es "más" que el de
"cultivo"), así que se deja fuera de la matriz de Spearman.Spearman, cuando no hay empates, donde \(d_i\) es la diferencia entre los puestos de la celda \(i\) en las dos variables y \(n\) es el número de celdas:
$$\rho = 1 - \frac{6\sum_{i=1}^{n} d_i^{2}}{n\,(n^{2} - 1)}$$Con empates (por ejemplo, muchas celdas con población 0) se usa la forma general: la correlación de Pearson aplicada a los puestos \(R\):
$$\rho = \frac{\operatorname{cov}\big(R(x),\, R(y)\big)}{\sigma_{R(x)}\;\sigma_{R(y)}}$$Regla de Tukey para señalar atípicos, con \(Q_1\) y \(Q_3\) el primer y el tercer cuartil:
$$IQR = Q_3 - Q_1 \qquad x \lt Q_1 - 1.5\,IQR \quad \text{o} \quad x \gt Q_3 + 1.5\,IQR \;\Rightarrow\; \text{atípico}$$Número de pares (18 variables numéricas): \(\binom{18}{2} = \dfrac{18 \cdot 17}{2} = 153\).
Seis celdas, con su altitud media y su altitud máxima. Se ordena cada columna por separado y se anota el puesto de cada celda:
| Celda | alt_media_m | Puesto | alt_max_m | Puesto | \(d\) | \(d^2\) |
|---|---|---|---|---|---|---|
| A | 2 540 | 1 | 2 571 | 1 | 0 | 0 |
| B | 2 612 | 2 | 2 668 | 3 | −1 | 1 |
| C | 2 655 | 3 | 2 662 | 2 | 1 | 1 |
| D | 2 790 | 4 | 2 846 | 4 | 0 | 0 |
| E | 2 903 | 5 | 2 977 | 5 | 0 | 0 |
| F | 3 010 | 6 | 3 088 | 6 | 0 | 0 |
Solo B y C intercambian puesto: la celda B es más empinada y su punto más alto supera al de C, aunque su altitud media sea menor. \(\sum d^2 = 2\), así que \(\rho = 1 - \dfrac{6 \cdot 2}{6\,(36 - 1)} = 1 - \dfrac{12}{210} = 0.943\).
Las dos altitudes ordenan las celdas casi igual: con \(\rho = 0.943\) este par pasaría el umbral del rombo siguiente (\(|\rho| \gt 0.9\)).
Valores ilustrativos, dentro del rango real de El Valle (2 482 a 3 103 m s. n. m.).
Así podría verse el histograma de deficit_4g en la malla de 250 m (688 celdas):
Una forma de "U" como esta indicaría que la mayoría de celdas quedan enteras dentro o enteras fuera de la zona con déficit 4G, y que solo las del borde de esa zona tienen valores intermedios.
Atípicos en pendiente_deg: si \(Q_1 = 6.2°\) y \(Q_3 = 18.4°\),
entonces \(IQR = 12.2°\) y el límite superior es \(18.4 + 1.5 \times 12.2 = 36.7°\). Una celda
con 41.5° queda señalada, pero al mirarla en el mapa es una quebrada: es real y
se conserva. En cambio, una celda con deficit_4g = 1.20 es
imposible (una fracción no pasa de 1): es un error del paso 2.4 y se
corrige.
Valores ilustrativos.
| Comprobación | Regla | Por qué |
|---|---|---|
deficit_4g | entre 0 y 1 | es una fracción del área de la celda |
clase_4g | 0, 1, 2 o 3 | sin servicio, baja, media o alta |
alt_media_m, alt_max_m | cerca de 2 482 – 3 103 m, y máxima ≥ media | rango real de altitud de El Valle |
pendiente_deg | entre 0° y 90° | es un ángulo con la horizontal |
pob_sin_4g | ≤ poblacion | es una parte de los habitantes de la celda |
Suma de poblacion | ≈ 24 314 hab. en cada malla | total del PDOT (Tablas 4-50 y 4-51); repartir no crea ni borra personas |
exclusion | 0 o 1 | es un sí o no |
La suma puede quedar un poco por debajo de 24 314 si algún círculo de área equivalente de una comunidad se sale del límite parroquial.
import geopandas as gpd
import pandas as pd
import matplotlib.pyplot as plt
VARIABLES = [
"deficit_4g", "clase_4g", "dist_rb4g_km", "n_rb4g_2km", # cobertura 4G
"alt_media_m", "alt_max_m", "pendiente_deg", "tpi_m", "visibilidad_km2", # topografía
"poblacion", "densidad_hab_km2", "pob_sin_4g",
"n_escuelas", "n_salud", "n_urbanizaciones", # beneficio social
"dist_via_m", "uso_suelo", "conductividad_S_m", "exclusion", # viabilidad
]
malla = gpd.read_file("malla_250m.gpkg") # salida de 2.4 (se repite con 100 y 500 m)
X = malla[VARIABLES]
# 1) Resumen, rangos y faltantes
print(X.describe().T[["min", "50%", "max"]])
print(X.isna().sum()) # celdas vacías por columna
assert X["deficit_4g"].between(0, 1).all()
assert (X["alt_max_m"] >= X["alt_media_m"]).all()
assert (X["pob_sin_4g"] <= X["poblacion"]).all()
print("Población total:", round(X["poblacion"].sum())) # PDOT: 24 314
# 2) Histogramas, uno por variable
X.hist(bins=20, figsize=(16, 12))
plt.tight_layout()
plt.savefig("histogramas_250m.png", dpi=150)
# 3) Atípicos con la regla de Tukey
q1, q3 = X.quantile(0.25), X.quantile(0.75)
iqr = q3 - q1
atipicos = ((X < q1 - 1.5 * iqr) | (X > q3 + 1.5 * iqr)).sum()
print(atipicos.sort_values(ascending=False))
# 4) Correlación de Spearman entre cada par (uso_suelo es un código sin orden)
rho = X.drop(columns="uso_suelo").corr(method="spearman")
rho.round(2).to_csv("spearman_250m.csv")
| deficit_4g | clase_4g | alt_media_m | alt_max_m | poblacion | densidad_hab_km2 | |
|---|---|---|---|---|---|---|
deficit_4g | 1.00 | −0.94 | 0.41 | 0.43 | −0.35 | −0.35 |
clase_4g | −0.94 | 1.00 | −0.38 | −0.40 | 0.37 | 0.37 |
alt_media_m | 0.41 | −0.38 | 1.00 | 0.97 | −0.52 | −0.52 |
alt_max_m | 0.43 | −0.40 | 0.97 | 1.00 | −0.49 | −0.49 |
poblacion | −0.35 | 0.37 | −0.52 | −0.49 | 1.00 | 0.99 |
densidad_hab_km2 | −0.35 | 0.37 | −0.52 | −0.49 | 0.99 | 1.00 |
Valores ilustrativos. La matriz completa es de 18 × 18 (las 19 variables menos
uso_suelo) y es simétrica: el valor de (fila, columna) es igual al de (columna, fila),
y la diagonal siempre vale 1.
Es una pregunta de control sobre la matriz de Spearman: ¿hay algún par de variables que ordene las celdas casi igual, tanto que una de las dos sobra? Si lo hay, se quita una (2.6). Si no, las variables están listas y se pasa a etiquetar (2.7).
Los rombos del diagrama son decisiones: de ellos salen dos caminos. En este:
deficit_4g (más déficit) y
clase_4g (mejor nivel de señal).poblacion y pob_sin_4g, pueden aportar información
diferente y se conservan.El bosque no "se rompe" con dos variables gemelas, pero sí se ven afectadas dos cosas:
alt_media_m y
alt_max_m. Y al desordenar solo una de ellas (importancia por permutación), la otra
la sustituye y el modelo casi no empeora: las dos parecerían poco importantes aunque la altitud
sí lo sea.Los cinco pares con mayor \(|\rho|\) en la malla de 250 m podrían ser:
| Par de variables | \(\rho\) | \(|\rho|\) | ¿Mayor que 0.9? |
|---|---|---|---|
poblacion · densidad_hab_km2 | 0.99 | 0.99 | Sí |
alt_media_m · alt_max_m | 0.97 | 0.97 | Sí |
deficit_4g · clase_4g | −0.94 | 0.94 | Sí |
dist_rb4g_km · n_rb4g_2km | −0.86 | 0.86 | No |
poblacion · pob_sin_4g | 0.78 | 0.78 | No |
Hay tres pares por encima de 0.9, así que la respuesta es Sí y se pasa a 2.6.
Valores ilustrativos. Que poblacion y densidad_hab_km2
salgan casi iguales es lo esperable: en una malla de celdas del mismo tamaño, la densidad es la
población dividida por un área casi constante (solo cambia en las celdas del borde).
Un caso que Spearman no ve. Si la conductividad del suelo se asigna según la
clase de clutter de la SUPERTEL, y uso_suelo sale de esa misma capa,
entonces conductividad_S_m es una función de uso_suelo: las dos
llevan la misma información. Como uso_suelo es un código sin orden, esto se
comprueba con una tabla cruzada: si cada clase de suelo tiene una sola conductividad, el par se
trata como "casi igual".
import numpy as np
import pandas as pd
UMBRAL = 0.9
rho = X.drop(columns="uso_suelo").corr(method="spearman") # matriz de 2.5
arriba = np.triu(np.ones(rho.shape, dtype=bool), k=1) # cada par una sola vez
pares = rho.where(arriba).stack().dropna()
casi_iguales = pares[pares.abs() > UMBRAL].sort_values(key=abs, ascending=False)
if casi_iguales.empty:
print("No: pasar a 2.7 (etiquetar)")
else:
print("Sí: pasar a 2.6 (quitar redundancias)")
print(casi_iguales.round(2))
# Caso especial: código sin orden frente a variable numérica
print(pd.crosstab(X["uso_suelo"], X["conductividad_S_m"]))
Si dos variables dicen casi lo mismo, basta con una. De cada par se queda la que cualquier persona entiende más rápido y se quita la otra; luego se vuelve a 2.5 para comprobar que ya no queda ningún par repetido.
Nunca se elige mirando cuál predice mejor la etiqueta. Las etiquetas todavía no existen (se crean en 2.7) y, aunque existieran, elegir así sería dejar que el examen influya en la preparación: una forma de hacer trampa que en aprendizaje automático se llama fuga de información.
donde \(q\) es el número de variables quitadas. Con \(q = 3\): \(p' = 16\), \(m = 4\) y \(\lfloor 16/3 \rfloor = 5\).
| Par casi igual | Se conserva | Se quita |
|---|---|---|
poblacion · densidad_hab_km2 | poblacion | densidad_hab_km2 |
alt_media_m · alt_max_m | alt_media_m | alt_max_m |
deficit_4g · clase_4g | deficit_4g | clase_4g |
Por qué, en cada par:
poblacion: "145 habitantes" se entiende sin cálculo (criterio 1).
En celdas del mismo tamaño, la densidad es la población dividida entre un área fija, así que no
añade información.alt_media_m: la media resume todos los píxeles de la celda; el
máximo depende de un solo píxel de 30 m del DEM (criterio 4).deficit_4g: es el problema central de la tesis (criterio 2) y lo
usan la regla de etiquetado (2.7) y el filtro de candidatas (3.10).Decisiones ilustrativas: los pares reales dependen de la matriz que salga en 2.5.
import geopandas as gpd
import numpy as np
import pandas as pd
def pares_casi_iguales(tabla, variables, umbral=0.9):
columnas = [v for v in variables if v != "uso_suelo"] # código sin orden
rho = tabla[columnas].corr(method="spearman")
arriba = np.triu(np.ones(rho.shape, dtype=bool), k=1)
pares = rho.where(arriba).stack().dropna()
return pares[pares.abs() > umbral]
# Qué variable se quita en cada par: lo decide una persona con los criterios de arriba
decisiones = {
("poblacion", "densidad_hab_km2"): "densidad_hab_km2",
("alt_media_m", "alt_max_m"): "alt_max_m",
("deficit_4g", "clase_4g"): "clase_4g",
}
tablas = {t: gpd.read_file(f"malla_{t}m.gpkg") for t in (100, 250, 500)}
while True: # 2.5 → rombo → 2.6 → 2.5 ...
pares = pd.concat([pares_casi_iguales(tb, VARIABLES) for tb in tablas.values()])
if pares.empty:
break # rombo "No": seguir a 2.7
for a, b in pares.index.unique():
quitar = decisiones[(a, b)] # un par nuevo sin decidir da error, a propósito
if quitar in VARIABLES:
VARIABLES.remove(quitar)
print(len(VARIABLES), "variables:", VARIABLES) # 16 en este ejemplo
Para aprender, el modelo necesita ejemplos resueltos: celdas marcadas con 1 (buen sitio para una radiobase 4G) o con 0 (no lo es). Nadie tiene esa lista para El Valle, así que se construye con una sola regla, calibrada con las radiobases que las operadoras ya instalaron.
A cada una de las tres tablas (100, 250 y 500 m) se le agrega una columna nueva,
etiqueta, con un 1 o un 0 en cada celda. Las variables no cambian; solo se añade la
"respuesta" que el modelo debe aprender a dar. En el diagrama, por eso, de este bloque sale una
sola flecha: hacia la regla de etiquetado, que es el bloque siguiente.
| Ingrediente | Qué aporta | Qué defecto evita |
|---|---|---|
| Déficit 4G ≥ 50 % (paso 2.2) | Apunta a donde falta cobertura, no a donde ya la hay | Recomendar sitios parecidos a los que ya existen |
| Perfil de sitio viable (pendiente, vía y población) | Define "viable" como lo definen las radiobases reales, no una opinión | Umbrales puestos a dedo por una persona |
| Percentiles 90 y 10 | Un número que sale de los datos y se puede recalcular y auditar | Que un solo caso extremo estire el umbral y lo vuelva inútil |
| 0 = el resto | Todas las celdas quedan etiquetadas | Depender de una muestra al azar de negativos |
Los dos primeros defectos de la columna derecha ya aparecen en la memoria. La limitación señalada para Niu et al. es que su modelo, al aprender solo de las estaciones de metro existentes, recomienda lugares parecidos a ellas. Y en el análisis de idoneidad con capas GIS, los pesos de las capas los fija una persona. La regla del bloque siguiente los esquiva: aprende de los sitios reales solo qué condiciones hacen viable una radiobase, y usa esas condiciones para señalar celdas nuevas, allí donde hoy falta señal.
Después de etiquetar, cada tabla es un conjunto de pares (variables, respuesta):
$$\mathcal{D} = \big\{ (\mathbf{x}_c,\; y_c) \big\}_{c=1}^{n} \qquad \mathbf{x}_c \in \mathbb{R}^{p} \qquad y_c \in \{0, 1\}$$donde \(\mathbf{x}_c\) son las \(p\) variables de la celda \(c\) y \(y_c\) su etiqueta. El modelo busca una función \(f\) tal que \(f(\mathbf{x}_c) \approx y_c\), también en celdas que nunca vio.
| id_celda | deficit_4g | pendiente_deg | dist_via_m | poblacion | … | etiqueta |
|---|---|---|---|---|---|---|
| C0421 | 0.85 | 12.4 | 180 | 145 | … | 1 |
| C0388 | 0.20 | 6.3 | 90 | 410 | … | 0 |
| C0517 | 1.00 | 31.7 | 1 250 | 0 | … | 0 |
Valores ilustrativos. C0388 tiene radiobases 4G de Claro y Movistar, y por eso mismo ya tiene buena señal: no hace falta otra, así que es 0. C0517 es una ladera sin acceso ni habitantes: aunque le falta 4G, no es un sitio viable. Ninguna celda queda sin etiqueta.
import geopandas as gpd
radiobases_4g = gpd.read_file("el_valle.gpkg", layer="radiobases_4g") # de 1.3
for tamano in (100, 250, 500):
malla = gpd.read_file(f"malla_{tamano}m.gpkg") # celdas + variables (2.4 a 2.6)
perfil = perfil_sitio_viable(medir_radiobases(radiobases_4g, tamano)) # ver bloque siguiente
malla["etiqueta"] = etiquetar(malla, perfil)
malla.to_file(f"malla_{tamano}m.gpkg", driver="GPKG")
print(tamano, "m |", {k: round(v, 1) for k, v in perfil.items()},
"|", malla["etiqueta"].value_counts().to_dict())
etiqueta: 1 o 0 en todas las
celdas.Claro, Movistar y CNT ya eligieron, con sus propios estudios, dónde poner cada radiobase 4G. Esta regla no copia esos sitios: los mide, para averiguar qué condiciones acepta una radiobase de verdad. Después marca con 1 las celdas que cumplen esas condiciones y además se quedaron sin cobertura.
Señalar un buen sitio exige responder dos preguntas: ¿hace falta una radiobase aquí y ¿se puede poner aquí. La primera la contesta el déficit 4G del paso 2.2. La segunda la contestan las radiobases que ya existen: si en El Valle y su alrededor hay radiobases en laderas de 18° y a 200 m de una vía, entonces 18° y 200 m son condiciones aceptables para una radiobase, porque alguien ya las aceptó y construyó. Ese conjunto de condiciones es el perfil de sitio viable, y lo definen los sitios reales de las operadoras, no el autor de la tesis.
pendiente_deg,
dist_via_m y poblacion.deficit_4g ≥ 0.5 cuyas tres variables caen dentro del perfil; el resto
recibe 0. No hay celdas sin etiqueta ni sorteos de por medio.deficit_4g ≥ 0.5: en al menos la mitad del área de
la celda, ninguna de las tres operadoras (Claro, Movistar y CNT) llega con RSRP ≥ −120 dBm,
según los mapas predictivos que entregan a la ARCOTEL. Es la condición "aquí hace falta
cobertura", y es la que impide que la regla señale sitios donde ya hay buena señal.pendiente_deg: el ángulo medio del terreno con
la horizontal, en grados. En laderas fuertes, levantar la torre y llegar con materiales es más
difícil y caro.dist_via_m: hace falta un acceso para
construir la radiobase y darle mantenimiento.poblacion: los habitantes asignados desde el
PDOT de El Valle. Una radiobase donde no vive nadie no aporta beneficio social.Si el 1 se pusiera en las celdas que contienen una radiobase, esas celdas tendrían
dist_rb4g_km casi 0, al menos una radiobase a menos de 2 km
(n_rb4g_2km) y buena cobertura justamente porque la radiobase está ahí. El
modelo aprendería "es adecuada donde ya hay una radiobase", algo inútil para recomendar sitios
nuevos, y habría que corregirlo a mano. Con esta regla no ocurre: una celda con radiobase tiene
poco déficit 4G, así que falla la primera condición y recibe 0. Las cuatro variables de cobertura
entran al modelo sin arreglos.
Sea \(R\) el conjunto de radiobases 4G del paso 1.3 (El Valle y 5 km a la redonda) y \(Q_{\alpha}\) el percentil \(\alpha\), es decir, el valor \(t\) que cumple:
$$Q_{\alpha}\big(\{v_r\}_{r \in R}\big) = t \quad \text{tal que} \quad \frac{\#\{\, r \in R : v_r \leq t \,\}}{|R|} = \frac{\alpha}{100}$$Los tres umbrales del perfil de sitio viable, medidos con cuadrados del lado de la malla:
$$\theta_{\text{pend}} = Q_{90}\big(\text{pendiente\_deg}\big) \qquad \theta_{\text{vía}} = Q_{90}\big(\text{dist\_via\_m}\big) \qquad \theta_{\text{pob}} = Q_{10}\big(\text{poblacion}\big)$$Y la etiqueta de la celda \(c\):
$$y_c = \mathbf{1}\big[\text{deficit\_4g}_c \geq 0.5\big] \cdot \mathbf{1}\big[\text{pendiente\_deg}_c \leq \theta_{\text{pend}}\big] \cdot \mathbf{1}\big[\text{dist\_via\_m}_c \leq \theta_{\text{vía}}\big] \cdot \mathbf{1}\big[\text{poblacion}_c \geq \theta_{\text{pob}}\big]$$\(\mathbf{1}[\cdot]\) vale 1 si la condición se cumple y 0 si no. Como se multiplican, basta un solo 0 para que la etiqueta sea 0: es un "Y" lógico. Lo único que decide una persona es el 50 % de déficit y los percentiles 90 y 10; los valores \(\theta\) salen de los datos.
Supón que el paso 1.3 deja 38 radiobases 4G entre El Valle y los 5 km a la redonda. Medidas con cuadrados de 250 m alrededor de cada una:
| Variable medida en la radiobase | Mínimo | \(Q_{10}\) | Mediana | \(Q_{90}\) | Máximo | Umbral del perfil |
|---|---|---|---|---|---|---|
pendiente_deg | 1.8° | 3.1° | 7.9° | 18.7° | 27.4° | ≤ 18.7° |
dist_via_m | 5 | 12 | 55 | 240 | 910 | ≤ 240 m |
poblacion (hab.) | 0 | 11 | 96 | 512 | 780 | ≥ 11 hab. |
Cómo sale el 18.7°. Con las 38 pendientes ordenadas de menor a mayor, \(0.90 \times (38 - 1) = 33.3\): el percentil 90 cae entre la 34.ª radiobase (18.4°) y la 35.ª (19.3°), y se interpola: \(18.4 + 0.3 \times (19.3 - 18.4) = 18.7°\). Los otros dos umbrales salen igual, cambiando el porcentaje.
Lo interesante es compararlos con umbrales puestos a ojo. Si una persona hubiera escrito "pendiente menor que 15° y vía a menos de 500 m", se habría equivocado en los dos sentidos a la vez: el umbral de pendiente sale más permisivo de lo supuesto (18.7°, porque sí hay radiobases en laderas de más de 15°), y el de la vía, bastante más exigente (240 m, porque las operadoras casi no construyen lejos de un acceso). Ninguno de los dos lo decidió el autor de la tesis.
Valores ilustrativos: el número real de radiobases y sus medidas salen del
paso 1.3. El percentil se calcula con la interpolación lineal que usan por defecto
numpy.quantile y pandas.Series.quantile.
| Celda | deficit_4g ≥ 0.5 | pendiente_deg ≤ 18.7 | dist_via_m ≤ 240 | poblacion ≥ 11 | etiqueta |
|---|---|---|---|---|---|
| C0421 | 0.85 ✔ | 12.4 ✔ | 180 ✔ | 145 ✔ | 1 |
| C0422 | 0.10 ✘ | 4.1 ✔ | 60 ✔ | 380 ✔ | 0 |
| C0517 | 1.00 ✔ | 31.7 ✘ | 1 250 ✘ | 0 ✘ | 0 |
| C0630 | 0.55 ✔ | 9.8 ✔ | 220 ✔ | 38 ✔ | 1 |
| C0655 | 0.62 ✔ | 14.2 ✔ | 640 ✘ | 95 ✔ | 0 |
Valores ilustrativos. C0422 ya tiene buena señal 4G, así que no hace falta nada ahí; C0517 es una ladera sin acceso ni habitantes; C0655 cumple casi todo, pero su vía más cercana está a 640 m, más lejos de lo que aceptan 9 de cada 10 radiobases reales. En toda la malla de 250 m quedarían 120 celdas con 1 y 568 con 0 (17.4 % de positivas): son las que usa el ejemplo del paso 2.8.
Si el perfil sale muy estrecho —por ejemplo, si casi todas las radiobases están en terreno plano y pegadas a una vía— pueden quedar muy pocas celdas con 1, y con pocas positivas la validación por bloques del paso 2.8 se vuelve frágil. Se controla con dos perillas, en este orden:
| Ajuste | Pendiente | Vía | Población | Celdas con 1 (250 m) |
|---|---|---|---|---|
| Percentiles 90 / 10, déficit ≥ 0.5 (valores de partida) | ≤ 18.7° | ≤ 240 m | ≥ 11 | 120 |
| Percentiles 95 / 5, déficit ≥ 0.5 | ≤ 21.9° | ≤ 380 m | ≥ 4 | 168 |
| Percentiles 90 / 10, déficit ≥ 0.4 | ≤ 18.7° | ≤ 240 m | ≥ 11 | 155 |
Valores ilustrativos. Se elige el ajuste más estrecho que deje suficientes positivas para las 10 partes de la validación espacial (paso 2.8), y se deja anotado cuál se usó, para que el resultado se pueda repetir.
import geopandas as gpd
def medir_radiobases(radiobases, lado):
"""Mide cada radiobase como si fuera una celda: un cuadrado de lado × lado a su alrededor."""
entorno = radiobases.copy()
entorno["geometry"] = radiobases.buffer(lado / 2, cap_style=3) # cap_style=3: cuadrado
entorno = variables_topografia(entorno) # pendiente_deg (mismas recetas que 2.4)
entorno = variables_sociales(entorno) # poblacion
entorno = variables_viabilidad(entorno) # dist_via_m
return entorno[["pendiente_deg", "dist_via_m", "poblacion"]]
def perfil_sitio_viable(medidas, p_alto=0.90, p_bajo=0.10):
"""Los tres umbrales, leídos de las radiobases que ya existen."""
return {
"pendiente_deg": medidas["pendiente_deg"].quantile(p_alto), # hasta el percentil 90
"dist_via_m": medidas["dist_via_m"].quantile(p_alto), # hasta el percentil 90
"poblacion": medidas["poblacion"].quantile(p_bajo), # desde el percentil 10
}
def etiquetar(malla, perfil, deficit=0.5):
"""1 = falta 4G y la celda cae dentro del perfil de sitio viable; 0 = el resto."""
cumple = ((malla["deficit_4g"] >= deficit) & # aquí hace falta cobertura
(malla["pendiente_deg"] <= perfil["pendiente_deg"]) & # terreno construible
(malla["dist_via_m"] <= perfil["dist_via_m"]) & # hay acceso
(malla["poblacion"] >= perfil["poblacion"])) # beneficia a alguien
return cumple.astype(int)
radiobases_4g = gpd.read_file("el_valle.gpkg", layer="radiobases_4g") # 1.3: El Valle + 5 km
medidas = medir_radiobases(radiobases_4g, 250) # una medida por radiobase
perfil = perfil_sitio_viable(medidas)
print({k: round(v, 1) for k, v in perfil.items()})
# p. ej. {'pendiente_deg': 18.7, 'dist_via_m': 240.0, 'poblacion': 11.0}
malla["etiqueta"] = etiquetar(malla, perfil)
print(malla["etiqueta"].value_counts().to_dict()) # p. ej. {0: 568, 1: 120}
# Sensibilidad: ¿cuántas celdas son 1 si se ensancha el perfil o se baja el déficit?
for p_alto, p_bajo, u in ((0.90, 0.10, 0.5), (0.95, 0.05, 0.5), (0.90, 0.10, 0.4)):
p = perfil_sitio_viable(medidas, p_alto, p_bajo)
print(p_alto, p_bajo, u, "->", etiquetar(malla, p, deficit=u).sum(), "celdas con 1")
| cod_estructura | operadora | pendiente_deg | dist_via_m | poblacion |
|---|---|---|---|---|
| RB-0147 | Claro | 6.2 | 35 | 412 |
| RB-0219 | Movistar | 18.4 | 190 | 64 |
| RB-0355 | CNT | 19.3 | 910 | 9 |
Filas ilustrativas: 38 en total, una por estructura. De esta tabla salen los tres percentiles, y nada más: sus coordenadas no se usan para etiquetar.
deficit_4g, pendiente_deg,
dist_via_m y poblacion.etiqueta de cada malla, con 1 o 0 en todas las
celdas, y los tres umbrales del perfil de sitio viable de cada malla, anotados para poder
repetir el cálculo.Es el entregable de la Etapa 2: tres tablas listas para entrenar, una por cada tamaño de celda. Cada fila es una celda de El Valle, cada columna una variable y la última columna es la respuesta (1 o 0).
De cada malla sale una tabla, con la columna etiqueta del paso anterior como
respuesta. Antes de guardarlas se comprueba que no haya vacíos en las variables y que la etiqueta
solo valga 0 o 1 en todas las filas.
etiqueta: 1 = adecuada, 0 = no
adecuada. Es la \(y\) que el modelo aprende a predecir a partir de las variables, que forman la
\(X\).x_utm, y_utm, en metros, EPSG:32717). Esas dos
columnas no son variables del modelo: sirven para formar los bloques de 1 km
(tamaño de partida propuesto) de la validación espacial (paso 2.8) y para dibujar los resultados
en el mapa. Cada tabla se guarda en formato Parquet: un archivo de tabla que
guarda los datos por columnas, conserva el tipo de cada una (entero, decimal, texto) y ocupa
menos que un CSV (texto plano con los valores separados por comas); pandas lo escribe y lo lee
con la librería pyarrow. El paso 3.1
abre estas tablas una por una, por su nombre de archivo.donde \(\ell\) es el lado de la celda en km. Por ejemplo, con \(\ell = 0.1\) km: \(43.03 / 0.01 \approx 4\,303\) filas.
| Malla | Filas (celdas) | Filas con etiqueta | Columnas | Archivo |
|---|---|---|---|---|
| 100 m | ≈ 4 303 | todas | id_celda, x_utm, y_utm, 19 variables, etiqueta | tabla_100m.parquet |
| 250 m | ≈ 688 | todas | las mismas | tabla_250m.parquet |
| 500 m | ≈ 172 | todas | las mismas | tabla_500m.parquet |
Las tres tablas tienen exactamente las mismas columnas, en el mismo orden: solo cambia cuántas filas trae cada una. Así la Etapa 3 puede recorrerlas con el mismo código y comparar los tres modelos sin trampas.
| id_celda | x_utm | y_utm | deficit_4g | dist_rb4g_km | alt_media_m | … | exclusion | etiqueta |
|---|---|---|---|---|---|---|---|---|
| C0421 | 727 375 | 9 675 125 | 0.85 | 3.2 | 2 635 | … | 0 | 1 |
| C0422 | 727 625 | 9 675 125 | 0.10 | 3.0 | 2 643 | … | 0 | 0 |
| C0517 | 727 625 | 9 670 375 | 1.00 | 5.8 | 3 010 | … | 1 | 0 |
Tres filas ilustrativas de tabla_250m.parquet; el "…" esconde las
demás variables (entre ellas pendiente_deg y poblacion, que se ven en
2.7). Las coordenadas son
centros de celdas de 250 m dentro de El Valle y las altitudes medias salen del DEM Copernicus en
esas celdas; el resto de valores es ilustrativo. C0421 y C0422 son vecinas (sus centros están a
250 m), por eso su distancia a la radiobase 4G más cercana es casi la misma: ninguna tiene una
radiobase dentro. Que C0422 tenga buena señal y C0421 no, a la misma distancia, lo explica el
relieve entre cada celda y la radiobase (una loma puede tapar a una y no a la otra).
| Columna | Tipo | Unidad | Para qué sirve |
|---|---|---|---|
id_celda | texto | — | identificar la celda y unirla con su geometría |
x_utm, y_utm | decimal | m (EPSG:32717) | bloques de 1 km (2.8) y mapas; no entran al modelo |
| 19 variables | decimal o entero | km, m, °, hab., hab/km², S/m, fracción, código | la \(X\) del modelo |
etiqueta | entero (0 o 1) | — | la \(y\) del modelo |
particion, bloque | texto | — | las agrega el paso 2.8 (entrenamiento o prueba, y bloque de 1 km); no entran al modelo |
import geopandas as gpd
import pandas as pd
for tamano in (100, 250, 500): # 3 mallas, 3 tablas
malla = gpd.read_file(f"malla_{tamano}m.gpkg") # x_utm, y_utm: centro de cada celda (paso 2.3)
tabla = pd.DataFrame(malla.drop(columns="geometry"))
tabla = tabla[["id_celda", "x_utm", "y_utm", *VARIABLES, "etiqueta"]]
assert tabla[VARIABLES].notna().all().all() # sin vacíos (revisado en 2.5)
assert tabla["etiqueta"].isin([0, 1]).all() # solo 0 o 1, en todas las filas
# Parquet guarda los tipos de cada columna y ocupa poco espacio
tabla.to_parquet(f"tabla_{tamano}m.parquet", index=False)
print(tamano, tabla.shape, tabla["etiqueta"].value_counts().to_dict())
etiqueta.tabla_{malla}m.parquet, que se preparan en 2.8 y
se usan en la Etapa 3.Antes de entrenar, cada una de las tres tablas se prepara para que la evaluación sea justa: se compensa que haya pocas celdas con 1, se aparta un 30 % para el examen final y se agrupan las celdas en bloques de 1 km (tamaño de partida), para que al validar el modelo tenga que acertar en zonas que no vio.
particion (entrenamiento o
prueba) y bloque (por ejemplo, 727_9675). Así la Etapa 3 recibe cada
tabla ya dividida.Se hace con las tres tablas y siempre con la misma semilla (random_state=42), para
que cualquiera pueda repetir exactamente la misma división.
class_weight="balanced". No se
duplican ni se inventan celdas.GroupKFold los reparte de modo que las 10 partes tengan un
número parecido de celdas, no de bloques: en la malla de 250 m quedan unos 5 a 8
bloques por parte. Como ahora todas las celdas tienen etiqueta, ningún bloque queda vacío y las
partes salen parejas en las tres mallas.class_weight="balanced"); el 70 %, para entrenar y ajustar; el
30 %, en la evaluación 3.5; y los bloques, en la búsqueda en malla 3.3 y en la elección del mejor
modelo 3.9, que se decide por el F1 de la validación espacial (el F1 es una nota de 0 a 1 que
resume qué tan bien acierta el modelo las celdas adecuadas, combinando precisión y
exhaustividad; se explica en 3.5). Esto importa porque Fauzi et al.
mostraron que el error de un Random Forest crece al aplicarlo fuera de la zona con la que se
entrenó, y el modelo tiene que recomendar sitios justo en zonas sin radiobases 4G.Peso de cada clase (\(n\) celdas en total, \(n_j\) de la clase \(j\), con \(j = 0\) o \(1\)):
$$w_j = \frac{n}{2\,n_j} \qquad\Rightarrow\qquad n_1\,w_1 = n_0\,w_0 = \frac{n}{2}$$Es decir, cada clase suma la mitad del peso total. Un árbol reparte las celdas haciendo preguntas (por ejemplo, ¿pendiente menor de 15°?); cada grupo que se forma es un nodo. El índice de Gini mide qué tan mezcladas están las clases 1 y 0 en un nodo (0 = todas iguales), y el árbol elige la pregunta que más reduce esa mezcla (se detalla en 3.2). Con pesos, el índice de Gini usa proporciones ponderadas:
$$\hat{p}_j = \frac{\sum_{i \in \text{nodo},\; y_i = j} w_{y_i}}{\sum_{i \in \text{nodo}} w_{y_i}}$$Estratificación:
$$\frac{n_1^{\text{ent}}}{n^{\text{ent}}} \approx \frac{n_1^{\text{prueba}}}{n^{\text{prueba}}} \approx \frac{n_1}{n}$$Bloque de una celda con centro \((x_c, y_c)\) en metros:
$$b(c) = \left( \left\lfloor \frac{x_c}{1000} \right\rfloor,\; \left\lfloor \frac{y_c}{1000} \right\rfloor \right)$$Pesos. La tabla de 250 m: 688 celdas, de las cuales 120 son 1 y 568 son 0.
División. scikit-learn redondea el 30 % hacia arriba: 207 celdas de prueba y 481 de entrenamiento. Estratificando, la prueba recibe 36 positivas y 171 negativas (17.4 % de positivas), y el entrenamiento 84 y 397 (17.5 %).
Conteos de clases ilustrativos; el número de celdas (688) sale del área de El Valle.
Bloque. Una celda con centro en el centro de gravedad de La Gloria Baja
(Tabla 4-50 del PDOT: X = 726 678.56, Y = 9 674 886.35) cae en el bloque
\(\big(\lfloor 726.68 \rfloor, \lfloor 9\,674.89 \rfloor\big) = (726,\ 9\,674)\), que en la
tabla se escribe 726_9674. Todas las celdas cuyo centro esté en ese kilómetro van
juntas a la misma parte de la validación.
Esquema: cada cuadro es un bloque de 1 km y su número es la parte (de 1 a 10) a la que se asigna. En la ronda 3, los bloques marcados con 3 (naranja) se usan para medir y todos los demás para entrenar. Luego se repite con la parte 4, y así hasta completar las 10.
StratifiedGroupKFold, que reparte los bloques cuidando también la proporción de 1; y
en último caso habrá que reducir \(k\) para esa tabla.import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split, GroupKFold
from sklearn.utils.class_weight import compute_class_weight
archivo = "tabla_250m.parquet" # se repite con las 3 tablas
tabla = pd.read_parquet(archivo)
X, y = tabla[VARIABLES], tabla["etiqueta"].astype(int) # todas las celdas tienen etiqueta
# 1) Pesos por clase: w_j = n / (2 · n_j) (lo mismo que hace class_weight="balanced")
pesos = compute_class_weight("balanced", classes=np.array([0, 1]), y=y)
print(pesos.round(2)) # p. ej. [0.61 2.87]
# 2) Bloque de 1 km de cada celda, a partir de su centro en UTM (metros)
tabla["bloque"] = ((tabla["x_utm"] // 1000).astype(int).astype(str) + "_" +
(tabla["y_utm"] // 1000).astype(int).astype(str)) # p. ej. "726_9674"
# 3) 70 % entrenamiento / 30 % prueba, estratificado por la etiqueta
X_ent, X_prueba, y_ent, y_prueba = train_test_split(
X, y, test_size=0.30, stratify=y, random_state=42)
bloque_ent = tabla.loc[X_ent.index, "bloque"]
# 4) Validación espacial: 10 partes formadas por bloques completos
cv = GroupKFold(n_splits=10)
for i, (ent, val) in enumerate(cv.split(X_ent, y_ent, groups=bloque_ent), start=1):
print(f"parte {i}: {bloque_ent.iloc[val].nunique()} bloques, "
f"{int(y_ent.iloc[val].sum())} celdas con 1")
# 5) Guardar la partición y el bloque en la misma tabla (los lee el paso 3.1)
tabla.loc[X_ent.index, "particion"] = "entrenamiento"
tabla.loc[X_prueba.index, "particion"] = "prueba"
tabla.to_parquet(archivo, index=False)
particion (entrenamiento o
prueba) y bloque (p. ej. 727_9675). Los pesos por clase se aplican con
class_weight="balanced" en la Etapa 3, que empieza en el paso 3.1.Aquí se le enseña a un algoritmo, con ejemplos, a reconocer qué celdas de El Valle son buenas candidatas para una nueva radiobase 4G. Después se le pide que le ponga una nota a todas las celdas de la parroquia y, con esas notas, se eligen los sitios que se recomiendan.
El algoritmo se llama Random Forest (bosque aleatorio). Funciona como un comité: en lugar de confiar en una sola persona que decide, se consulta a cien "expertos" un poco distintos entre sí y gana la mayoría. Cada experto es un árbol de decisión, es decir, una serie de preguntas de sí o no sobre la celda: ¿le falta cobertura 4G?, ¿el terreno es muy empinado?, ¿vive gente cerca?
Además, el círculo A que está sobre 3.3 recibe el regreso desde la Etapa 4: si las ubicaciones del modelo no cubren más área y población que las de los otros escenarios, el modelo se vuelve a ajustar.
La Etapa 2 entrega tres tablas de entrenamiento (Producto 2), una por malla: una fila por celda, una columna por variable y, al final, la etiqueta (1 = celda adecuada, 0 = no adecuada) que puso la regla única de etiquetado del paso 2.7.
| id_celda | deficit_4g | dist_rb4g_km | alt_media_m | pendiente_deg | poblacion | … | exclusion | etiqueta |
|---|---|---|---|---|---|---|---|---|
| C0421 | 0.85 | 3.2 | 2 635 | 12.4 | 145 | … | 0 | 1 |
| C0422 | 0.10 | 0.6 | 2 643 | 4.1 | 380 | … | 0 | 0 |
| C0517 | 1.00 | 5.8 | 3 010 | 31.7 | 0 | … | 1 | 0 |
Valores ilustrativos: sirven para entender la forma de la tabla, no son datos reales. El "…" esconde las otras 13 variables.
mallas = [100, 250, 500] # lado de la celda, en metros
registro = []
for tamano in mallas: # 3.1: 3 vueltas
X, y, bloques = cargar_tabla(tamano) # Producto 2 + partición de 2.8
f1_ref = entrenar_modelo_base(X, y, bloques) # 3.2
modelo = ajustar_y_entrenar(X, y, bloques, f1_ref) # 3.3 a 3.5 + rombo del F1
variables = simplificar(modelo, X, y) # 3.6 y 3.7
registro.append(resumen(tamano, modelo, variables)) # 3.8
mejor, variables = elegir_mejor(registro) # 3.9: mayor F1 (AUC si empatan)
malla["idoneidad"] = mejor.predict_proba(malla[variables])[:, 1] # Producto 3
candidatas = filtrar(malla) # 3.10
sitios = elegir_sitios(candidatas, N=3, d_min=1500) # 3.11: Producto 4
Es un esqueleto: cada función se desarrolla en el bloque correspondiente.
Es el inicio del ciclo de la etapa: se toma una de las tres mallas de celdas (100, 250 o 500 m) que todavía no se ha evaluado y, con su tabla, se entrena y se evalúa un modelo. Son tres mallas y se prueban todas: un modelo por malla.
donde \(\ell\) es el lado de la celda en km.
| Vuelta | Malla | Área de cada celda | Celdas de la malla (aprox.) |
|---|---|---|---|
| 1 | 100 m | 0.01 km² | ≈ 4 303 |
| 2 | 250 m | 0.0625 km² | ≈ 688 |
| 3 | 500 m | 0.25 km² | ≈ 172 |
Por ejemplo, con celdas de 250 m: \(43.03 / 0.25^2 = 43.03 / 0.0625 \approx 688\) celdas. Como la regla de 2.7 etiqueta todas las celdas de la malla, la tabla de entrenamiento tiene esas 688 filas.
| id_celda | x_utm | y_utm | deficit_4g | … | exclusion | etiqueta | particion | bloque |
|---|---|---|---|---|---|---|---|---|
| C0421 | 727 375 | 9 675 125 | 0.85 | … | 0 | 1 | entrenamiento | 727_9675 |
| C0422 | 727 625 | 9 675 125 | 0.10 | … | 0 | 0 | prueba | 727_9675 |
Valores ilustrativos. Entre deficit_4g y exclusion están
las 19 variables; x_utm, y_utm (centro de la celda en EPSG:32717),
particion y bloque no las ve el modelo: solo sirven para dividir y validar.
import pandas as pd
mallas = [100, 250, 500] # lado de la celda, en metros
for vuelta, tamano in enumerate(mallas, start=1):
tabla = pd.read_parquet(f"tabla_{tamano}m.parquet") # Producto 2 (Etapa 2)
print(vuelta, tamano, tabla.shape, round(tabla["etiqueta"].mean(), 3))
# tabla.shape: (celdas, columnas) · la media de "etiqueta" es la fracción de celdas adecuadas
# ... pasos 3.2 a 3.8 con esta tabla ...
Es el punto de partida: un bosque de 100 árboles de decisión que votan si una celda es adecuada o no para una radiobase 4G. Se entrena con los valores habituales para tener una primera referencia contra la cual medir las mejoras.
Se crea un Random Forest con \(B = 100\) árboles, \(m = \sqrt{p}\) variables por nodo y el criterio de Gini. Se mide su F1 con la misma validación espacial que usará 3.3 (esa será la "ronda 0") y se entrena con el 70 % de las celdas.
Un árbol de decisión clasifica una celda con una serie de preguntas de sí o no: ¿el déficit 4G es mayor que 0.5? → sí → ¿la pendiente es menor que 15°? → sí → adecuada. Un árbol solo tiende a memorizar los ejemplos y se equivoca con celdas nuevas.
Un bosque aleatorio entrena muchos árboles distintos y los pone a votar. Son distintos por dos razones: cada árbol aprende con una muestra diferente de celdas, y en cada pregunta solo puede elegir entre unas pocas variables sorteadas al azar. Los errores de unos se compensan con los aciertos de otros.
RandomForestClassifier implementa el bosque.n_estimators). Más árboles hacen la votación más estable. No causan sobreajuste,
solo hacen el cálculo más lento.Impureza de Gini de un nodo, donde \(p_1\) es la proporción de celdas adecuadas y \(p_0\) la de no adecuadas:
$$G = p_0\,(1 - p_0) + p_1\,(1 - p_1) = 1 - \left(p_0^{2} + p_1^{2}\right)$$Voto del bosque para una celda \(c\), donde \(h_b(c)\) es la respuesta del árbol \(b\) y \(\mathbb{1}[\cdot]\) vale 1 si lo que está entre corchetes es cierto y 0 si no: la suma cuenta los votos "adecuada".
$$\hat{p}(c) = \frac{1}{B}\sum_{b=1}^{B} \mathbb{1}\left[h_b(c) = 1\right] \qquad \hat{y}(c) = \begin{cases} 1 & \text{si } \hat{p}(c) \geq 0.5 \\ 0 & \text{si no} \end{cases}$$Variables por nodo: \(m = \lfloor \sqrt{p} \rfloor = \lfloor \sqrt{19} \rfloor = \lfloor 4.36 \rfloor = 4\).
Un nodo tiene 40 celdas: 10 adecuadas y 30 no. Su impureza es \(G = 1 - (0.25^2 + 0.75^2) = 0.375\). El árbol prueba la pregunta "¿deficit_4g > 0.5?":
Impureza media ponderada: \(\tfrac{12}{40}(0.375) + \tfrac{28}{40}(0.069) = 0.161\). Reducción: \(0.375 - 0.161 = 0.214\). Si otra pregunta, como "¿poblacion > 100?", reduce solo 0.02, el árbol se queda con la del déficit 4G.
Números ilustrativos.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupKFold, cross_val_score
modelo_base = RandomForestClassifier(
n_estimators=100, # B = 100 árboles
max_features="sqrt", # m = √p: con p = 19, int(4.36) = 4 variables por nodo
criterion="gini", # criterio de división
class_weight="balanced", # pesos por clase (2.8): compensa que haya pocas celdas adecuadas
oob_score=True, # calcula el error OOB (ver 3.4)
random_state=42, # resultados reproducibles
)
# F1 de referencia ("ronda 0"): misma validación espacial por bloques que usa 3.3
f1_base = cross_val_score(modelo_base, X_ent, y_ent, groups=bloque_ent,
cv=GroupKFold(n_splits=10), scoring="f1").mean()
modelo_base.fit(X_ent, y_ent)
print("F1 del modelo base:", round(f1_base, 3))
El modelo recibe dos cosas: X, la tabla de variables (una fila por celda, 19
columnas), e y, la columna de etiquetas.
| Grupo | Columnas de X |
|---|---|
| Cobertura 4G (4) | deficit_4g, clase_4g, dist_rb4g_km, n_rb4g_2km |
| Topografía (5) | alt_media_m, alt_max_m, pendiente_deg, tpi_m, visibilidad_km2 |
| Beneficio social (6) | poblacion, densidad_hab_km2, pob_sin_4g, n_escuelas, n_salud, n_urbanizaciones |
| Viabilidad (4) | dist_via_m, uso_suelo, conductividad_S_m, exclusion |
| y | etiqueta: 1 = adecuada, 0 = no adecuada |
4 + 5 + 6 + 4 = 19 variables. bloque_ent es la columna
bloque de las celdas de entrenamiento: indica a qué cuadro de 1 km pertenece cada una.
Los hiperparámetros son las "perillas" del bosque que no aprende por sí solo: cuántos árboles, qué tan profundos y cuántas variables mira cada pregunta. Aquí se prueban todas las combinaciones de perillas y se queda la que mejor predice en zonas de El Valle que el modelo no vio.
Se arma una lista de valores para cada perilla (3 × 3 × 2 = 18 combinaciones). Cada una se entrena y se mide 10 veces, dejando fuera cada vez un grupo distinto de bloques de 1 km, y se promedia su F1. Gana la que tiene el F1 medio más alto.
n_estimators: 100, 300, 500max_depth: 10, 20, sin límiteNone: el árbol crece hasta que cada hoja
tiene celdas de una sola clase.max_features: \(\sqrt{p}\), \(p/3\)Cada casilla es un grupo de bloques de 1 km. Ronda 3 de 10: el grupo 3 (naranja) se usa para medir y los otros nueve para entrenar. En la siguiente ronda le toca al grupo 4, y así hasta el 10.
Para cada combinación \(\theta\) se promedia el F1 de las 10 rondas y se elige la mejor:
$$\overline{F1}(\theta) = \frac{1}{k}\sum_{j=1}^{k} F1_j(\theta) \qquad \theta^{*} = \arg\max_{\theta}\ \overline{F1}(\theta)$$Probar todo tiene un costo de cómputo alto (180 bosques de hasta 500 árboles por cada una de las 3 mallas); es la misma limitación que señalan Yuliana et al.
| Puesto | n_estimators | max_depth | max_features | F1 medio (10 grupos) |
|---|---|---|---|---|
| 1 | 300 | 20 | sqrt (4) | 0.66 ± 0.07 |
| 2 | 500 | 20 | sqrt (4) | 0.66 ± 0.08 |
| 3 | 300 | sin límite | p/3 (6) | 0.63 ± 0.09 |
Valores ilustrativos. El "±" es la desviación entre grupos: si es grande, el modelo acierta en unas zonas de El Valle y falla en otras.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, GroupKFold
# Bloque de 1 km × 1 km de cada celda (2.8), a partir de su centro en UTM
bloque = ((tabla["x_utm"] // 1000).astype(int).astype(str) + "_" +
(tabla["y_utm"] // 1000).astype(int).astype(str))
bloque_ent = bloque.loc[X_ent.index] # solo las celdas del 70 %
rejilla = {
"n_estimators": [100, 300, 500],
"max_depth": [10, 20, None], # None = sin límite
"max_features": ["sqrt", 1/3], # √19 → 4 variables · 19/3 → 6 variables
}
busqueda = GridSearchCV(
RandomForestClassifier(class_weight="balanced", random_state=42),
rejilla,
scoring="f1", # se optimiza el F1
cv=GroupKFold(n_splits=10), # validación cruzada espacial, k = 10
n_jobs=-1, # usa todos los núcleos del procesador
)
busqueda.fit(X_ent, y_ent, groups=bloque_ent) # 18 × 10 = 180 entrenamientos
print(busqueda.best_params_, round(busqueda.best_score_, 3))
Las celdas adecuadas son pocas —y si el perfil de sitio viable de 2.7 sale muy
estrecho, todavía menos—, así que algún grupo podría quedarse sin ninguna. En ese caso se puede
usar StratifiedGroupKFold, que respeta los bloques y además reparte las celdas
adecuadas entre los 10 grupos; y si siguen siendo demasiado pocas, se amplían los percentiles de
la regla de etiquetado.
Con las perillas ya elegidas, el bosque aprende en serio usando el 70 % de las celdas. De paso, cada árbol se pone a prueba con las celdas que no le tocaron: ese es el error OOB.
Se construye un bosque nuevo con los hiperparámetros \(\theta^{*}\) de 3.3, se entrena con todo el 70 % de entrenamiento y se pide a scikit-learn que calcule el error OOB.
n_estimators,
max_depth y max_features que ganaron la búsqueda en malla de 3.3
(\(\theta^{*}\)).Probabilidad de que una celda quede fuera de la bolsa de un árbol, con \(n\) celdas de entrenamiento:
$$P(\text{fuera}) = \left(1 - \frac{1}{n}\right)^{n} \;\xrightarrow{\;n \to \infty\;}\; e^{-1} \approx 0.368$$Error OOB, donde \(\hat{y}_i^{\,OOB}\) es el voto de los árboles que no vieron la celda \(i\) y \(\mathbb{1}[\cdot]\) suma 1 por cada celda mal clasificada:
$$\text{err}_{OOB} = \frac{1}{n}\sum_{i=1}^{n} \mathbb{1}\left[\hat{y}_i^{\,OOB} \neq y_i\right]$$Con celdas de 250 m hay unas 688 celdas y la tabla las incluye todas: 481 para entrenar (70 %) y 207 para la prueba (30 %; scikit-learn redondea la prueba hacia arriba, como en 2.8). Cada árbol deja fuera unas \(481 \times 0.368 \approx 177\) celdas.
La bolsa del árbol 1 podría ser: C0012, C0012, C0045, C0101, C0101, C0101, C0233… (C0012 entra dos veces, C0101 tres veces). Las que nunca aparecen, como C0421, son OOB para ese árbol.
Si, al predecir cada celda solo con los árboles que no la vieron, fallan 26 de las 481: \(\text{err}_{OOB} = 26 / 481 \approx 5.4\%\).
Números ilustrativos.
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
# Partición de 2.8 (se hace una sola vez por tabla, antes de 3.2)
X_ent, X_prueba, y_ent, y_prueba = train_test_split(
X, y, test_size=0.30, stratify=y, random_state=42) # 70 % / 30 %
modelo = RandomForestClassifier(
**busqueda.best_params_, # hiperparámetros elegidos en 3.3
class_weight="balanced",
oob_score=True, # activa el cálculo OOB
random_state=42,
).fit(X_ent, y_ent)
print("Exactitud OOB:", round(modelo.oob_score_, 3))
print("Error OOB: ", round(1 - modelo.oob_score_, 3))
Es el examen final: se le muestran al modelo celdas que nunca vio (el 30 % guardado) y se cuentan sus aciertos y errores de varias maneras, porque cada medida responde una pregunta distinta.
El bosque de 3.4 clasifica cada celda de prueba (0 o 1) y le da una puntuación entre 0 y 1. Se compara con la etiqueta real y se calculan la matriz de confusión y las cinco medidas del bloque.
Un cuidado: el 30 % de prueba se separó al azar (estratificado), no por bloques, así que tiene celdas vecinas de las de entrenamiento y su F1 puede salir algo optimista. Por eso las decisiones (el rombo del F1 y el paso 3.9) se toman con el F1 de la validación espacial, y la prueba solo confirma.
La última forma dice que el AUC es la probabilidad de que una celda adecuada tomada al azar reciba más puntuación que una no adecuada tomada al azar.
207 celdas de prueba (malla de 250 m): 36 adecuadas y 171 no adecuadas, como en el ejemplo de 2.8.
¿Por qué no basta la exactitud? Un "modelo" que siempre dijera "no adecuada" acertaría 171 de 207 celdas: 82.6 % de exactitud. Pero no recomendaría ningún sitio: su F1 es 0.
Como referencia, en el estado del arte Sakti et al. obtuvieron un AUC de 0.73 al ubicar radiobases con Random Forest, y Niu et al. una exactitud del 89.6 % al ubicar estaciones de metro.
La matriz es ilustrativa. Junto a estas métricas se anota el error OOB de 3.4: si se parece al error de prueba, el modelo es estable.
from sklearn.metrics import confusion_matrix, classification_report, roc_auc_score
y_pred = modelo.predict(X_prueba) # 0 o 1 para cada celda
y_prob = modelo.predict_proba(X_prueba)[:, 1] # puntuación entre 0 y 1
print(confusion_matrix(y_prueba, y_pred)) # ojo: scikit-learn pone primero la clase 0
# [[VN FP]
# [FN VP]]
print(classification_report(y_prueba, y_pred,
target_names=["no adecuada", "adecuada"]))
print("AUC-ROC: ", round(roc_auc_score(y_prueba, y_prob), 3))
print("Error OOB:", round(1 - modelo.oob_score_, 3))
Es un control de calidad: si el último ajuste superó al anterior por un margen claro, el modelo se acepta y se sigue adelante. Si no lo superó, se vuelven a ajustar las perillas con otros valores.
Los rombos del diagrama son decisiones: de ellos salen dos caminos. En este:
| Ronda | Qué se cambió | F1 medio | ΔF1 | Decisión |
|---|---|---|---|---|
| 0 | Modelo base (3.2) | 0.61 | — | Referencia |
| 1 | Rejilla de 3.3 (18 combinaciones) | 0.66 | +0.05 | Sí: continuar a 3.6 |
Valores ilustrativos. Si la ronda 1 hubiera dado 0.615 (+0.005), la respuesta
sería No y se probaría otra rejilla con valores que no están en la de 3.3, por ejemplo
n_estimators = 800 o max_depth = 30.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, GroupKFold
TOLERANCIA = 0.01 # mejora mínima de F1 (valor de partida)
MAX_RONDAS = 3 # tope propuesto para no ajustar sin fin
rf = RandomForestClassifier(class_weight="balanced", random_state=42)
f1_anterior = f1_base # ronda 0: el modelo base (3.2)
for ronda, rejilla in enumerate(rejillas_a_probar[:MAX_RONDAS], start=1):
busqueda = GridSearchCV(rf, rejilla, scoring="f1", n_jobs=-1,
cv=GroupKFold(n_splits=10)).fit(X_ent, y_ent, groups=bloque_ent)
delta = busqueda.best_score_ - f1_anterior
print(f"Ronda {ronda}: F1 = {busqueda.best_score_:.3f} (ΔF1 = {delta:+.3f})")
if delta >= TOLERANCIA: # "Sí": pasa a 3.6
break
f1_anterior = max(f1_anterior, busqueda.best_score_) # "No": vuelve a 3.3
Con el modelo ya aceptado, se le pregunta: ¿en qué te fijaste más para decidir? Así se sabe cuánto pesan el déficit 4G, el relieve o la población en cada recomendación.
Se calculan dos rankings de las 19 variables: uno que el bosque trae de fábrica (reducción de Gini) y otro que se obtiene desordenando cada variable y viendo cuánto empeora el F1 (permutación). Si los dos coinciden en las primeras posiciones, la conclusión es sólida.
poblacion y n_salud.MDI de la variable \(j\): se suman las reducciones de Gini \(\Delta G(t)\) en los nodos \(t\) que preguntan por \(j\), ponderadas por la fracción de celdas \(n_t / n\) que pasan por ese nodo, y se promedia en los \(B\) árboles:
$$\text{MDI}(j) = \frac{1}{B}\sum_{b=1}^{B}\ \sum_{t \in b\,:\,v(t) = j} \frac{n_t}{n}\,\Delta G(t)$$Permutación de la variable \(j\), promediada en las 10 partes \(f\) de la validación espacial:
$$\text{Imp}_{\text{perm}}(j) = \frac{1}{10}\sum_{f=1}^{10}\Big[F1_f\big(\text{datos originales}\big) - F1_f\big(\text{datos con } x_j \text{ desordenada}\big)\Big]$$MDI ilustrativo; los valores de las 19 variables suman 1. Si al desordenar
deficit_4g el F1 cae de 0.66 a 0.41, su importancia por permutación es 0.25.
Un cuidado al leerlo: aunque en 2.6 se quitan los pares casi iguales
(\(|\rho| \gt 0.9\)), quedan variables bastante parecidas, por ejemplo dist_rb4g_km
y n_rb4g_2km (\(\rho \approx -0.86\) en el ejemplo de 2.5) o poblacion y
pob_sin_4g. Esas variables se reparten la importancia y cada una parece menos
importante de lo que es: al desordenar una, el bosque todavía puede apoyarse en la otra.
Por eso conviene leer el ranking por grupos (cobertura 4G, topografía, beneficio social y
viabilidad) y no solo variable por variable.
import numpy as np
import pandas as pd
from sklearn.base import clone
from sklearn.inspection import permutation_importance
from sklearn.model_selection import GroupKFold
# 1) Reducción media del índice de Gini (viene calculada dentro del modelo)
imp_gini = pd.Series(modelo.feature_importances_, index=X.columns)
print(imp_gini.sort_values(ascending=False).head(10))
# 2) Permutación en las partes de validación espacial (dentro del 70 %):
# cuánto cae el F1 al desordenar cada columna (20 veces cada una)
imps = []
for ent, val in GroupKFold(n_splits=10).split(X_ent, y_ent, groups=bloque_ent):
m = clone(modelo).fit(X_ent.iloc[ent], y_ent.iloc[ent])
r = permutation_importance(m, X_ent.iloc[val], y_ent.iloc[val],
scoring="f1", n_repeats=20, random_state=42)
imps.append(r.importances_mean)
imp_perm = pd.Series(np.mean(imps, axis=0), index=X.columns)
print(imp_perm.sort_values(ascending=False).head(10))
Si unas pocas variables explican casi todo, se entrena un modelo más simple solo con ellas y se comprueba que no pierde calidad. Además, se compara contra un árbol solitario para demostrar que usar un bosque vale la pena.
El margen de 0.01 es el mismo del rombo del F1 y es un valor de partida.
| Modelo | Variables | F1 (VC espacial) | F1 (prueba) |
|---|---|---|---|
| Random Forest completo | 19 | 0.66 | 0.67 |
| Random Forest reducido | 8 | 0.66 | 0.66 |
| Árbol CART (línea base) | 8 | 0.55 | 0.55 |
Valores ilustrativos. Aquí el modelo reducido no pierde nada en la validación espacial y solo 0.01 en la prueba, con 11 variables menos, y el bosque le saca 0.11 al árbol solitario.
from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GroupKFold, cross_val_score
top = list(imp_perm.sort_values(ascending=False).head(8).index) # p_r = 8: las más influyentes
def f1_espacial(estimador, columnas):
"""F1 medio de la validación cruzada espacial (10 grupos de bloques de 1 km)."""
return cross_val_score(estimador, X_ent[columnas], y_ent, groups=bloque_ent,
cv=GroupKFold(n_splits=10), scoring="f1").mean()
rf_red = RandomForestClassifier(**busqueda.best_params_, class_weight="balanced",
oob_score=True, random_state=42)
cart = DecisionTreeClassifier(class_weight="balanced", random_state=42)
f1_completo = busqueda.best_score_ # ya calculado en 3.3
f1_reducido = f1_espacial(rf_red, top)
f1_cart = f1_espacial(cart, top)
print(f"RF {X.shape[1]} var.: {f1_completo:.3f} · RF {len(top)} var.: {f1_reducido:.3f} · CART: {f1_cart:.3f}")
if f1_completo - f1_reducido <= 0.01: # se queda el modelo reducido
modelo_final, variables_finales = rf_red.fit(X_ent[top], y_ent), top
else: # se queda el modelo completo
modelo_final, variables_finales = modelo, list(X.columns)
Se anota todo lo que dio esta malla en una tabla común, para compararla después con las otras dos y para poder repetir el experimento más adelante.
Se agrega una fila a la tabla de resultados y se guarda en disco el modelo final junto con la lista de variables que usa. Así, en 3.9 se puede elegir el mejor sin volver a entrenar nada.
n_estimators,
max_depth y max_features (3.3).registro, con una fila por malla.
Al terminar el ciclo tiene 3 filas y se guarda como CSV.registro| celda_m | n_celdas | n_estimators | max_depth | max_features | n_var | f1_cv | auc_cv | f1_prueba | auc_prueba | err_oob |
|---|---|---|---|---|---|---|---|---|---|---|
| 100 | 4 303 | 500 | None | 1/3 | 11 | 0.58 | 0.80 | 0.57 | 0.79 | 0.081 |
| 250 | 688 | 300 | 20 | sqrt | 8 | 0.66 | 0.85 | 0.66 | 0.85 | 0.054 |
| 500 | 172 | 300 | 10 | sqrt | 6 | 0.61 | 0.82 | 0.59 | 0.81 | 0.062 |
Valores ilustrativos. Al final del ciclo la tabla tiene exactamente estas 3 filas, una por malla, y con ellas se decide en 3.9.
import joblib
import pandas as pd
from sklearn.metrics import f1_score, roc_auc_score
from sklearn.model_selection import GroupKFold, cross_validate
registro = [] # se crea una vez, antes del ciclo de las mallas
cv = cross_validate(modelo_final, X_ent[variables_finales], y_ent, groups=bloque_ent,
cv=GroupKFold(n_splits=10), scoring=["f1", "roc_auc"])
registro.append({
"celda_m": tamano, "n_celdas": len(tabla),
**busqueda.best_params_,
"n_var": len(variables_finales),
"variables": ", ".join(variables_finales),
"f1_cv": cv["test_f1"].mean(),
"auc_cv": cv["test_roc_auc"].mean(),
"f1_prueba": f1_score(y_prueba, modelo_final.predict(X_prueba[variables_finales])),
"auc_prueba": roc_auc_score(y_prueba,
modelo_final.predict_proba(X_prueba[variables_finales])[:, 1]),
"err_oob": 1 - modelo_final.oob_score_,
})
joblib.dump({"modelo": modelo_final, "variables": variables_finales},
f"modelos/rf_{tamano}m.joblib") # modelo + sus variables
pd.DataFrame(registro).to_csv("resultados_etapa3.csv", index=False)
registro y el modelo guardado en disco.Es el contador del ciclo: mientras falte alguna de las 3 mallas, se vuelve al inicio de la etapa con la siguiente. Cuando ya están las tres, se pasa a elegir la mejor.
registro, porque
3.9 solo compara filas completas.| Vuelta | Malla | Estado |
|---|---|---|
| 1 | 100 m | ✔ registrada |
| 2 | 250 m | ✔ registrada (recién) |
| 3 | 500 m | pendiente |
En esta situación la respuesta es "No": queda la malla de 500 m, así que el flujo vuelve a 3.1 y la toma.
Es decir: ¿la tabla de resultados ya tiene 3 filas?
En código, el rombo es simplemente el final del ciclo for: Python pasa a la
siguiente malla ("No") hasta que se acaban ("Sí").
for tamano in mallas: # 3 vueltas: 100, 250 y 500 m
... # pasos 3.2 a 3.8
registro.append(fila)
assert len(registro) == 3 # "Sí": todas evaluadas
tabla = pd.DataFrame(registro) # pasa a 3.9
registro con las mallas ya evaluadas.De los 3 modelos —uno por malla— se elige el que mejor encuentra celdas adecuadas en zonas que no vio: el de mayor F1 en la validación espacial y, si hay empate, el de mayor AUC-ROC.
Se ordena la tabla de resultados por F1 de la validación espacial, de mayor a menor, y se toma la primera fila. Luego se carga desde el disco el modelo guardado de esa malla, con su lista de variables.
f1_cv) y no el de una sola partición, porque es más estable y representa
mejor lo que pasará en zonas nuevas.| Malla | Celdas (aprox.) | Variables | F1 (VC espacial) | AUC-ROC (VC espacial) |
|---|---|---|---|---|
| 100 m | ≈ 4 303 | 11 | 0.58 | 0.80 |
| 250 m | ≈ 688 | 8 | 0.66 | 0.85 |
| 500 m | ≈ 172 | 6 | 0.61 | 0.82 |
Valores ilustrativos; en este caso ganaría la malla de 250 m. Si la de 250 m y la de 500 m hubieran tenido ambas 0.66, habría ganado la de 250 m por su AUC-ROC (0.85 frente a 0.82).
import joblib
import pandas as pd
tabla = pd.DataFrame(registro)
tabla["f1_redondeado"] = tabla["f1_cv"].round(2) # empate = mismo F1 con dos decimales
ganadora = tabla.sort_values(["f1_redondeado", "auc_cv"], ascending=False).iloc[0]
print(ganadora[["celda_m", "n_var", "f1_cv", "auc_cv"]])
paquete = joblib.load(f"modelos/rf_{ganadora.celda_m}m.joblib")
mejor, variables = paquete["modelo"], paquete["variables"]
registro con las 3 mallas.El modelo ganador le pone una nota de 0 a 1 a cada celda de El Valle: qué tan adecuada es para una nueva radiobase 4G. Pintadas sobre el mapa, esas notas forman el mapa de idoneidad, el tercer producto de la metodología.
Se toma la malla ganadora completa (todas sus celdas, no solo las de entrenamiento), se le pasan sus variables al modelo y se guarda, para cada celda, la fracción de árboles que votan "adecuada". El resultado se exporta como capa geográfica.
donde \(B\) es el número de árboles, \(h_b(c)\) el voto del árbol \(b\) (1 = adecuada) y \(\mathbb{1}[\cdot]\) vale 1 si ese árbol votó "adecuada" y 0 si no.
Un detalle técnico: en scikit-learn, predict_proba promedia las
probabilidades de las hojas de cada árbol. Cuando los árboles crecen hasta tener hojas puras, eso
coincide exactamente con la fracción de votos. Si se limita max_depth, es una versión
"suavizada" de la misma idea.
La celda C0421 recibe 365 votos de 500 → \(s = 365/500 = 0.73\) → se pinta como "alta".
import geopandas as gpd
malla = gpd.read_file(f"malla_{ganadora.celda_m}m.gpkg") # todas las celdas de El Valle
malla["idoneidad"] = mejor.predict_proba(malla[variables])[:, 1] # columna 1 = "adecuada"
malla.to_crs(4326).to_file(f"idoneidad_{ganadora.celda_m}m.geojson",
driver="GeoJSON") # para la web (Etapa 5)
| id_celda | geometry | deficit_4g | exclusion | idoneidad |
|---|---|---|---|---|
| C0421 | POLYGON ((727250 9675000, …)) | 0.85 | 0 | 0.73 |
| C0422 | POLYGON ((727500 9675000, …)) | 0.10 | 0 | 0.12 |
| C0517 | POLYGON ((727500 9670250, …)) | 1.00 | 1 | 0.31 |
Valores ilustrativos, en coordenadas UTM 17S (EPSG:32717); la esquina que se muestra es la inferior izquierda de cada cuadrado de 250 m.
No toda celda con buena nota sirve. Se descartan las que ya tienen buena cobertura 4G (no tiene sentido poner una radiobase donde no hace falta) y las que están en zonas donde no se puede construir.
Se aplican dos condiciones a la vez sobre el mapa de idoneidad. Las celdas que cumplen las dos son las candidatas; las demás salen de la competencia, por buena que sea su nota.
deficit_4g: fracción del área de la celda donde,
según los mapas predictivos 4G, la señal es débil (RSRP menor de −120 dBm, nivel "baja") o no hay
servicio.exclusion = 0. Las zonas de
exclusión son lugares donde no se puede o no conviene instalar: áreas protegidas, cuerpos de
agua, pendientes extremas. Se marcan como variable en la Etapa 2.\(A(\cdot)\) es un área en m² y \(\mathcal{C}\) es el conjunto de celdas candidatas.
| Celda | idoneidad | deficit_4g | exclusion | ¿Candidata? |
|---|---|---|---|---|
| C0421 | 0.73 | 0.85 | 0 | Sí |
| C0388 | 0.81 | 0.20 | 0 | No: ya tiene buena señal |
| C0517 | 0.31 | 1.00 | 1 | No: zona de exclusión |
| C0630 | 0.64 | 0.55 | 0 | Sí |
Valores ilustrativos. C0388 tiene muy buena nota, pero ahí la cobertura 4G ya es buena.
UMBRAL_DEFICIT = 0.5 # 50 % de la celda (valor de partida)
candidatas = malla[(malla["deficit_4g"] >= UMBRAL_DEFICIT) &
(malla["exclusion"] == 0)].copy()
print(f"{len(candidatas)} de {len(malla)} celdas son candidatas")
Se ordenan las candidatas de mayor a menor nota y se eligen las \(N\) mejores, cuidando que estén separadas entre sí: dos radiobases pegadas cubrirían casi la misma zona.
Como las coordenadas están en UTM (EPSG:32717, en metros), la distancia se calcula directamente en metros.
| Orden | Celda | idoneidad | Distancia al sitio más cercano ya elegido | Resultado |
|---|---|---|---|---|
| 1 | C0421 | 0.73 | — | Sitio 1 |
| 2 | C0420 | 0.71 | 0.25 km | Se salta (muy cerca) |
| 3 | C0630 | 0.64 | 2.15 km | Sitio 2 |
| 4 | C0655 | 0.62 | 0.90 km | Se salta (muy cerca) |
| 5 | C0709 | 0.58 | 1.90 km | Sitio 3 |
Valores ilustrativos. C0420 es la celda vecina de C0421 (a 250 m): tiene casi la misma nota, pero cubriría casi la misma zona.
import numpy as np
import pandas as pd
def elegir_sitios(candidatas, N, d_min=1500):
"""Elige N celdas de mayor idoneidad, separadas al menos d_min metros."""
orden = candidatas.sort_values("idoneidad", ascending=False)
elegidos = []
for _, c in orden.iterrows():
lejos = all(np.hypot(c.x_utm - e.x_utm, c.y_utm - e.y_utm) >= d_min
for e in elegidos)
if lejos:
elegidos.append(c)
if len(elegidos) == N:
break
return pd.DataFrame(elegidos)
candidatas["x_utm"] = candidatas.geometry.centroid.x # centro de cada celda (m)
candidatas["y_utm"] = candidatas.geometry.centroid.y
sitios = elegir_sitios(candidatas, N=3)
Es el resultado de toda la etapa: las coordenadas de 1, 2 y 3 nuevas radiobases 4G que el modelo propone para El Valle. Pasan a la Etapa 4 para comprobar en Radio Mobile cuánto mejoran realmente la cobertura.
| sitio | se usa en | id_celda | x_utm | y_utm | lat | lon | idoneidad | alt_media_m |
|---|---|---|---|---|---|---|---|---|
| 1 | N = 1, 2 y 3 | C0421 | 727 375 | 9 675 125 | −2.9373 | −78.9544 | 0.73 | 2 635 |
| 2 | N = 2 y 3 | C0630 | 728 625 | 9 676 875 | −2.9215 | −78.9432 | 0.64 | 2 652 |
| 3 | N = 3 | C0709 | 726 625 | 9 673 375 | −2.9532 | −78.9611 | 0.58 | 2 677 |
Sitios ilustrativos. Las tres celdas están dentro del límite de El Valle, su latitud y longitud son la conversión exacta de sus coordenadas UTM y la altitud es la media del DEM Copernicus en cada celda de 250 m.
import geopandas as gpd
sitios["sitio"] = range(1, len(sitios) + 1) # 1, 2, 3 en orden de nota
puntos = gpd.GeoDataFrame(
sitios,
geometry=gpd.points_from_xy(sitios.x_utm, sitios.y_utm),
crs=32717, # UTM 17S
).to_crs(4326) # a latitud / longitud (WGS 84)
sitios["lat"], sitios["lon"] = puntos.geometry.y, puntos.geometry.x
sitios.to_csv("ubicaciones_recomendadas.csv", index=False) # entrada de la Etapa 4
El círculo con la letra A es un atajo del diagrama: une este punto con otro lejano sin dibujar una flecha que cruce toda la página. Aquí, justo encima de 3.3, llega el flujo que regresa desde la Etapa 4.
En la Etapa 4, después de 4.6 Comparar los escenarios, un rombo pregunta: ¿el Escenario 1 cubre más área y población que el 2 y el 3? Si la respuesta es No, el flujo sale por otro círculo A (con la nota "reajustar el modelo (3.3)") y entra aquí, a 3.3 Ajustar los hiperparámetros. Es decir, el modelo se reajusta y se vuelven a recomendar ubicaciones.
Para cada \(N\), con \(A^{(e)}_N\) = área de El Valle con RSRP ≥ −120 dBm (km²) y \(P^{(e)}_N\) = habitantes con cobertura en el escenario \(e\):
$$\text{Sí} \iff A^{(1)}_N \gt \max\big(A^{(2)}_N,\, A^{(3)}_N\big) \;\;\text{y}\;\; P^{(1)}_N \gt \max\big(P^{(2)}_N,\, P^{(3)}_N\big)$$Si no se cumple, se vuelve por A. Se propone exigirlo para \(N = 1, 2\) y \(3\).
MAX_VUELTAS = 3 # tope propuesto para no ciclar sin fin
for vuelta in range(1, MAX_VUELTAS + 1):
modelo = ajustar_y_entrenar(X, y, rejilla) # Etapa 3, desde 3.3
sitios = recomendar(modelo, N=3) # 3.9 a 3.11: Producto 4
res = simular_escenarios(sitios) # Etapa 4, en Radio Mobile (fuera de Python)
# res: una fila por escenario y N, con columnas area_km2 y poblacion
t = res.pivot(index="N", columns="escenario", values=["area_km2", "poblacion"])
gana = ((t["area_km2"][1] > t["area_km2"][[2, 3]].max(axis=1)) &
(t["poblacion"][1] > t["poblacion"][[2, 3]].max(axis=1))).all()
if gana:
break # "Sí": se sigue a la Etapa 5
rejilla = ampliar(rejilla) # "No": regreso por el conector A
Aquí se comprueba si las ubicaciones que recomienda el modelo de IA mejoran de verdad la cobertura 4G de El Valle. Para eso se usa Radio Mobile, un simulador que calcula la señal a partir del relieve real, y se comparan tres formas de elegir los sitios: el modelo, el criterio tradicional y el azar.
El Random Forest de la Etapa 3 aprendió a reconocer celdas "parecidas a un buen sitio", pero nunca calculó cuánta señal saldría desde ahí. Una celda puede tener muy buena nota y, aun así, quedar tapada por un cerro. Hace falta un árbitro independiente que use la física de las ondas de radio: Radio Mobile.
Al árbitro también hay que ponerlo a prueba. Antes de simular radiobases nuevas, se simulan las que ya existen y se comprueba que el resultado se parezca al mapa 4G de la operadora. Solo cuando la simulación reproduce lo que ya se conoce se confía en ella para predecir lo que todavía no existe.
| Simulación | Radiobases que incluye | Para qué sirve |
|---|---|---|
| Escenario 0 | Solo las 4G existentes: Claro, Movistar, CNT y las tres juntas | Calibrar y servir de "antes" |
| Escenario 1 (N = 1, 2, 3) | Existentes + sitios del Random Forest | Probar el modelo |
| Escenario 2 (N = 1, 2, 3) | Existentes + los puntos más altos con déficit | Compararlo con el criterio tradicional |
| Escenario 3 (N = 1, 2, 3) | Existentes + sitios al azar con déficit (5 sorteos) | Control: lo que se logra sin criterio |
Son 3 escenarios × 3 valores de \(N\) = 9 combinaciones; como el Escenario 3 se sortea 5 veces, dan 21 simulaciones. El Escenario 0 son 4 (una por operadora y una con las tres juntas). A estas 25 se suman las vueltas de calibración.
Radio Mobile se maneja desde su propia ventana. Python prepara lo que entra y analiza lo que sale:
# Funciones esquemáticas: cada una se detalla en el paso que se indica
# 4.1 Entradas para Radio Mobile
unidades = preparar_radiobases("el_valle_etapa1.gpkg") # parámetros del RFE-1A y del SMA-RT-008
unidades.to_csv("unidades_radio_mobile.csv", index=False)
# 4.2 – 4.3 Lazo de calibración, por operadora (cada simulación se corre en Radio Mobile)
for op in ("claro", "movistar", "cnt"):
for vuelta in range(1, 6): # máximo 5 vueltas (valor de partida)
rsrp0, t = leer_simulacion(f"rsrp_esc0_{op}.tif") # ráster de RSRP exportado
C, sesgo, matriz, _ = concordancia(rsrp0, t, malla_op[op]) # rombo "¿coincide?"
if C >= 0.80:
break # "Sí": simulación calibrada
ajustar_parametros(op, sesgo) # "No": 4.3, y se vuelve a simular
# 4.4 – 4.6 Escenarios, métricas y comparación
sitios = elegir_por_escenario(candidatas) # modelo, más altos y azar
metricas = medir_todos(sitios, base="rsrp_esc0.tif") # base: las tres operadoras juntas
tabla = comparar(metricas) # ¿gana el Escenario 1?
Radio Mobile es un programa gratuito que calcula, punto por punto, cuánta señal llega desde cada antena teniendo en cuenta el relieve. En este paso se le cargan el terreno, el modelo de propagación, los datos de cada radiobase 4G y el umbral que separa "hay cobertura" de "no hay".
Para cada punto del mapa, el modelo repite los mismos pasos:
Un detalle físico: la difracción es la capacidad de la onda de curvarse en el borde de un obstáculo y llegar detrás de él. Alrededor de la línea recta entre antenas hay una zona en forma de huso, la primera zona de Fresnel, que lleva casi toda la energía; si un cerro la invade, aparecen pérdidas aunque desde la antena "se vea" el punto. Por eso, en El Valle, el perfil del terreno pesa más que la distancia. Mlinar et al. (2023) encontraron que Longley-Rice fue el modelo más exacto en VHF (de 30 a 300 MHz; desviación promedio de 4.1 dB), pero que en UHF (de 300 MHz a 3 GHz), donde están las bandas LTE, tiende a sobrestimar la atenuación sin línea de vista.
PIRE, en las tres unidades habituales:
$$\text{PIRE}\,[\text{dBm}] = P_{TX}\,[\text{dBm}] - L_{\text{cables}}\,[\text{dB}] + G_{TX}\,[\text{dBi}]$$ $$\text{PIRE}\,[\text{dBW}] = \text{PIRE}\,[\text{dBm}] - 30 \qquad \text{PIRE}\,[\text{W}] = P_{TX}\,[\text{W}] \cdot 10^{\,(G_{TX} - L_{\text{cables}})/10} = \frac{10^{\,\text{PIRE}[\text{dBm}]/10}}{1000}$$Pasar a RSRP. El RSRP mide la potencia de un elemento de recurso, no la del canal completo. Si la potencia se reparte por igual entre las \(12\,N_{RB}\) subportadoras del canal (\(N_{RB}\) = número de bloques de recursos: 50 en 10 MHz, 100 en 20 MHz):
$$P_{TX,RE} = P_{TX} - 10\log_{10}\!\left(12\,N_{RB}\right) \qquad \text{PIRE}_{RE} = P_{TX,RE} - L_{\text{cables}} + G_{TX} = \text{PIRE} - 10\log_{10}\!\left(12\,N_{RB}\right)$$ $$\text{RSRP} \approx \text{PIRE}_{RE} - L_b + G_{RX}$$\(G_{RX}\) es la ganancia de la antena del teléfono (0 dBi) y \(L_b\) la pérdida del trayecto. Radio Mobile calcula la potencia de todo lo que emite el transmisor; por eso, como potencia del transmisor se le carga \(P_{TX,RE}\): con 43 dBm y 10 MHz, \(43 - 27.8 = 15.2\) dBm. Así, el nivel que muestra Radio Mobile ya es el RSRP y se compara directamente con −120 dBm. La otra opción sería cargar 43 dBm y usar un umbral equivalente de \(-120 + 27.8 = -92.2\) dBm.
La pérdida \(L_b\) según Longley-Rice: espacio libre (fórmula de Friis, con \(f\) en MHz y \(d\) en km) más una atenuación que depende del terreno:
$$L_b = \underbrace{32.45 + 20\log_{10} f + 20\log_{10} d}_{L_{bf}\ \text{(espacio libre)}} + \underbrace{A_{\text{ref}}\big(d,\, h_{e1},\, h_{e2},\, \Delta h,\, f,\, N_s,\, \sigma,\, \varepsilon_r\big)}_{\text{atenuación por el terreno}}$$donde \(h_{e1}, h_{e2}\) son las alturas efectivas de las antenas (la altura de cada antena sobre el terreno medio que la rodea), \(N_s\) la refractividad del aire, \(\sigma\) la conductividad y \(\varepsilon_r\) la permitividad relativa del suelo.
Un obstáculo como filo de cuchillo (la versión más simple de la difracción): \(h\) es cuánto sobresale el obstáculo de la línea recta, y \(d_1\), \(d_2\) sus distancias a cada extremo:
$$\nu = h\sqrt{\frac{2\,(d_1 + d_2)}{\lambda\, d_1\, d_2}} \qquad J(\nu) \approx 6.9 + 20\log_{10}\!\left(\sqrt{(\nu - 0.1)^2 + 1} + \nu - 0.1\right)\ \text{dB}$$\(\lambda = c/f\) es la longitud de onda (a 1900 MHz, \(3\times10^{8}/1.9\times10^{9} = 0.158\) m); \(\nu\) es el parámetro de difracción, un número sin unidades que dice cuánto invade el obstáculo la primera zona de Fresnel; y \(J(\nu)\) es la pérdida extra por difracción, en dB. La aproximación vale para \(\nu \gt -0.78\); por debajo, el obstáculo casi no estorba y la pérdida es prácticamente 0.
1. PIRE. Un sector con los valores más frecuentes del RFE-1A: 43 dBm y 17 dBi, sin pérdidas de cable declaradas: \(\text{PIRE} = 43 + 17 = 60\) dBm \(= 30\) dBW. En vatios: \(20\ \text{W} \times 50 = 1\,000\ \text{W}\).
2. Por elemento de recurso. Con un canal de 10 MHz (50 bloques, 600 subportadoras): \(\text{PIRE}_{RE} = 60 - 10\log_{10}600 = 60 - 27.8 = 32.2\) dBm. Es lo mismo que cargar en Radio Mobile \(P_{TX,RE} = 43 - 27.8 = 15.2\) dBm: \(15.2 + 17 = 32.2\) dBm.
3. Espacio libre. Teléfono a 2 km, a 1900 MHz: \(L_{bf} = 32.45 + 65.58 + 6.02 = 104.1\) dB.
4. Un cerro en medio. A 1.2 km de la antena (y 0.8 km del teléfono) un cerro sobresale 20 m de la línea recta. Con \(\lambda = 0.158\) m: \(\nu = 3.25\) y \(J \approx 23.1\) dB.
5. Resultado. \(\text{RSRP} \approx 32.2 - 104.1 - 23.1 + 0 = -95.0\) dBm: nivel "alta" (≥ −100 dBm). Si se olvidara el paso 2 (es decir, si se cargaran 43 dBm en Radio Mobile), saldría \(60 - 104.1 - 23.1 = -67.2\) dBm: 27.8 dB (≈ 28 dB) demasiado optimista.
Potencia y ganancia son los valores más frecuentes del RFE-1A; el ancho de banda, la distancia y el cerro son ilustrativos. La fórmula del filo de cuchillo sirve para entender la idea: Longley-Rice hace un cálculo más completo sobre todo el perfil.
| Nivel (mapas 4G) | RSRP | ¿Cuenta como cobertura? |
|---|---|---|
| Alta | ≥ −100 dBm | Sí |
| Media | −120 a −100 dBm | Sí |
| Baja | −140 a −120 dBm | No: déficit 4G |
| Sin servicio | < −140 dBm | No: déficit 4G |
| Parámetro | Valor | De dónde sale |
|---|---|---|
| Relieve | SRTM de 1 segundo de arco (≈ 30 m) | Radio Mobile |
| Modelo de propagación | Longley-Rice (ITM) | Radio Mobile |
| Frecuencia | La de bajada de la banda LTE de cada sector | RFE-1A (banda de operación) · Movistar y CNT: valor de partida |
| Potencia del transmisor | La de un elemento de recurso: \(P_{TX} - 10\log_{10}(12\,N_{RB})\) = 43 − 27.8 = 15.2 dBm (43 dBm por sector en casi todos los sectores LTE de Claro; canal de 10 MHz, supuesto). Así el mapa sale directamente en RSRP | RFE-1A · Movistar y CNT: valor de partida |
| Ganancia y diagrama | Por sector (17 dBi la más común) | RFE-1A y RNE-2A · Movistar y CNT: valor de partida |
| Azimut | Por sector (Movistar y CNT: 0°, 120° y 240°, supuesto) | RFE-1A · Movistar y CNT: valor de partida |
| Altura de la antena | Base-antena (Claro) o de la estructura (Movistar y CNT) | RFE-1A · SMA-RT-008.1 |
| Conductividad · permitividad | 0.005 S/m · 15, o el valor de la clase de suelo predominante | Radio Mobile · SUPERTEL "Ecuador 60 m" |
| Refractividad del aire | 301 unidades N (valor por defecto) | Radio Mobile |
| Variabilidad | 50 % del tiempo y de las ubicaciones (la mediana) | Valor de partida |
| Receptor | Teléfono a 1.5 m, 0 dBi | Valor típico |
| Umbral | −120 dBm | Mapas 4G (límite media/baja) |
Los valores marcados como "de partida" o "típicos" se pueden ajustar en la calibración (4.3).
Python prepara la lista de sectores con su PIRE ya calculada, la potencia por elemento de recurso que se carga en Radio Mobile y sus coordenadas en latitud y longitud, lista para crear las unidades:
import numpy as np
import pandas as pd
import geopandas as gpd
rb = gpd.read_file("el_valle_etapa1.gpkg", layer="radiobases_lte") # 1.3 (EPSG:32717)
rb = rb.rename(columns={"cod_estructura": "sitio", "azimut": "azimut_deg",
"potencia_dbm": "potencia_dBm", "ganancia_dbi": "ganancia_dBi"})
# Movistar y CNT: el SMA-RT-008.1 no trae sectores, azimut, banda, potencia ni ganancia
mv = rb["operadora"].isin(["Movistar", "CNT"])
tres = [rb[mv].assign(sector=s, azimut_deg=a) for s, a in ((1, 0), (2, 120), (3, 240))]
rb = pd.concat([rb[~mv]] + tres, ignore_index=True) # 3 sectores por sitio (supuesto)
rb["banda"] = rb["banda"].fillna("1900") # valores de partida para Movistar y CNT,
rb["potencia_dBm"] = rb["potencia_dBm"].fillna(43.0) # los más frecuentes del RFE-1A;
rb["ganancia_dBi"] = rb["ganancia_dBi"].fillna(17.0) # se afinan en la calibración (4.3)
PERDIDAS_DB = 0.0 # el RFE-1A no declara pérdidas de cables (valor de partida)
N_RB = 50 # 10 MHz de canal: supuesto, se confirma por banda
POR_RE = 10 * np.log10(12 * N_RB) # 27.8 dB
rb["pire_dBm"] = rb["potencia_dBm"] - PERDIDAS_DB + rb["ganancia_dBi"]
rb["pire_dBW"] = rb["pire_dBm"] - 30
rb["pire_W"] = 10 ** (rb["pire_dBm"] / 10) / 1000
rb["potencia_re_dBm"] = rb["potencia_dBm"] - POR_RE # la que se carga en Radio Mobile
rb["pire_re_dBm"] = rb["pire_dBm"] - POR_RE # PIRE por elemento de recurso
geo = rb.to_crs(4326) # Radio Mobile trabaja en latitud y longitud
rb["lat"], rb["lon"] = geo.geometry.y, geo.geometry.x
columnas = ["operadora", "sitio", "sector", "banda", "lat", "lon", "altura_m", "azimut_deg",
"ganancia_dBi", "potencia_re_dBm", "pire_dBm", "pire_re_dBm"]
rb[columnas].to_csv("unidades_radio_mobile.csv", index=False)
print(rb.groupby("operadora")["sitio"].nunique()) # radiobases por operadora
unidades_radio_mobile.csv| operadora | sitio | sector | banda | lat | lon | altura_m | azimut_deg | ganancia_dBi | potencia_re_dBm | pire_dBm | pire_re_dBm |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Claro | RB-01 | X | 1900 | −2.9266 | −78.9625 | 30 | 55 | 17 | 15.2 | 60.0 | 32.2 |
| Claro | RB-01 | Y | 1900 | −2.9266 | −78.9625 | 30 | 160 | 17 | 15.2 | 60.0 | 32.2 |
| Movistar | RB-12 | 1* | 1900* | −2.9492 | −78.9834 | 24 | 0* | 17* | 15.2* | 60.0* | 32.2* |
Filas ilustrativas. * Valores de partida para Movistar y CNT, que sus formularios no
reportan (3 sectores a 0°, 120° y 240°; banda, potencia y ganancia típicas); se afinan en la
calibración. potencia_re_dBm es la potencia que se escribe en Radio Mobile.
Antes de agregar radiobases imaginarias se simula lo que ya existe: la cobertura 4G que dan hoy las radiobases de Claro, Movistar y CNT. Es el "antes" de toda la comparación y, a la vez, la prueba de que el simulador está bien configurado.
Con el proyecto de 4.1, Radio Mobile calcula la señal que llega a cada punto desde cada sector y se queda con la más fuerte. Se hace una simulación por operadora, para compararla con su propio mapa 4G, y una con las tres juntas, que es la línea base de los escenarios. Cada resultado se exporta como imagen y Python lo convierte en un ráster de RSRP.
El RSRP de un punto \(p\) es el del mejor sector \(k\) entre todos los existentes:
$$\text{RSRP}_0(p) = \max_{k\,\in\,\text{sectores existentes}} \text{RSRP}_k(p)$$Y el nivel se asigna con los mismos cortes de los mapas 4G:
$$\text{nivel}(p) = \begin{cases} 3\ \text{(alta)} & \text{si } \text{RSRP}(p) \geq -100 \\ 2\ \text{(media)} & \text{si } -120 \leq \text{RSRP}(p) \lt -100 \\ 1\ \text{(baja)} & \text{si } -140 \leq \text{RSRP}(p) \lt -120 \\ 0\ \text{(sin servicio)} & \text{si } \text{RSRP}(p) \lt -140 \end{cases}$$A un punto de El Valle le llegan tres sectores: −112 dBm (sector X de RB-01), −104 dBm (sector A de RB-03) y −127 dBm (sector 2 de RB-05). Su RSRP es el mayor: \(\max(-112, -104, -127) = -104\) dBm → nivel media → tiene cobertura 4G.
Valores ilustrativos.
Radio Mobile pinta la cobertura con una escala de colores que se configura de antemano: cada color es un rango de señal. Se puede exportar para Google Earth: una imagen PNG y un archivo KML con las coordenadas de sus cuatro bordes. Con una escala así, cada color se traduce a su nivel:
Escala ilustrativa en pasos de 10 dB. En la práctica conviene usar pasos más finos (de 1 a 2 dB, hasta unos −60 dBm): cada píxel guarda el valor central de su color, así que cuanto más fino el paso, más cerca queda del RSRP calculado (y así funciona la prueba rápida de 4.3). Lo que queda sin color está por debajo de −140 dBm: sin servicio.
Aunque el umbral de cobertura es −120 dBm, la escala baja hasta −140 dBm: así se distingue el nivel "baja" del "sin servicio", algo necesario para comparar la simulación nivel por nivel con el mapa de la operadora.
import numpy as np
import rasterio
from rasterio.transform import from_bounds
from rasterio.warp import calculate_default_transform, reproject, Resampling
# 1) Imagen que exporta Radio Mobile y bordes de su KML (grados, WGS 84)
# Se repite con las cuatro imágenes: Claro, Movistar, CNT y las tres juntas
oeste, sur, este, norte = -79.06, -3.04, -78.88, -2.85 # del LatLonBox del KML
with rasterio.open("esc0_movistar.png") as src: # la imagen no trae georreferencia
rgba = src.read() # bandas: rojo, verde, azul, alfa
_, alto, ancho = rgba.shape
t_geo = from_bounds(oeste, sur, este, norte, ancho, alto)
# 2) Cada color de la escala configurada = un rango de RSRP (valor central, en dBm)
PALETA = {
(224, 73, 47): -135.0, # −140 a −130
(240, 138, 75): -125.0, # −130 a −120
(232, 180, 0): -115.0, # −120 a −110
(156, 203, 90): -105.0, # −110 a −100
(24, 163, 75): -95.0, # ≥ −100
}
rsrp = np.full((alto, ancho), -9999, dtype="float32") # sin color = sin servicio
for (r, g, b), dbm in PALETA.items():
pinta = (rgba[0] == r) & (rgba[1] == g) & (rgba[2] == b) & (rgba[3] > 0)
rsrp[pinta] = dbm
# 3) Pasar a UTM 17S (EPSG:32717) con píxeles de 30 m y guardar como GeoTIFF
t_utm, w, h = calculate_default_transform("EPSG:4326", "EPSG:32717", ancho, alto,
oeste, sur, este, norte, resolution=30)
utm = np.full((h, w), -9999, dtype="float32")
reproject(rsrp, utm, src_transform=t_geo, src_crs="EPSG:4326", src_nodata=-9999,
dst_transform=t_utm, dst_crs="EPSG:32717", dst_nodata=-9999,
resampling=Resampling.nearest)
with rasterio.open("rsrp_esc0_movistar.tif", "w", driver="GTiff", height=h, width=w, count=1,
dtype="float32", crs="EPSG:32717", transform=t_utm, nodata=-9999) as dst:
dst.write(utm, 1) # igual: rsrp_esc0_claro.tif, rsrp_esc0_cnt.tif y rsrp_esc0.tif (las tres juntas)
| fila | columna | x_utm | y_utm | rsrp_dBm | nivel |
|---|---|---|---|---|---|
| 212 | 340 | 726 495 | 9 676 305 | −95.0 | 3 · alta |
| 212 | 341 | 726 525 | 9 676 305 | −105.0 | 2 · media |
| 418 | 297 | 725 205 | 9 670 125 | −125.0 | 1 · baja |
| 430 | 305 | 725 445 | 9 669 765 | −9999 | 0 · sin servicio |
Valores ilustrativos. Cada píxel mide 30 × 30 m; −9999 es el código de "sin dato", que aquí significa por debajo de −140 dBm.
rsrp_esc0_claro.tif, rsrp_esc0_movistar.tif y
rsrp_esc0_cnt.tif (uno por operadora, para el rombo) y rsrp_esc0.tif
(las tres juntas: la línea base de los escenarios).Es la prueba de confianza del simulador: si al simular las radiobases que ya existen no se obtiene algo parecido al mapa 4G de la operadora, tampoco se puede confiar en lo que diga sobre radiobases nuevas.
clase_4g.Con \(M\) la matriz de confusión y \(n\) el número de celdas:
$$C = \frac{1}{n}\sum_{c=1}^{n} \mathbb{1}\left[\text{nivel}_{\text{sim}}(c) = \text{nivel}_{\text{op}}(c)\right] = \frac{\sum_{k} M_{kk}}{n} \qquad \text{¿} C \geq 0.80 \text{?}$$ $$\text{sesgo} = \frac{1}{n}\sum_{c=1}^{n}\big(\text{nivel}_{\text{sim}}(c) - \text{nivel}_{\text{op}}(c)\big) \qquad \kappa = \frac{C - C_e}{1 - C_e}, \quad C_e = \sum_{k}\frac{f_k\, s_k}{n^2}$$donde \(\mathbb{1}[\cdot]\) vale 1 si lo que está entre corchetes se cumple y 0 si no (así se cuentan las celdas que coinciden); \(M_{kk}\) son las casillas de la diagonal; \(C_e\) es la concordancia que se esperaría por puro azar; y \(f_k\) y \(s_k\) son los totales de la fila y de la columna del nivel \(k\).
| Mapa \ Simulación | Sin servicio | Baja | Media | Alta | Total |
|---|---|---|---|---|---|
| Sin servicio | 1 750 | 190 | 20 | 0 | 1 960 |
| Baja | 150 | 230 | 45 | 0 | 425 |
| Media | 113 | 330 | 900 | 40 | 1 383 |
| Alta | 10 | 25 | 120 | 380 | 535 |
| Total | 2 023 | 775 | 1 085 | 420 | 4 303 |
Diagonal: \(1\,750 + 230 + 900 + 380 = 3\,260\) celdas → \(C = 3\,260 / 4\,303 = 75.8\ \%\). No llega al 80 %: respuesta "No".
Hay 748 celdas a la izquierda de la diagonal (la simulación da menos señal) y solo 295 a la derecha. El sesgo es −0.14 niveles: la simulación es pesimista. Encaja con lo que se espera de Longley-Rice detrás de los cerros, y dice que en 4.3 hay que sumar señal, no quitarla. El kappa es 0.64.
Si solo se mirara "cubierto / no cubierto" (corte en −120 dBm), coincidirían 3 760 celdas (87.4 %). La regla de los cuatro niveles es más exigente.
Matriz ilustrativa sobre las ≈ 4 303 celdas de 100 m de El Valle.
import numpy as np
import pandas as pd
import geopandas as gpd
import rasterio
from rasterstats import zonal_stats
from sklearn.metrics import cohen_kappa_score
malla = gpd.read_file("malla_100m.gpkg") # 2.3: ≈ 4 303 celdas
bandas = gpd.read_file("el_valle_etapa1.gpkg", layer="cobertura4g_movistar") # Producto 1
bandas["nivel"] = bandas["nivel"].map({"alta": 3, "media": 2, "baja": 1}) # texto (Etapa 1) → 3, 2, 1
# Nivel predominante del mapa de la operadora en cada celda
trozos = gpd.overlay(malla[["id_celda", "geometry"]], bandas[["nivel", "geometry"]],
how="intersection")
area = (trozos.assign(a=trozos.area)
.pivot_table(index="id_celda", columns="nivel", values="a", aggfunc="sum")
.reindex(malla["id_celda"]).fillna(0))
area[0] = malla.set_index("id_celda").area - area.sum(axis=1) # parte sin servicio
malla["clase_op"] = area.idxmax(axis=1).values
def nivel(rsrp):
"""RSRP (dBm) → 0 sin servicio, 1 baja, 2 media, 3 alta."""
return np.select([rsrp >= -100, rsrp >= -120, rsrp >= -140], [3, 2, 1], default=0)
def concordancia(rsrp, transform, malla):
"""Nivel predominante simulado por celda y su comparación con el del mapa."""
conteos = zonal_stats(malla.geometry, nivel(rsrp).astype("uint8"),
affine=transform, categorical=True, nodata=255)
sim = pd.Series([max(c, key=c.get) if c else 0 for c in conteos], index=malla.index)
matriz = pd.crosstab(malla["clase_op"], sim, rownames=["mapa"], colnames=["simulación"])
C = (sim == malla["clase_op"]).mean()
sesgo = (sim - malla["clase_op"]).mean()
return C, sesgo, matriz, sim
with rasterio.open("rsrp_esc0_movistar.tif") as src:
rsrp, t = src.read(1), src.transform
C, sesgo, matriz, sim = concordancia(rsrp, t, malla)
print(matriz)
print(f"Concordancia {C:.1%} · sesgo {sesgo:+.2f} · kappa {cohen_kappa_score(malla['clase_op'], sim):.2f}")
coincide = C >= 0.80 # "Sí" → simulación calibrada · "No" → 4.3
Calibrar es afinar, con criterio físico, los datos de entrada de la simulación hasta que el Escenario 0 se parezca al mapa de la operadora. No se retoca el resultado: se corrigen las entradas y se vuelve a simular.
Un cambio de PIRE o de pérdidas adicionales desplaza el RSRP de todos los puntos que cubre el sector:
$$\text{RSRP}'(p) = \text{RSRP}(p) + \Delta\text{PIRE} - \Delta L_{\text{adic}}$$ $$\text{sesgo} \lt 0 \;\Rightarrow\; \text{pesimista} \;\Rightarrow\; \Delta\text{PIRE} \gt 0 \ \text{o quitar pérdidas} \qquad \text{sesgo} \gt 0 \;\Rightarrow\; \text{optimista} \;\Rightarrow\; \Delta L_{\text{adic}} \gt 0 \ \text{o}\ \Delta\text{PIRE} \lt 0$$Rango propuesto (valor de partida): \(-6 \leq \Delta\text{PIRE} \leq +6\) dB, en pasos de 1 a 3 dB.
| Vuelta | Qué se cambió | Concordancia | Sesgo | ¿≥ 80 %? |
|---|---|---|---|---|
| 1 | Valores de los formularios, sin pérdidas adicionales | 75.8 % | −0.14 | No |
| 2 | Se corrigen 2 sitios cuya cota en el SRTM difería más de 20 m de la reportada | 78.1 % | −0.10 | No |
| 3 | +3 dB de PIRE en todos los sectores | 81.9 % | −0.05 | Sí |
Valores ilustrativos. En la vuelta 3, sumar 3 dB compensa la sobrestimación de la pérdida detrás de los cerros; el sesgo se acerca a 0.
Las alturas exigen volver a Radio Mobile, pero un cambio de PIRE se puede probar antes en Python, desplazando el ráster (usa las funciones del rombo anterior). Ojo: la prueba solo sirve si el ráster se exportó con pasos finos (1 a 2 dB). Con la escala de 10 dB del ejemplo de 4.2, todos los píxeles de un color tienen el mismo valor central, y un desplazamiento menor que medio paso (5 dB) no cambia ningún nivel: −3, 0 y +3 dB darían la misma concordancia.
import numpy as np
import rasterio
registro_cal = [] # una fila por vuelta (las vueltas 1 y 2 se anotan igual)
with rasterio.open("rsrp_esc0_movistar.tif") as src: # vuelta actual (pasos finos de 1–2 dB)
rsrp, t = src.read(1), src.transform
res = {}
for delta in (-3, 0, 3, 6): # dB de PIRE a probar
prueba = np.where(rsrp == -9999, -9999, rsrp + delta)
res[delta] = concordancia(prueba, t, malla)[:2] # (C, sesgo)
print(f"ΔPIRE {delta:+d} dB → concordancia {res[delta][0]:.1%}, sesgo {res[delta][1]:+.2f}")
# Es una aproximación: lo que estaba bajo −140 dBm no aparece al subir la PIRE.
# El valor elegido (+3 dB) se carga en Radio Mobile y se confirma con una simulación nueva
with rasterio.open("rsrp_esc0_movistar_v3.tif") as src: # Radio Mobile con +3 dB
rsrp3, t3 = src.read(1), src.transform
C, sesgo, _, _ = concordancia(rsrp3, t3, malla)
registro_cal.append({"vuelta": 3, "cambio": "+3 dB PIRE", "concordancia": C, "sesgo": sesgo})
Es el punto de llegada del lazo de calibración: una configuración de Radio Mobile que, con las radiobases que ya existen, reproduce el mapa 4G de la operadora en al menos el 80 % de las celdas. Desde aquí esa configuración queda congelada.
| Mapa \ Simulación | Sin servicio | Baja | Media | Alta | Total |
|---|---|---|---|---|---|
| Sin servicio | 1 790 | 140 | 30 | 0 | 1 960 |
| Baja | 85 | 265 | 70 | 5 | 425 |
| Media | 63 | 200 | 1 050 | 70 | 1 383 |
| Alta | 5 | 15 | 95 | 420 | 535 |
| Total | 1 943 | 620 | 1 245 | 495 | 4 303 |
Diagonal: \(3\,525\) de \(4\,303\) celdas → \(C = 81.9\ \%\) (kappa 0.73). Cuánto coincide cada nivel del mapa:
Valores ilustrativos. El nivel "baja" es el más difícil: es una franja estrecha (20 dB) entre "media" y "sin servicio".
Los parámetros finales se guardan en un archivo, para usar exactamente los mismos en las 21 simulaciones de los escenarios y poder repetir el trabajo:
import json
calibracion = {
"operadora": "Movistar",
"vueltas": 3,
"delta_pire_dB": 3.0,
"perdidas_adicionales_dB": 0.0,
"sitios_con_altura_corregida": 2,
"conductividad_S_m": 0.005,
"permitividad_relativa": 15,
"umbral_dBm": -120,
"concordancia": 0.819,
"kappa": 0.73,
}
with open("calibracion_movistar.json", "w", encoding="utf-8") as f:
json.dump(calibracion, f, ensure_ascii=False, indent=2)
Con el simulador ya calibrado se prueban tres maneras de elegir dónde poner las radiobases nuevas. Las tres agregan el mismo número de radiobases, con el mismo equipo, a la misma red existente: lo único que cambia es el lugar.
Para cada escenario y para \(N = 1, 2\) y \(3\) se cargan en Radio Mobile las radiobases 4G existentes más las \(N\) nuevas, se simula y se exporta el ráster de RSRP. Son 3 escenarios × 3 valores de \(N\) = 9 combinaciones; como el Escenario 3 se sortea 5 veces, dan \(2 \times 3 + 5 \times 3 = 21\) simulaciones, todas con la configuración calibrada.
| Aspecto | Escenario 1 | Escenario 2 | Escenario 3 |
|---|---|---|---|
| Cómo ordena las candidatas | Nota de idoneidad del Random Forest | Altitud máxima de la celda | Orden al azar (5 sorteos) |
| Dónde va la antena | Centro de la celda | Píxel más alto del DEM dentro de la celda | Centro de la celda |
| Candidatas | Celdas con déficit 4G ≥ 50 % y fuera de zonas de exclusión | ||
| Separación mínima | 1.5 km entre sitios nuevos | ||
| Radiobase tipo | La misma (tabla de abajo) | ||
| Red existente y configuración | Escenario 0 calibrado | ||
| Parámetro | Valor de partida | Por qué |
|---|---|---|
| Altura de la antena | 30 m | Algo más que la mediana de los sectores LTE del RFE-1A (24 m) |
| Sectores | 3, con azimut de 0°, 120° y 240° | Cubren los 360° |
| Potencia | 43 dBm por sector; en Radio Mobile se carga la de un elemento de recurso: 43 − 27.8 = 15.2 dBm (10 MHz) | La que declaran casi todos los sectores LTE del RFE-1A; se carga igual que en 4.1 para que el mapa salga en RSRP |
| Ganancia | 17 dBi | La más frecuente en el RFE-1A |
| Banda | Una sola banda LTE, igual para todas (p. ej., 1900 MHz) | Que la banda no favorezca a ningún escenario |
La segunda expresión dice que los sitios están anidados: el escenario con \(N = 2\) repite el sitio de \(N = 1\) y le agrega uno. En cada punto, la señal es la del mejor servidor entre los existentes y los nuevos:
$$\text{RSRP}_{e,N}(p) = \max\Big(\text{RSRP}_0(p),\ \max_{s\,\in\,S_e(N)} \text{RSRP}_s(p)\Big)$$Por eso, en la simulación, agregar radiobases nunca reduce el área cubierta: el RSRP no modela la interferencia entre celdas.
import numpy as np
import pandas as pd
import geopandas as gpd
import rasterio
CELDA_M = 250 # lado de la malla ganadora (3.9)
malla = gpd.read_file(f"idoneidad_{CELDA_M}m.geojson").to_crs(32717) # 3.9: malla con su nota
malla["x_utm"], malla["y_utm"] = malla.centroid.x, malla.centroid.y # centro de cada celda (m)
cand = malla[(malla["deficit_4g"] >= 0.5) & (malla["exclusion"] == 0)] # filtro de 3.10
def elegir_separados(tabla, N=3, d_min=1500):
"""Misma lógica que elegir_sitios de 3.11, pero el orden llega como parámetro:
recorre la tabla en su orden y toma N celdas separadas al menos d_min metros."""
elegidos = []
for _, c in tabla.iterrows():
if all(np.hypot(c.x_utm - e.x_utm, c.y_utm - e.y_utm) >= d_min for e in elegidos):
elegidos.append(c)
if len(elegidos) == N:
break
return pd.DataFrame(elegidos)
# Escenario 2: la antena va al píxel más alto de cada celda (punto_mas_alto: ver Escenario 2)
with rasterio.open("Metodologia/Topografia El Valle/dem_el_valle_utm17s_30m.tif") as dem:
xy = [punto_mas_alto(g, dem) for g in cand.geometry]
altos = cand.assign(x_utm=[p[0] for p in xy], y_utm=[p[1] for p in xy])
sitios = {
"esc1": elegir_separados(cand.sort_values("idoneidad", ascending=False)), # = Producto 4
"esc2": elegir_separados(altos.sort_values("alt_max_m", ascending=False)), # los más altos
}
for s in range(5): # control: 5 sorteos
sitios[f"esc3_s{s}"] = elegir_separados(cand.sample(frac=1, random_state=s))
filas = []
for nombre, tabla in sitios.items():
for N in (1, 2, 3): # anidados: head(N)
for orden, (_, c) in enumerate(tabla.head(N).iterrows(), start=1):
filas.append({"escenario": nombre, "N": N, "sitio": orden,
"id_celda": c.id_celda, "x_utm": c.x_utm, "y_utm": c.y_utm})
nuevas = pd.DataFrame(filas)
geo = gpd.GeoSeries(gpd.points_from_xy(nuevas.x_utm, nuevas.y_utm), crs=32717).to_crs(4326)
nuevas["lat"], nuevas["lon"] = geo.y, geo.x
nuevas.to_csv("radiobases_nuevas.csv", index=False) # se cargan en Radio Mobile
radiobases_nuevas.csv| escenario | N | sitio | id_celda | x_utm | y_utm | lat | lon |
|---|---|---|---|---|---|---|---|
| esc1 | 3 | 1 | C0421 | 727 375 | 9 675 125 | −2.9373 | −78.9544 |
| esc1 | 3 | 2 | C0630 | 728 625 | 9 676 875 | −2.9215 | −78.9432 |
| esc1 | 3 | 3 | C0709 | 726 625 | 9 673 375 | −2.9532 | −78.9611 |
| esc2 | 1 | 1 | C0532 | 727 749 | 9 669 511 | −2.9881 | −78.9509 |
| esc3_s0 | 1 | 1 | C0118 | 723 875 | 9 676 375 | −2.9261 | −78.9859 |
Filas ilustrativas. Las de esc1 son el Producto 4 de la Etapa 3; la de
esc2 es el píxel más alto de la celda C0532 (ver Escenario 2), y la de
esc3_s0, el centro de una celda de la malla de 250 m. El archivo completo tiene una
fila por sitio, escenario (y sorteo) y valor de \(N\).
rsrp_esc1_N1.tif … rsrp_esc3_s4_N3.tif.El Escenario 1 pone a prueba la propuesta de la tesis: las radiobases nuevas se colocan exactamente donde las recomienda el Random Forest, es decir, en las ubicaciones del Producto 4.
ubicaciones_recomendadas.csv (Producto 4),
ya ordenado por nota de idoneidad.| sitio | id_celda | idoneidad | lat | lon | Entra en |
|---|---|---|---|---|---|
| 1 | C0421 | 0.73 | −2.9373 | −78.9544 | N = 1, 2 y 3 |
| 2 | C0630 | 0.64 | −2.9215 | −78.9432 | N = 2 y 3 |
| 3 | C0709 | 0.58 | −2.9532 | −78.9611 | N = 3 |
Valores ilustrativos: los mismos del Producto 4 de la Etapa 3.
import pandas as pd
ubic = pd.read_csv("ubicaciones_recomendadas.csv") # Producto 4, ordenado por idoneidad
for N in (1, 2, 3):
sitios_N = ubic.head(N) # anidados: N = 2 repite el sitio de N = 1
sitios_N[["sitio", "lat", "lon"]].to_csv(f"esc1_N{N}.csv", index=False)
# En Radio Mobile: existentes + esc1_N{N}.csv → rsrp_esc1_N{N}.tif
rsrp_esc1_N1.tif, rsrp_esc1_N2.tif y
rsrp_esc1_N3.tif.El Escenario 2 imita cómo elegiría los sitios un planificador con el criterio clásico: ir a los puntos más altos, desde donde la antena "ve" más terreno, dentro de la zona que necesita cobertura.
Las celdas candidatas se ordenan de la más alta a la más baja según su altitud máxima y se toman las \(N\) primeras que estén separadas al menos 1.5 km entre sí. La radiobase se coloca en el píxel más alto del DEM dentro de la celda elegida, no en su centro: si no, el escenario de "los puntos más altos" perdería justo la ventaja que lo define. Luego se simulan igual que en el Escenario 1.
alt_max_m (altitud
máxima de la celda, del DEM Copernicus de 30 m). La antena se pone en el píxel de 30 m donde se
alcanza esa altitud. Desde lo alto hay más línea de vista y es más fácil despejar la primera zona
de Fresnel.deficit_4g ≥ 0.5) y fuera de zonas de exclusión:
las mismas candidatas de los otros escenarios.donde \(\mathcal{C}\) es el conjunto de candidatas y \(d\) la distancia en metros (UTM) entre los puntos donde irían las antenas. Ese punto es el píxel más alto de la celda, con \(z\) la altura del DEM:
$$q^{*}(c) = \arg\max_{q\,\in\,c} z(q)$$| Orden | Celda | Píxel más alto (x, y) | alt_max_m | Altura en el centro | Distancia al sitio ya elegido más cercano | Resultado |
|---|---|---|---|---|---|---|
| 1 | C0532 | 727 749, 9 669 511 | 3 097 | 3 039 | — | Sitio 1 |
| 2 | C0534 | 727 659, 9 670 081 | 3 056 | 3 046 | 0.58 km | Se salta |
| 3 | C0567 | 727 959, 9 670 891 | 3 052 | 3 045 | 1.40 km | Se salta |
| 4 | C0536 | 727 659, 9 670 651 | 3 051 | 3 043 | 1.14 km | Se salta |
| 5 | C0009 | 722 259, 9 673 381 | 3 049 | 3 001 | 6.72 km | Sitio 2 |
| 6 | C0568 | 727 959, 9 671 011 | 3 045 | 3 030 | 1.51 km | Sitio 3 |
Ejemplo calculado con el DEM Copernicus de El Valle sobre la malla de 250 m de 2.3, todavía sin el filtro de déficit 4G ni de exclusión: sirve para ver la mecánica, no es el resultado final. Los tres sitios quedan en el sur y el oeste de la parroquia. Si la antena se pusiera en el centro de la celda, quedaría 58 m más abajo en C0532, 48 m en C0009 y 16 m en C0568.
import numpy as np
import rasterio
from rasterio.mask import mask
def punto_mas_alto(celda, dem):
"""(x, y) del centro del píxel más alto del DEM dentro de la celda, en metros."""
alturas, t = mask(dem, [celda], crop=True, filled=False) # solo los píxeles de la celda
fila, col = np.unravel_index(np.ma.argmax(alturas[0]), alturas[0].shape)
return rasterio.transform.xy(t, fila, col)
with rasterio.open("Metodologia/Topografia El Valle/dem_el_valle_utm17s_30m.tif") as dem:
xy = [punto_mas_alto(g, dem) for g in cand.geometry] # cand: candidatas de 4.4 (EPSG:32717)
altos = cand.assign(x_utm=[p[0] for p in xy], y_utm=[p[1] for p in xy]) # la antena va ahí
esc2 = elegir_separados(altos.sort_values("alt_max_m", ascending=False), N=3, d_min=1500)
print(esc2[["id_celda", "alt_max_m", "x_utm", "y_utm"]])
# Se simulan esc2.head(1), esc2.head(2) y esc2.head(3), junto a las radiobases existentes
rsrp_esc2_N1.tif, rsrp_esc2_N2.tif y
rsrp_esc2_N3.tif.El Escenario 3 es el grupo de control: pone las radiobases en lugares sorteados dentro de la zona con déficit. Responde a la pregunta "¿cuánto se ganaría sin ningún criterio?". Un buen método tiene que superarlo con claridad.
Se barajan las candidatas al azar y se toman las \(N\) primeras separadas al menos 1.5 km. Como un solo sorteo puede tener buena o mala suerte, se repite con varias semillas (valor de partida: 5 sorteos) y se reporta la media y el rango.
random_state): la misma semilla da siempre el mismo sorteo, así el experimento
se puede repetir.deficit_4g ≥ 0.5 y fuera de zonas de exclusión.\(\Delta A_3^{(r)}(N)\) es el área que gana el sorteo \(r\) con \(N\) radiobases; se hace lo mismo con la población.
| Sorteo (semilla) | ΔA (km²) | Población que gana |
|---|---|---|
| 0 | 2.1 | 900 |
| 1 | 3.6 | 1 850 |
| 2 | 2.4 | 1 020 |
| 3 | 3.3 | 1 600 |
| 4 | 3.1 | 1 180 |
| Media | 2.9 | 1 310 |
Valores ilustrativos. El rango (2.1 a 3.6 km²) muestra cuánto depende del azar. La regla del rombo final compara con la media de los sorteos; superar también al mejor sorteo es una prueba extra de solidez que se reporta aparte.
sorteos = [elegir_separados(cand.sample(frac=1, random_state=s), N=3, d_min=1500)
for s in range(5)] # 5 sorteos reproducibles
# Después de medir en 4.5:
m3 = metricas[metricas["escenario"].str.startswith("esc3")]
resumen = m3.groupby("N")[["delta_area_km2", "delta_pob"]].agg(["mean", "min", "max"])
print(resumen)
Aquí cada mapa simulado se convierte en números: cuánta área de El Valle queda con señal 4G útil, cuánta gente, cuántas escuelas y centros de salud pasan a tener cobertura, y cómo cambia la señal en cada comunidad.
Cada ráster de RSRP se cruza, con GeoPandas y Rasterio, con las capas de la base de datos: el límite de El Valle, la malla con la población, las escuelas, los centros de salud y las comunidades. Todo se compara con el Escenario 0.
Área, con \(a_p = 900\) m² el área de cada píxel y \(\mathbb{1}[\cdot]\) igual a 1 si se cumple lo que está entre corchetes y 0 si no (así se cuentan solo los píxeles con cobertura):
$$A_s = \sum_{p\,\in\,\text{El Valle}} \mathbb{1}\left[\text{RSRP}_s(p) \geq -120\right] a_p \qquad \%A_s = 100\cdot\frac{A_s}{43.03\ \text{km}^2} \qquad \Delta A_s = A_s - A_0$$Población, con \(f_s(c)\) la fracción de la celda \(c\) con RSRP ≥ −120 dBm:
$$P_s = \sum_{c} \text{poblacion}_c \cdot f_s(c) \qquad \Delta P_s = P_s - P_0$$Escuelas (igual para centros de salud); \(\wedge\) significa "y": la escuela cuenta si antes no tenía cobertura y ahora sí:
$$E_s = \sum_{e\,\in\,\text{escuelas}} \mathbb{1}\left[\text{RSRP}_0(e) \lt -120 \ \wedge\ \text{RSRP}_s(e) \geq -120\right]$$Señal media de la comunidad \(k\), con \(\Pi_k\) los píxeles con señal dentro de su círculo:
$$\overline{\text{RSRP}}_{k,s} = \frac{1}{\lvert \Pi_k \rvert}\sum_{p\,\in\,\Pi_k} \text{RSRP}_s(p)$$Área. En el Escenario 0 hay 23 889 píxeles con RSRP ≥ −120 dBm dentro de El Valle: \(23\,889 \times 900\ \text{m}^2 = 21.50\) km² (50.0 %). En el Escenario 1 con \(N = 3\) hay 29 667: 26.70 km² (62.0 %). Aumento: \(\Delta A = +5.20\) km².
Población. Una celda de 100 m con 42 habitantes estaba cubierta en un 25 % y pasa al 90 %: aporta \(42 \times 0.25 = 10.5\) y luego \(42 \times 0.90 = 37.8\) habitantes. Gana 27.3.
Escuela. Una escuela con −126 dBm en el Escenario 0 y −112 dBm en el Escenario 1 cuenta como una escuela que gana cobertura.
Valores ilustrativos.
| Comunidad | Habitantes (PDOT) | Señal media, Esc. 0 | Esc. 1 (N = 3) | Cambio |
|---|---|---|---|---|
| Chilcapamba | 1 736 | −118.4 dBm | −104.7 dBm | +13.7 dB |
| Cochapamba | 1 389 | −112.0 dBm | −101.3 dBm | +10.7 dB |
| El Valle Centro | 1 362 | −96.2 dBm | −95.9 dBm | +0.3 dB |
| Quillopungo | 1 192 | −123.5 dBm | −109.8 dBm | +13.7 dB |
Los habitantes son datos reales del PDOT (las cuatro comunidades más pobladas); las señales son ilustrativas. El promedio se hace sobre los valores en dBm y deja fuera los píxeles sin servicio.
import numpy as np
import pandas as pd
import geopandas as gpd
import rasterio
from rasterio.features import shapes
from shapely.geometry import shape
from rasterstats import zonal_stats
UMBRAL = -120 # dBm
GPKG = "el_valle_etapa1.gpkg" # Producto 1, EPSG:32717
limite = gpd.read_file(GPKG, layer="limite_el_valle")
escuelas = gpd.read_file(GPKG, layer="escuelas")
salud = gpd.read_file(GPKG, layer="salud")
malla = gpd.read_file("malla_100m.gpkg") # id_celda, poblacion (2.4)
pdot = pd.read_csv("poblacion_pdot_el_valle.csv") # 1.2: comunidad, x_utm, y_utm, habitantes, area_ha
radio = np.sqrt(pdot["area_ha"] * 1e4 / np.pi) # r = √(A/π), en metros
centros = gpd.GeoSeries(gpd.points_from_xy(pdot["x_utm"], pdot["y_utm"]), crs=32717)
comunidades = gpd.GeoDataFrame(pdot, geometry=centros.buffer(radio), crs=32717)
def zona_cubierta(ruta):
"""Polígonos de El Valle con RSRP ≥ −120 dBm."""
with rasterio.open(ruta) as src:
rsrp, t, crs = src.read(1), src.transform, src.crs
cubierto = (rsrp >= UMBRAL).astype("uint8")
geoms = [shape(g) for g, _ in shapes(cubierto, mask=cubierto == 1, transform=t)]
return gpd.clip(gpd.GeoDataFrame(geometry=geoms, crs=crs).dissolve(), limite)
def rsrp_en(ruta, puntos):
with rasterio.open(ruta) as src:
return np.array([v[0] for v in src.sample([(p.x, p.y) for p in puntos.geometry])])
def medir(ruta, ruta0="rsrp_esc0.tif"):
zona = zona_cubierta(ruta)
area = zona.area.sum() / 1e6 # km²
trozos = gpd.overlay(malla[["id_celda", "poblacion", "geometry"]], zona, how="intersection")
f = trozos.area / trozos["id_celda"].map(malla.set_index("id_celda").area)
pob = (trozos["poblacion"] * f).sum() # habitantes cubiertos
ganan = {n: int(((rsrp_en(ruta0, c) < UMBRAL) & (rsrp_en(ruta, c) >= UMBRAL)).sum())
for n, c in [("escuelas", escuelas), ("salud", salud)]}
media = [z["mean"] for z in zonal_stats(comunidades.geometry, ruta,
stats=["mean"], nodata=-9999)]
return area, pob, ganan, media
def senal_por_comunidad(media, esc, N):
return pd.DataFrame({"comunidad": comunidades["comunidad"], "escenario": esc, "N": N,
"rsrp_medio_dBm": media})
A0, P0, _, media0 = medir("rsrp_esc0.tif")
filas, senal = [], [senal_por_comunidad(media0, "esc0", 0)]
for esc in ["esc1", "esc2"] + [f"esc3_s{s}" for s in range(5)]: # 21 rásters
for N in (1, 2, 3):
A, P, g, media = medir(f"rsrp_{esc}_N{N}.tif")
filas.append({"escenario": esc, "N": N, "area_km2": A, "pct_area": 100 * A / 43.03,
"delta_area_km2": A - A0, "delta_pob": P - P0,
"escuelas": g["escuelas"], "salud": g["salud"]})
senal.append(senal_por_comunidad(media, esc, N))
metricas = pd.DataFrame(filas)
metricas.to_csv("metricas_etapa4.csv", index=False) # la lee la Etapa 5 (5.1)
pd.concat(senal).to_csv("senal_comunidades.csv", index=False) # 49 comunidades por caso
metricas| escenario | N | area_km2 | pct_area | delta_area_km2 | delta_pob | escuelas | salud |
|---|---|---|---|---|---|---|---|
| esc1 | 3 | 26.70 | 62.0 | 5.20 | 2 870 | 3 | 1 |
| esc2 | 3 | 25.80 | 60.0 | 4.30 | 1 350 | 1 | 0 |
| esc3_s0 | 3 | 23.60 | 54.8 | 2.10 | 900 | 1 | 0 |
Tres filas ilustrativas. La tabla completa tiene una fila por escenario (y sorteo) y por valor de \(N\).
metricas (metricas_etapa4.csv, que usa la
Etapa 5) y la señal media de cada una de las 49 comunidades en cada escenario
(senal_comunidades.csv).Se ponen todos los resultados juntos, en una tabla y en mapas del mismo tamaño y con la misma escala de colores, para ver de un vistazo qué criterio de ubicación rinde más.
Se arma la tabla de métricas (una fila por escenario y por \(N\), con la media de los sorteos en el Escenario 3) y una lámina de mapas: Escenario 0, 1, 2 y 3 para el mismo \(N\), lado a lado. También se calcula cuánto aporta cada radiobase adicional.
| Escenario | N | Área (km²) | % de El Valle | ΔA (km²) | Población que gana | % de 24 314 | Escuelas | C. de salud |
|---|---|---|---|---|---|---|---|---|
| 0 · existentes | — | 21.5 | 50.0 | — | — | — | — | — |
| 1 · modelo | 1 | 23.8 | 55.3 | +2.3 | +1 250 | 5.1 | 1 | 0 |
| 1 · modelo | 2 | 25.4 | 59.0 | +3.9 | +2 180 | 9.0 | 2 | 1 |
| 1 · modelo | 3 | 26.7 | 62.0 | +5.2 | +2 870 | 11.8 | 3 | 1 |
| 2 · más altos | 1 | 23.6 | 54.8 | +2.1 | +610 | 2.5 | 0 | 0 |
| 2 · más altos | 2 | 24.9 | 57.9 | +3.4 | +1 020 | 4.2 | 1 | 0 |
| 2 · más altos | 3 | 25.8 | 60.0 | +4.3 | +1 350 | 5.6 | 1 | 0 |
| 3 · azar (media) | 1 | 22.6 | 52.5 | +1.1 | +480 | 2.0 | 0 | 0 |
| 3 · azar (media) | 2 | 23.6 | 54.8 | +2.1 | +900 | 3.7 | 1 | 0 |
| 3 · azar (media) | 3 | 24.4 | 56.7 | +2.9 | +1 310 | 5.4 | 1 | 0 |
Valores ilustrativos. Ganancia marginal del Escenario 1: 2.3, 1.6 y 1.3 km² para la primera, segunda y tercera radiobase; cada una aporta un poco menos.
Con \(N = 3\), lo que gana cada escenario (barra más larga = mejor):
Área en km²; personas en habitantes. En este ejemplo los puntos altos cubren casi tanta área como el modelo, pero menos de la mitad de población.
import matplotlib.pyplot as plt
import rasterio
from rasterio.plot import show
from matplotlib.colors import ListedColormap, BoundaryNorm
# 1) Tabla: el Escenario 3 se resume con la media de sus sorteos
tabla = (metricas.assign(grupo=metricas["escenario"].str[:4]) # esc1, esc2, esc3
.groupby(["grupo", "N"])[["area_km2", "pct_area", "delta_area_km2",
"delta_pob", "escuelas", "salud"]]
.mean().round(1))
tabla["marginal_km2"] = tabla.groupby(level="grupo")["delta_area_km2"].diff().fillna(
tabla["delta_area_km2"])
print(tabla)
# 2) Mapas lado a lado, con la misma escala de niveles
colores = ListedColormap(["#BDBDBD", "#E0492F", "#E8B400", "#18A34B"]) # sin servicio, baja, media, alta
cortes = BoundaryNorm([-10000, -140, -120, -100, 0], colores.N)
rutas = {"Escenario 0": "rsrp_esc0.tif", "Escenario 1": "rsrp_esc1_N3.tif",
"Escenario 2": "rsrp_esc2_N3.tif", "Escenario 3": "rsrp_esc3_s0_N3.tif"}
fig, ejes = plt.subplots(1, 4, figsize=(16, 4.5), sharex=True, sharey=True)
for eje, (titulo, ruta) in zip(ejes, rutas.items()):
with rasterio.open(ruta) as src:
show(src, ax=eje, cmap=colores, norm=cortes)
limite.boundary.plot(ax=eje, color="black", linewidth=0.8)
eje.set_title(titulo)
eje.set_axis_off()
fig.savefig("comparacion_N3.png", dpi=200, bbox_inches="tight")
metricas de 4.5 y los rásters de cada escenario.Es la pregunta final de la evaluación: ¿las ubicaciones del modelo le ganan al criterio tradicional y al azar? Si la respuesta es sí, los resultados pasan a la plataforma web; si no, el modelo vuelve a ajustarse.
| N | ΔA (km²): Esc. 1 · 2 · 3 | ¿Gana en área? | ΔP (hab.): Esc. 1 · 2 · 3 | ¿Gana en población? |
|---|---|---|---|---|
| 1 | 2.3 · 2.1 · 1.1 | Sí | 1 250 · 610 · 480 | Sí |
| 2 | 3.9 · 3.4 · 2.1 | Sí | 2 180 · 1 020 · 900 | Sí |
| 3 | 5.2 · 4.3 · 2.9 | Sí | 2 870 · 1 350 · 1 310 | Sí |
Con los valores de 4.6 el Escenario 1 gana en los seis casos: respuesta "Sí", se pasa a la Etapa 5.
Pero si con \(N = 1\) el punto más alto hubiera cubierto 2.6 km² (frente a 2.3), el Escenario 2 ganaría en área y la respuesta sería "No", aunque el modelo lleve cobertura al doble de personas. La regla es exigente a propósito.
Valores ilustrativos.
t = (metricas.assign(grupo=metricas["escenario"].str[:4])
.groupby(["grupo", "N"]).mean(numeric_only=True)) # esc3 = media de los sorteos
gana = True
for N in (1, 2, 3):
e1, e2, e3 = t.loc[("esc1", N)], t.loc[("esc2", N)], t.loc[("esc3", N)]
gana_area = e1.delta_area_km2 > max(e2.delta_area_km2, e3.delta_area_km2)
gana_pob = e1.delta_pob > max(e2.delta_pob, e3.delta_pob)
print(f"N = {N}: área {gana_area}, población {gana_pob}")
gana = gana and gana_area and gana_pob
print("Sí: pasar a la Etapa 5" if gana else "No: volver por el conector A a 3.3")
El círculo con la letra A es un atajo del diagrama: une este punto con el otro círculo A de la Etapa 3 sin dibujar una flecha que cruce toda la página. Por aquí sale el flujo cuando el modelo no le gana al criterio tradicional o al azar.
Desde 3.3 se recorre de nuevo la Etapa 3 (entrenar, evaluar, elegir el mejor modelo, filtrar y elegir sitios) hasta obtener un Producto 4 nuevo, que vuelve a entrar a la Etapa 4.
Lo que no se toca es la regla de medir: la simulación calibrada y la radiobase tipo quedan como están. Si solo cambian los hiperparámetros, las candidatas siguen siendo las mismas y basta con volver a simular el Escenario 1. Si cambian las candidatas (otra malla ganadora u otro umbral en 3.10), se rehacen también los Escenarios 2 y 3, para que los tres sigan eligiendo del mismo conjunto.
MAX_REGRESOS = 2 # valor de partida
for vuelta in range(MAX_REGRESOS + 1):
modelo = ajustar_y_entrenar(rejilla) # Etapa 3, desde 3.3
sitios = recomendar(modelo, N=3) # 3.10 y 3.11 → nuevo Producto 4
m_esc1 = simular_y_medir(sitios) # 4.4 y 4.5, solo el Escenario 1
if gana_a_esc2_y_esc3(m_esc1, m_esc2, m_esc3):
break # "Sí": a la Etapa 5
rejilla = ampliar(rejilla) # "No": regreso por el conector A
Los resultados de la tesis se llevan a una página web con mapas. Así cualquier persona, desde su computadora o su celular, puede ver dónde falta cobertura 4G en El Valle, dónde recomienda el modelo instalar nuevas radiobases y cuánto mejora la cobertura con cada escenario.
Una plataforma web funciona como un restaurante. La despensa son los archivos de datos: mapas ya preparados (paso 5.1). La cocina es el servidor, que recibe los pedidos y entrega lo que se le pide (paso 5.2). El comedor es la interfaz, lo que el visitante ve y toca en su navegador (paso 5.3). Después hay que abrir el local al público (paso 5.4, desplegar), comprobar que todo funciona y, al final, contar en la memoria lo que se obtuvo.
tesisucuenca.com./roadmap que estás leyendo la sirve el mismo programa). Hoy
muestra la cobertura de Claro y Movistar en todo el Azuay, con todas las tecnologías que
entregan las operadoras, y ofrece consulta de un punto, comparador swipe y ranking por
parroquia. En la Etapa 5 se le agregan los resultados de El Valle: el mapa de idoneidad
(Producto 3), las ubicaciones recomendadas (Producto 4) y la cobertura de cada escenario
simulado en la Etapa 4. La tesis solo usa las capas 4G (LTE): las capas 2G y 3G que hoy tiene el
sitio no se usan y se retiran en esta etapa (preprocess.py se limita a las capas
LTE). También se agrega CNT, la tercera operadora de la tesis, que hoy el sitio no
tiene. La idea tiene antecedente en el estado del arte: Fauzi et al. publicaron
su modelo Random Forest como una herramienta web que el planificador consulta en línea.tesisucuenca.com.| Archivo | Qué contiene | Formato | De dónde sale | Estado |
|---|---|---|---|---|
cobertura.pmtiles | Bandas alta, media y baja de las operadoras (hoy, Claro y Movistar con todas las tecnologías; en la Etapa 5, solo las bandas de RSRP 4G de Claro, Movistar y CNT) | PMTiles | Mapas predictivos que las operadoras entregan a la ARCOTEL (Etapa 1) | Existe (Azuay, 1.67 MB, Claro y Movistar con todas las tecnologías; se regenera solo con 4G y con CNT) |
idoneidad_250m.geojson | Nota de 0 a 1 de cada celda | GeoJSON | Producto 3 (Etapa 3) | Propuesto |
ubicaciones.geojson | Sitios de \(N = 1, 2\) y \(3\) radiobases nuevas | GeoJSON | Producto 4 (Etapa 3) | Propuesto |
escenarios.pmtiles | Cobertura 4G simulada de cada escenario | PMTiles | Etapa 4 (Radio Mobile) | Propuesto |
metricas_escenarios.json | Área, población, escuelas y centros de salud que ganan cobertura | JSON | Etapa 4 (paso 4.5) | Propuesto |
Los nombres de los archivos nuevos son una propuesta. "250 m" es solo un ejemplo:
se exporta la malla del modelo ganador. Los archivos nuevos se guardan en
data/el_valle/, una carpeta que sí se sube al repositorio, y el
Dockerfile los copia junto a cobertura.pmtiles (paso 5.4).
# 5.1 Exportar (una vez, en la computadora de trabajo), a la carpeta data/el_valle/
exportar_geojson(idoneidad, "idoneidad_250m.geojson") # Producto 3
exportar_geojson(ubicaciones, "ubicaciones.geojson") # Producto 4
vectorizar(rasters_rsrp, "escenarios.geojson") # Etapa 4: ráster -> polígonos
generar_pmtiles("escenarios.geojson", "escenarios.pmtiles") # Etapa 4
# 5.2 Servidor (app.py): responde a lo que pide el navegador
@app.route("/api/metricas")
def api_metricas():
...
# 5.3 Interfaz (static/js/mapa.js, en JavaScript): dibuja las capas
# 5.4 Desplegar: git push -> Dokploy construye la imagen Docker y la publica
# Rombo: pruebas en escritorio y móvil -> si fallan, se corrige y se repite
tesisucuenca.com y el Capítulo 5 de la memoria.Los resultados salen de Python como tablas con coordenadas UTM (EPSG:32717, en metros). Un navegador no puede dibujarlos así: hay que guardarlos en formatos web. Las capas pequeñas van en GeoJSON y las pesadas, en PMTiles.
malla_ganadora_idoneidad.gpkg), los sitios para
\(N = 1, 2\) y \(3\) (Producto 4, ubicaciones_recomendadas.csv), los rásters de
RSRP de los 10 casos de la Etapa 4 (el Escenario 0 más 3 escenarios × 3 valores de \(N\):
rsrp_esc0.tif, rsrp_esc1_N1.tif…), la tabla
metricas de 4.5 y las bandas 4G de las operadoras.rasterio.features.shapes). Del Escenario 3 se dibuja un sorteo representativo
(s0); sus métricas son la media de los 5 sorteos.id_celda, idoneidad, deficit_4g). Las 19
variables completas no hacen falta en el mapa.make_tiles.py. Las métricas van en un JSON.data/el_valle/,
una carpeta que sí se sube al repositorio. No se usa data/processed/ porque esa
carpeta se borra y se vuelve a generar en cada construcción. En el paso 5.4, el
Dockerfile los copia a data/processed/ dentro de la imagen, que es
de donde el servidor (5.2) entrega los datos.make_tiles.py pasa las geometrías a este sistema antes de
cortarlas.mapbox-vector-tile, mercantile y pmtiles). Así la
construcción en Docker (5.4) solo necesita pip install.| GeoJSON | PMTiles | |
|---|---|---|
| Qué es | Texto con todas las geometrías | Binario: teselas por zoom + índice |
| Cómo se descarga | Entero, de una vez | Por partes: solo lo que se ve |
| Detalle | El mismo en cualquier zoom | Simplificado según el zoom |
| Se puede leer a simple vista | Sí | No |
| Ideal para | Capas pequeñas y descargas | Capas grandes que se exploran con zoom |
Tamaños reales del archivo actual del sitio (bandas de cobertura del Azuay, con todas las tecnologías que entregan las operadoras). La barra "solo 4G" es una estimación: la parte 4G ocupa cerca del 20 % de ese GeoJSON.
Número de teselas que tiene el mundo en el zoom \(z\), y ancho de cada una en el ecuador:
$$n_{\text{teselas}}(z) = 2^{z} \times 2^{z} = 4^{z} \qquad W_z = \frac{40\,075\ \text{km}}{2^{z}}$$40 075 km es la longitud del ecuador, es decir, el ancho del mapa del mundo en Web Mercator.
Dentro de una tesela hay 4 096 unidades por lado. La tolerancia con la que
make_tiles.py simplifica cada forma es una de esas unidades (un "píxel" de la
tesela):
¿Por qué bastan 6 decimales (paso 5)? Un grado de longitud en el ecuador mide unos 111 320 m, así que:
$$10^{-6}\ \text{grados} \approx 111\,320\ \text{m} \times 10^{-6} \approx 0.11\ \text{m}$$En El Valle (latitud \(\approx -2.94^{\circ}\)) las teselas son apenas un 0.13 % más angostas que en el ecuador (\(\cos 2.94^{\circ} \approx 0.9987\)), así que los valores del ecuador sirven.
| Zoom \(z\) | Teselas en el mundo | Ancho de una tesela | Tolerancia | Teselas que cubren El Valle |
|---|---|---|---|---|
| 6 | 4 096 | 626 km | 153 m | 1 |
| 9 | 262 144 | 78.3 km | 19.1 m | 1 |
| 10 | 1 048 576 | 39.1 km | 9.6 m | 1 |
| 11 | 4 194 304 | 19.6 km | 4.8 m | 2 |
| 13 | 67 108 864 | 4.89 km | 1.2 m | 6 |
El Valle mide unos 8.4 km de oeste a este y 9.4 km de sur a norte (límite de OpenStreetMap). El sitio actual usa los zooms 6 a 11; más allá, MapLibre agranda las teselas del zoom 11 (overzoom), y con 4.8 m de tolerancia una celda de 100 m se sigue viendo nítida.
¿Cuánto pesaría cada malla en GeoJSON? Una prueba con mallas sobre el límite de El Valle, con 3 columnas y 6 decimales, dio unos 1.3 MB para la de 100 m, 0.22 MB para la de 250 m y 0.06 MB para la de 500 m. Las dos últimas se pueden enviar enteras sin problema; la de 100 m conviene pasarla a PMTiles.
Estimación para tener el orden de magnitud (notas de idoneidad al azar), no un resultado de la tesis.
Una celda del mapa de idoneidad en GeoJSON (5 esquinas: la última repite la primera para cerrar el cuadrado):
{
"type": "Feature",
"properties": { "id_celda": "C0421", "idoneidad": 0.73, "deficit_4g": 0.85 },
"geometry": {
"type": "Polygon",
"coordinates": [[ [-78.955525, -2.938470], [-78.953277, -2.938466],
[-78.953281, -2.936206], [-78.955530, -2.936210],
[-78.955525, -2.938470] ]]
}
}
Valores ilustrativos (la celda de 250 m con esquina en X 727 250, Y 9 675 000 de UTM 17S, pasada a longitud y latitud).
Por dentro de un PMTiles (archivo real cobertura.pmtiles del sitio):
| Parte | Empieza en el byte | Tamaño | Para qué sirve |
|---|---|---|---|
| Encabezado | 0 | 127 bytes | Zoom mínimo y máximo, límites, dónde está el resto |
| Directorio (índice) | 127 | 253 bytes | En qué byte empieza y cuánto mide cada tesela |
| Metadatos | 380 | 173 bytes | Nombre de la capa y de sus columnas |
| Teselas | 553 | 1 674 351 bytes | Las 66 teselas comprimidas |
1) Capas pequeñas a GeoJSON (con GeoPandas):
import geopandas as gpd
import pandas as pd
SALIDA = "data/el_valle/" # carpeta que sí se sube al repositorio
# Producto 3 (3.9): malla ganadora, EPSG:32717, con id_celda, deficit_4g e idoneidad
malla = gpd.read_file("malla_ganadora_idoneidad.gpkg") # la misma que lee la Etapa 4
malla = malla.round({"idoneidad": 3, "deficit_4g": 2}) # menos decimales, menos peso
malla[["id_celda", "idoneidad", "deficit_4g", "geometry"]].to_crs(4326).to_file(
SALIDA + "idoneidad_250m.geojson", driver="GeoJSON",
COORDINATE_PRECISION=6) # 6 decimales ≈ 0.11 m
# Producto 4: sitios recomendados (sale de la Etapa 3)
sitios = pd.read_csv("ubicaciones_recomendadas.csv")
puntos = gpd.GeoDataFrame(
sitios[["sitio", "id_celda", "idoneidad"]],
geometry=gpd.points_from_xy(sitios.x_utm, sitios.y_utm),
crs=32717)
puntos.to_crs(4326).to_file(SALIDA + "ubicaciones.geojson", driver="GeoJSON")
Otra opción para el Producto 3: partir del idoneidad_{m}m.geojson que
ya deja 3.9 y solo recortarle columnas y decimales.
2) Rásters de RSRP de la Etapa 4 a polígonos (vectorizar):
import numpy as np
import geopandas as gpd
import rasterio
from rasterio.features import shapes
from shapely.geometry import shape
NIVEL = {3: "alta", 2: "media", 1: "baja"}
casos = [(0, 0, "rsrp_esc0.tif")] # (escenario, N, ráster)
for N in (1, 2, 3):
casos += [(1, N, f"rsrp_esc1_N{N}.tif"), (2, N, f"rsrp_esc2_N{N}.tif"),
(3, N, f"rsrp_esc3_s0_N{N}.tif")] # Escenario 3: se dibuja el sorteo s0
filas = []
for e, N, ruta in casos:
with rasterio.open(ruta) as src:
rsrp, t = src.read(1), src.transform
# 3 = alta (≥ −100), 2 = media (≥ −120), 1 = baja (≥ −140), 0 = sin servicio (−9999)
clase = np.select([rsrp >= -100, rsrp >= -120, rsrp >= -140], [3, 2, 1], 0).astype("uint8")
for g, v in shapes(clase, mask=clase > 0, transform=t):
filas.append({"escenario": e, "n": N, "nivel": NIVEL[int(v)], "geometry": shape(g)})
esc = gpd.GeoDataFrame(filas, crs=32717).dissolve(["escenario", "n", "nivel"]).reset_index()
esc.to_crs(4326).to_file("escenarios.geojson", driver="GeoJSON") # 10 casos × 3 niveles
3) Capas grandes a PMTiles (resumen de lo que hace make_tiles.py):
import gzip
import geopandas as gpd
import mercantile
from mapbox_vector_tile import encode
from shapely.geometry import box
from pmtiles.writer import Writer
from pmtiles.tile import TileType, Compression, zxy_to_tileid
from make_tiles import _solo_poligonos # descarta restos que no son polígonos
g = gpd.read_file("escenarios.geojson") # sale del bloque anterior (EPSG:4326)
minlon, minlat, maxlon, maxlat = g.total_bounds
g = g.to_crs(3857) # Web Mercator: el sistema de las teselas
MINZOOM, MAXZOOM = 9, 13 # valores de partida para El Valle
teselas = {}
for z in range(MINZOOM, MAXZOOM + 1):
tol = 40075016.686 / 2**z / 4096 # un "píxel" de la tesela, en metros
for t in mercantile.tiles(minlon, minlat, maxlon, maxlat, z):
b = mercantile.xy_bounds(t)
caja = box(b.left, b.bottom, b.right, b.top)
feats = []
for geom, e, n, nivel in zip(g.geometry, g.escenario, g.n, g.nivel):
if geom.intersects(caja):
recorte = _solo_poligonos(geom.simplify(tol).intersection(caja))
if recorte is not None:
feats.append({"geometry": recorte,
"properties": {"escenario": int(e), "n": int(n), "nivel": nivel}})
if feats:
mvt = encode([{"name": "escenarios", "features": feats}],
default_options={"quantize_bounds": (b.left, b.bottom, b.right, b.top),
"extents": 4096})
teselas[zxy_to_tileid(z, t.x, t.y)] = gzip.compress(mvt)
with open("data/el_valle/escenarios.pmtiles", "wb") as f:
w = Writer(f)
for tid in sorted(teselas): # en orden de id de tesela
w.write_tile(tid, teselas[tid])
w.finalize(
{"tile_type": TileType.MVT, "tile_compression": Compression.GZIP,
"min_zoom": MINZOOM, "max_zoom": MAXZOOM,
"min_lon_e7": int(minlon * 1e7), "min_lat_e7": int(minlat * 1e7),
"max_lon_e7": int(maxlon * 1e7), "max_lat_e7": int(maxlat * 1e7),
"center_zoom": 12, "center_lon_e7": int((minlon + maxlon) / 2 * 1e7),
"center_lat_e7": int((minlat + maxlat) / 2 * 1e7)},
{"name": "Escenarios El Valle", "vector_layers": [{"id": "escenarios"}]})
4) Métricas de la Etapa 4 a JSON (las lee la API del paso 5.2):
import json
import pandas as pd
m = pd.read_csv("metricas_etapa4.csv") # tabla metricas de 4.5 (metricas.to_csv)
m["escenario"] = m["escenario"].str[3].astype(int) # "esc1" -> 1, "esc3_s2" -> 3
t = m.groupby(["escenario", "N"], as_index=False).mean(numeric_only=True) # Esc. 3: media de 5 sorteos
A0 = (t["area_km2"] - t["delta_area_km2"]).iloc[0] # área del Escenario 0 (A − ΔA)
filas = [{"escenario": 0, "n": 0, "area_km2": round(A0, 2), "pct_area": round(100 * A0 / 43.03, 1),
"delta_area_km2": 0.0, "delta_pob": 0, "escuelas": 0, "salud": 0}]
filas += t.rename(columns={"N": "n"}).round(2).to_dict("records") # los otros 9 casos
with open("data/el_valle/metricas_escenarios.json", "w", encoding="utf-8") as f:
json.dump(filas, f, ensure_ascii=False, indent=1)
Se conservan los nombres de columna de la Etapa 4 (area_km2,
pct_area, delta_area_km2, delta_pob, escuelas,
salud); solo cambian escenario (de texto a número) y N
(a n, como en la dirección de la API).
metricas de la Etapa 4. Se llega por el "Sí" de la Etapa 4..geojson, .pmtiles y .json en data/el_valle/ (se suben al repositorio; en la imagen de Docker se copian a data/processed/), que usan 5.2 y 5.3.El servidor es el programa que está siempre esperando pedidos. Cuando el navegador pide algo (una página, un pedazo del mapa o un dato), lo busca y lo devuelve. Se programa en Python con Flask.
Se programa el archivo app.py, que entrega tres tipos de cosas:
data/processed/ (PMTiles, GeoJSON y JSON), entregados por partes cuando el
navegador lo pide.Todos los datos se cargan una sola vez, al arrancar, y se guardan en memoria. Como son pocos y nadie los modifica desde la web, no hace falta una base de datos.
app.py: qué dirección atiende cada
función y qué devuelve.@app.route("/api/punto")) se dice qué función responde a qué dirección. Es
liviana: el app.py actual tiene unas 220 líneas./api/punto. Los datos del pedido van después del signo ?:
/api/punto?lat=-2.93325&lon=-78.9682.{"nivel": "alta"}. Lo entienden Python y JavaScript sin esfuerzo./api/meta.send_from_directory(..., conditional=True).app.py, un STRtree
de shapely) que agrupa los polígonos por los rectángulos que los encierran. Para saber qué
polígonos contienen un punto, primero descarta los rectángulos lejanos y solo revisa a fondo
unos pocos candidatos.| Ruta | Qué devuelve | Estado |
|---|---|---|
/ | La página del mapa | Existe |
/archivos | La explicación de los datos | Existe |
/roadmap | Esta página del diagrama | Existe |
/data/<archivo> | PMTiles, GeoJSON y JSON, con peticiones por rangos | Existe |
/api/meta | API de capas: nombres, colores, umbrales y áreas | Existe |
/api/punto?lat=&lon= | Nivel 4G de cada operadora en un punto y cuál conviene | Existe |
/api/ranking | Porcentaje de cobertura por parroquia del Azuay | Existe |
/health | "Estoy funcionando" (para Dokploy y para las pruebas) | Existe |
/api/escenarios | Escenarios 0 a 3 y valores de \(N\) | Propuesto |
/api/metricas?escenario=&n= | Las métricas de la Etapa 4 de ese caso | Propuesto |
/api/punto ampliada | Además, la nota de idoneidad de la celda y si el punto gana cobertura en cada escenario | Propuesto |
El centro de gravedad de la comunidad La Pradera en la Tabla 4-50 del PDOT es X 725 841.69, Y 9 675 579.94 (UTM 17S). Convertido con pyproj, queda en latitud −2.93325 y longitud −78.96820. Esta es la respuesta del servidor actual:
GET /api/punto?lat=-2.93325&lon=-78.9682
{
"lat": -2.93325, "lon": -78.9682,
"dentro_azuay": true, "cubierto": true,
"recomendado": "MOVISTAR (OTECEL)",
"operadores": [
{ "op": "movistar", "op_label": "MOVISTAR (OTECEL)",
"tecnologias": { "4G": { "nivel": "alta", "dbm": -100, "color": "#18A34B",
"metric": "RSRP (DL) (dBm)" } } },
{ "op": "claro", "op_label": "CLARO (CONECEL)",
"tecnologias": { "4G": { "nivel": "media", "dbm": -120, "color": "#E8B400",
"metric": "RSRP (DL) (dBm)" } } }
]
}
Se lee así: en La Pradera, Movistar tiene RSRP ≥ −100 dBm (alta) y Claro, entre −120 y
−100 dBm (media); por eso recomienda Movistar. Ojo: dbm es el umbral
inferior de la banda, no una medición.
Respuesta real del sitio actual, recortada a la parte 4G.
Lo primero que pide el mapa es el principio del PMTiles, donde están el encabezado y el índice.
Pedido del navegador:
GET /data/cobertura.pmtiles
Range: bytes=0-16383
Respuesta del servidor:
HTTP/1.1 206 PARTIAL CONTENT
Accept-Ranges: bytes
Content-Range: bytes 0-16383/1674904
Content-Type: application/octet-stream
Cada línea es una cabecera: una nota que acompaña al pedido o a la respuesta.
Range = qué bytes quiero. Accept-Ranges: bytes = sí sé entregar por
partes. Content-Range = qué bytes van y cuánto mide el archivo completo.
Content-Type = qué tipo de archivo es (octet-stream: datos binarios).
El servidor entregó 16 384 bytes de un archivo de 1 674 904: menos del 1 %. Después, el mapa pide cada tesela visible por su posición en el archivo.
¿En qué celda cae un punto? Como la malla es regular, no hace falta buscar: se calcula. Con \((x, y)\) en UTM, el origen de la malla \((x_0, y_0)\) y el lado \(\ell\):
$$\text{col} = \left\lfloor \frac{x - x_0}{\ell} \right\rfloor \qquad \text{fila} = \left\lfloor \frac{y - y_0}{\ell} \right\rfloor$$Las métricas que entrega la API (se calculan en la Etapa 4 y aquí solo se leen), donde \(A_{e,N}\) es el área con RSRP ≥ −120 dBm del escenario \(e\) con \(N\) radiobases nuevas (en el Escenario 3, la media de sus 5 sorteos) y \(A_0\) la del Escenario 0:
$$\Delta A_{e,N} = A_{e,N} - A_{0} \qquad \%A_{e,N} = 100 \cdot \frac{A_{e,N}}{43.03\ \text{km}^2}$$La Pradera en una malla de 250 m con origen en \(x_0 = 721\,500\), \(y_0 = 9\,669\,250\) (la esquina suroeste de El Valle redondeada a 250 m):
\(\text{col} = \lfloor (725\,841.69 - 721\,500) / 250 \rfloor = \lfloor 17.37 \rfloor = 17\) y \(\text{fila} = \lfloor (9\,675\,579.94 - 9\,669\,250) / 250 \rfloor = \lfloor 25.32 \rfloor = 25\).
Basta leer la celda (17, 25) de la tabla: una operación, en vez de revisar las ≈ 688 celdas.
El origen de la malla es un ejemplo; se usará el de la malla ganadora.
Así funciona hoy la búsqueda con el índice espacial (resumen de app.py):
from shapely import STRtree
from shapely.geometry import Point
_TREE = STRtree(_GEOMS) # se construye una vez, al arrancar
punto = Point(lon, lat)
# El árbol descarta los polígonos lejanos y prueba a fondo solo los cercanos
for idx in _TREE.query(punto, predicate="intersects"):
banda = _BANDAS[int(idx)] # operadora, nivel, dBm y color de esa banda
...
Y así se agregarían las rutas de escenarios y métricas (propuesta):
import json
import math
from flask import Flask, jsonify, request, abort
from pyproj import Transformer
app = Flask(__name__)
# Se lee UNA vez al arrancar, igual que META y RANKING en app.py
with open("data/processed/metricas_escenarios.json", encoding="utf-8") as f:
METRICAS = json.load(f) # lista de dicts armada en 5.1: escenario, n, area_km2, ...
NOMBRES = {0: "Radiobases existentes", 1: "Modelo propuesto",
2: "Planificación tradicional", 3: "Control (al azar)"}
@app.route("/api/escenarios")
def api_escenarios():
return jsonify([{"escenario": e, "nombre": NOMBRES[e],
"n": [0] if e == 0 else [1, 2, 3]} for e in NOMBRES])
@app.route("/api/metricas")
def api_metricas():
esc = request.args.get("escenario", type=int) # None si no es un número
n = request.args.get("n", type=int)
if esc is None or n is None:
return jsonify({"error": "faltan escenario y n"}), 400
for fila in METRICAS:
if fila["escenario"] == esc and fila["n"] == n:
return jsonify({"nombre": NOMBRES[esc], **fila})
abort(404) # ese caso no existe
# Para la /api/punto ampliada: celda de la malla que contiene un punto
A_UTM = Transformer.from_crs(4326, 32717, always_xy=True)
X0, Y0, LADO = 721500, 9669250, 250
def celda_de(lat, lon):
x, y = A_UTM.transform(lon, lat)
return math.floor((x - X0) / LADO), math.floor((y - Y0) / LADO)
/api/metricas?escenario=1&n=2{
"escenario": 1, "nombre": "Modelo propuesto", "n": 2,
"area_km2": 25.40, "pct_area": 59.0,
"delta_area_km2": 3.90, "delta_pob": 2180,
"escuelas": 2, "salud": 1
}
Los nombres son los de la tabla metricas de la Etapa 4: delta_pob es
la población que gana cobertura, y escuelas y salud, las escuelas y los
centros de salud que la ganan.
Valores ilustrativos: los mismos de la tabla de 4.6. Con un Escenario 0 de 21.50 km² (50.0 %), el aumento es 25.40 − 21.50 = 3.90 km², es decir, 9.1 puntos porcentuales de los 43.03 km² de El Valle.
data/el_valle/; dentro de la imagen de Docker están en data/processed/).app.py con las páginas, los archivos y la API. Se une con la interfaz en el paso 5.4.La interfaz es lo que el visitante ve y toca en su navegador: el mapa, los interruptores de capas, la leyenda y el comparador. Se programa en JavaScript con MapLibre GL JS, que dibuja el mapa con los archivos del paso 5.1.
Se parte del mapa que ya existe (templates/index.html y
static/js/mapa.js, unas 540 líneas) y se le agrega un apartado para El Valle:
/api/metricas.clip-path es una orden de CSS que muestra solo una
parte de un elemento.fill), línea (line) o círculo (circle), con un color y
un filtro. Una misma fuente puede alimentar varias capas.clip-path) a partir de donde está la barra.
Usa eventos pointer, que sirven igual para el ratón y para el dedo. En la Etapa 5 solo
cambia lo que muestra cada lado: Escenario 0 frente al escenario elegido. Así el lector ve con
sus ojos cuánta cobertura 4G agrega cada propuesta.Colores reales del sitio (definidos en preprocess.py). El mapa de
idoneidad usa la escala morada de la Etapa 3.
| Capa | Fuente | Tipo en MapLibre | Color | Al tocarla |
|---|---|---|---|---|
| Claro 4G / Movistar 4G / CNT 4G | cobertura.pmtiles | fill | Semáforo por nivel | Nivel de RSRP de cada operadora |
| Idoneidad | idoneidad_250m.geojson | fill | Morado según la nota | Nota y déficit 4G de la celda |
| Ubicaciones | ubicaciones.geojson | circle | Rojo de la Etapa 5 | Sitio, celda y nota |
| Escenarios | escenarios.pmtiles | fill | Semáforo por nivel | Nivel simulado |
| Límite de El Valle | GeoJSON (OSM) | line | Gris | — |
¿Cuántos metros cubre un píxel? En MapLibre, en el zoom \(z\) el mundo mide \(512 \cdot 2^{z}\) píxeles de ancho. En la latitud \(\varphi\):
$$\text{m/píxel}(z) = \frac{40\,075\,016\ \text{m} \cdot \cos\varphi}{512 \cdot 2^{z}}$$Color de una celda. MapLibre mezcla dos colores de la escala en proporción a la nota \(s\) (por separado en rojo, verde y azul):
$$c(s) = c_a + \frac{s - s_a}{s_b - s_a}\,(c_b - c_a) \qquad s_a \leq s \leq s_b$$\(s_a\) y \(s_b\) son las notas de los dos colores de la escala entre los que cae \(s\) (por ejemplo 0.5 y 1.0); \(c_a\) y \(c_b\) son el valor de rojo, verde o azul de esos colores (de 0 a 255).
Posición de la barra del comparador, en porcentaje del ancho \(W\) del mapa, limitada entre 4 % y 96 % para que la barra no se salga:
$$p = \min\left(96,\ \max\left(4,\ 100 \cdot \frac{x_{\text{dedo}} - x_{\text{borde}}}{W}\right)\right)$$\(x_{\text{dedo}}\) es la posición del dedo o del ratón y \(x_{\text{borde}}\) la del borde izquierdo del mapa, ambas en píxeles contados desde el borde izquierdo de la pantalla.
Tamaño en pantalla. En El Valle (\(\varphi \approx -2.94^{\circ}\)): zoom 11 → 38.2 m/píxel, así que una celda de 250 m ocupa unos 6.5 píxeles. Zoom 13 → 9.54 m/píxel: una celda de 100 m ocupa unos 10.5 píxeles. Por eso la malla de 100 m se aprecia a partir del zoom 13.
Color. La celda C0421 tiene nota \(s = 0.73\), entre los colores de 0.5
(#B58BD1) y 1.0 (#5E2285). La fracción es \((0.73 - 0.5)/0.5 = 0.46\).
Los colores se escriben en hexadecimal, dos cifras por canal: en el rojo, B5 = 181 y 5E = 94.
Canal rojo: \(181 + 0.46\,(94 - 181) = 141\), que en hexadecimal es 8D. Con verde y azul igual,
queda #8D5BAE, un morado medio.
Comparador. En un celular de 390 píxeles de ancho, el mapa ocupa toda la pantalla (\(x_{\text{borde}} = 0\)). Si el dedo suelta la barra en \(x_{\text{dedo}} = 117\): \(p = 100 \cdot (117 - 0)/390 = 30\,\%\), que está entre 4 y 96, así que se queda. El 30 % izquierdo muestra el Escenario 0 y el 70 % derecho, el escenario con las radiobases nuevas.
Las notas y la posición del dedo son ilustrativas; la resolución por zoom es un cálculo real.
Fuentes y capas (la sintaxis es la del mapa.js actual):
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile); // enseña a MapLibre a leer PMTiles
map.on("load", () => {
// Capas 4G: una fuente PMTiles, una capa por operadora
map.addSource("cobertura", {
type: "vector", url: "pmtiles://" + location.origin + "/data/cobertura.pmtiles" });
map.addLayer({
id: "claro-4g", type: "fill", source: "cobertura", "source-layer": "cobertura",
filter: ["==", ["get", "layer_id"], "claro_4g"],
paint: { "fill-color": ["get", "color"], "fill-opacity": 0.55 } });
// Mapa de idoneidad: color interpolado según la nota de 0 a 1
map.addSource("idoneidad", { type: "geojson", data: "/data/idoneidad_250m.geojson" });
map.addLayer({
id: "idoneidad", type: "fill", source: "idoneidad",
paint: {
"fill-color": ["interpolate", ["linear"], ["get", "idoneidad"],
0, "#F2EBF7", 0.5, "#B58BD1", 1, "#5E2285"],
"fill-opacity": 0.7 } });
// Ubicaciones: solo los sitios 1..N (N elegido en el selector)
map.addSource("ubicaciones", { type: "geojson", data: "/data/ubicaciones.geojson" });
map.addLayer({
id: "ubicaciones", type: "circle", source: "ubicaciones",
filter: ["<=", ["get", "sitio"], 2], // N = 2
paint: { "circle-radius": 8, "circle-color": "#C0392B",
"circle-stroke-color": "#ffffff", "circle-stroke-width": 2 } });
});
El corazón del comparador (simplificado de mapa.js):
// Mover o hacer zoom en un mapa replica la vista en el otro
a.on("move", () => b.jumpTo({ center: a.getCenter(), zoom: a.getZoom() }));
// La barra: el mapa derecho se recorta desde el pct % del ancho
const setPos = (pct) => {
pct = Math.max(4, Math.min(96, pct));
derDiv.style.clipPath = "inset(0 0 0 " + pct + "%)";
handle.style.left = pct + "%";
};
En mapa.js la sincronización tiene además un seguro para que los dos
mapas no se llamen el uno al otro sin fin.
templates/*.html, static/js/*.js y static/css/main.css con el mapa de El Valle.Desplegar es "abrir el local": poner la plataforma en una computadora que está
encendida y conectada a internet todo el tiempo, con la dirección tesisucuenca.com,
para que cualquiera la pueda abrir.
app.py,
templates/, static/) y los datos (data/raw/ con los
mapas de las operadoras y data/el_valle/ con los productos del paso 5.1) a un
repositorio Git.cobertura-azuay/Dockerfile, contexto
cobertura-azuay (escrito con /; las barras invertidas de Windows
rompen la construcción) y puerto interno 5000.tesisucuenca.com con el contenedor y se encarga del certificado
HTTPS.https://tesisucuenca.com/health.tesisucuenca.compreprocess.py y
make_tiles.py y genera data/processed/. La etapa final copia solo
esos archivos (más, según la propuesta, los de data/el_valle/) y no lleva
GeoPandas, así que es más liviana y tiene menos piezas que puedan fallar.python app.py) es solo para desarrollo. app:app significa "en el
archivo app.py, la variable app".tesisucuenca.com por HTTPS (puerto 443).data/processed/ no se sube al
repositorio (está en .gitignore): se genera de nuevo en cada construcción, así que
el resultado siempre sale de los datos crudos y se puede reproducir. La construcción necesita
internet, porque descarga los límites parroquiales de GADM. Los productos de El Valle
(idoneidad, ubicaciones y escenarios) no se pueden recalcular dentro de Docker, porque vienen del
modelo y de Radio Mobile. Por eso se propone subirlos ya exportados, en una carpeta propia
(data/el_valle/, paso 5.1), y copiarlos a la imagen con una línea más en el
Dockerfile. Pesan poco.| Etapa 1: builder | Etapa 2: la que se publica | |
|---|---|---|
| Base | python:3.11-slim | python:3.11-slim |
| Librerías | requirements-dev.txt: geopandas, pandas, pyproj, fiona, pmtiles, mapbox-vector-tile, mercantile | requirements.txt: flask 3.0.3, gunicorn 22.0.0 y shapely 2.0.6 |
| Qué hace | Ejecuta preprocess.py y make_tiles.py | Sirve la web con gunicorn |
| ¿Llega al VPS en funcionamiento? | No: se descarta, salvo data/processed/ | Sí |
Cuántos pedidos atiende gunicorn a la vez, y cuántos por segundo si cada uno tarda \(t\):
$$\text{simultáneos} = \text{procesos} \times \text{hilos} = 1 \times 4 = 4 \qquad \text{pedidos/s} \approx \frac{4}{t}$$Si una respuesta de la API tarda \(t = 50\) ms, gunicorn atiende unos \(4 / 0.05 = 80\) pedidos por segundo: de sobra para una plataforma académica. Se usa un solo proceso porque cada proceso carga todas las bandas en memoria; con 4 hilos dentro del mismo proceso se atienden varios pedidos sin repetir esa carga.
El tiempo de 50 ms es ilustrativo.
Extracto real del Dockerfile, con una línea propuesta (marcada) para los productos
de El Valle:
# Etapa 1: builder (prepara los datos y las teselas)
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY preprocess.py make_tiles.py ./
COPY data/raw ./data/raw
RUN python preprocess.py && python make_tiles.py
# Etapa 2: runtime (solo sirve la web)
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY templates ./templates
COPY static ./static
COPY --from=builder /build/data/processed ./data/processed
# propuesto: productos de El Valle (5.1)
COPY data/el_valle/ ./data/processed/
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "4", \
"--timeout", "120", "app:app"]
En el archivo real la última orden va en una sola línea; aquí se partió con
\ para que quepa. En un Dockerfile los comentarios van en su propia
línea, empezando con #.
import json
import urllib.request
with urllib.request.urlopen("https://tesisucuenca.com/health", timeout=10) as r:
print(r.status, json.load(r))
# 200 {'bandas': 9, 'capas': 3, 'ok': True}
La última línea es la respuesta esperada cuando la plataforma tenga solo las capas 4G: 3 capas (Claro, Movistar y CNT) × 3 niveles = 9 bandas. Hoy responde 18 bandas y 6 capas, porque el sitio aún incluye las capas 2G y 3G, que la tesis no usa, y todavía no tiene CNT.
https://tesisucuenca.com, lista para probarse.Antes de dar por terminada la plataforma, se prueba como la usaría un visitante: en computadora y en celular, con distintos navegadores. Si algo falla, se corrige y se vuelve a publicar.
smoke_test.py, que revisa las rutas y la API.| ID | Qué se prueba | Cómo | Aprueba si... |
|---|---|---|---|
| P1 | El servidor está vivo | Automática | /health responde 200 con "ok": true |
| P2 | Las páginas cargan | Automática | /, /archivos y /roadmap responden 200 |
| P3 | Teselas por rangos | Automática | Range: bytes=0-99 devuelve 206 y Accept-Ranges: bytes |
| P4 | Consulta de punto | Automática | Las 49 comunidades del PDOT responden 200 con un nivel 4G por operadora (o sin servicio); lat=abc da 400 |
| P5 | Métricas | Automática | Los 10 casos de /api/metricas coinciden con la tabla de la Etapa 4 |
| P6 | Capas 4G | Manual | Las bandas alta, media y baja de cada operadora se ven con el color de la leyenda al acercarse y alejarse sobre El Valle |
| P7 | Idoneidad | Manual | En 3 celdas al azar, la nota que muestra el mapa es la del Producto 3 |
| P8 | Ubicaciones | Manual | \(N = 1, 2, 3\) muestran 1, 2 y 3 marcadores en las coordenadas del Producto 4 |
| P9 | Comparador | Manual | La barra se arrastra con ratón y con el dedo, y los dos lados se mueven juntos |
| P10 | Pantalla angosta | Automática y manual | A 360 y 390 px no hay desplazamiento horizontal, los paneles se pliegan y los botones se pueden tocar |
| P11 | Rapidez | Manual | El mapa aparece en menos de 5 s en un celular con datos 4G |
| P12 | Conexión segura | Manual | Se ve el candado, y http:// redirige a https:// |
Los umbrales (5 s, 3 celdas, anchos de 360 y 390 px) son valores de partida.
| Dispositivo | Sistema | Navegadores | Ancho de pantalla |
|---|---|---|---|
| Computadora de escritorio | Windows | Chrome y Firefox | 1 920 px |
| Laptop | Windows | Edge | 1 366 px |
| Celular Android | Android | Chrome | 360–412 px |
| iPhone | iOS | Safari | 390 px |
Propuesta: se ajusta a los equipos que se tengan a mano.
Para la prueba de rapidez, el tiempo de descarga es aproximadamente el tamaño \(B\) (en bytes) por 8, dividido para la velocidad de la conexión \(v\) (en bits por segundo):
$$t \approx \frac{8 \cdot B}{v}$$Rapidez. Con una conexión de \(v = 10\) Mbit/s, el GeoJSON completo de las bandas del Azuay (34.0 MB, archivo actual con todas las tecnologías) tardaría \(8 \times 34.0 \times 10^{6} / 10^{7} = 27.2\) s. Solo su parte 4G (unos 6.7 MB) tardaría \(8 \times 6.7 \times 10^{6} / 10^{7} \approx 5.4\) s: tampoco pasaría la prueba P11. Las 2 teselas de El Valle en el zoom 11 (158 kB) tardan \(0.13\) s.
La velocidad es ilustrativa; los tamaños son reales, salvo los 6.7 MB, que son una estimación (cerca del 20 % del GeoJSON actual).
| Ronda | Aprobadas | Qué falló | Decisión |
|---|---|---|---|
| 1 | 10 / 12 | P9: en el iPhone la barra del comparador no se mueve con el dedo · P10: a 360 px la leyenda tapa medio mapa | No: corregir en 5.3 y desplegar |
| 2 | 12 / 12 | — | Sí: documentar |
Rondas ilustrativas.
Pruebas automáticas P1 a P4, con el cliente de pruebas de Flask (amplía el
smoke_test.py actual):
import pandas as pd
from pyproj import Transformer
from app import app
cliente = app.test_client()
a_gps = Transformer.from_crs(32717, 4326, always_xy=True)
# P1 y P2: servidor vivo y páginas
for ruta in ["/health", "/", "/archivos", "/roadmap"]:
assert cliente.get(ruta).status_code == 200, ruta
assert cliente.get("/health").get_json()["ok"] is True
# P3: peticiones por rangos (las necesita PMTiles)
r = cliente.get("/data/cobertura.pmtiles", headers={"Range": "bytes=0-99"})
assert r.status_code == 206 and r.headers["Accept-Ranges"] == "bytes"
# P4: los 49 centros de gravedad de la Tabla 4-50 del PDOT (digitalizada en 1.2)
comunidades = pd.read_csv("poblacion_pdot.csv") # 1.2: comunidad, x, y, hab, area_ha
fallos = []
for c in comunidades.itertuples():
lon, lat = a_gps.transform(c.x, c.y)
r = cliente.get(f"/api/punto?lat={lat:.6f}&lon={lon:.6f}")
if r.status_code != 200:
fallos.append((c.comunidad, r.status_code))
continue
ops = r.get_json()["operadores"]
# Cada operadora debe traer la clave "4G": un nivel, o None si no hay servicio
if len(ops) != 2 or any("4G" not in o["tecnologias"] for o in ops):
fallos.append((c.comunidad, "sin clave 4G"))
print(f"{len(comunidades) - len(fallos)} de {len(comunidades)} comunidades bien", fallos)
assert not fallos
assert cliente.get("/api/punto?lat=abc&lon=1").status_code == 400
Prueba P10 en pantalla de celular, con Playwright (una librería que maneja un navegador de verdad desde Python):
from playwright.sync_api import sync_playwright
PANTALLAS = {"escritorio": (1366, 768), "movil": (390, 844)}
with sync_playwright() as p:
navegador = p.chromium.launch()
for nombre, (ancho, alto) in PANTALLAS.items():
es_movil = nombre == "movil"
pagina = navegador.new_page(viewport={"width": ancho, "height": alto},
is_mobile=es_movil, has_touch=es_movil)
pagina.goto("https://tesisucuenca.com/", wait_until="networkidle")
desborda = pagina.evaluate(
"document.documentElement.scrollWidth > window.innerWidth")
hay_mapa = pagina.locator("canvas.maplibregl-canvas").count() > 0
pagina.screenshot(path=f"prueba_{nombre}.png") # evidencia para la memoria
print(nombre, "desborda" if desborda else "ok", "| mapa:", hay_mapa)
pagina.close()
navegador.close()
tesisucuenca.com (paso 5.4).Es el último paso del plan: contar en la memoria lo que salió de cada etapa. El Capítulo 5, "Resultados y Discusión", reúne las cifras, tablas y mapas y los compara con otros trabajos. Después viene el FIN del diagrama.
Memoria/TFG.tex). Tiene 6 capítulos: Introducción, Marco Teórico, Estado del Arte,
Metodología, Resultados y Discusión, y Conclusiones y Recomendaciones.Memoria/sections/05_Resultados.tex).
El cómo se hizo cada cosa va en el Capítulo 4 (Metodología); el Capítulo 5 cuenta
qué salió.| Sección del Capítulo 5 | Sale de | Qué se muestra |
|---|---|---|
| 5.1 Análisis de zonas con cobertura deficiente en la parroquia El Valle | Etapa 2 (2.1 y 2.2) | Área y porcentaje de El Valle en cada nivel de RSRP por operadora; mapa del déficit 4G; población afectada por comunidad (PDOT) |
| 5.2 Escenarios de ubicación candidata generados por el modelo | Etapa 3 | Tabla de las 3 mallas (F1, AUC-ROC, error OOB); importancia de variables; mapa de idoneidad (Producto 3); ubicaciones para \(N = 1, 2, 3\) (Producto 4) |
| 5.3 Evaluación comparativa de escenarios según cobertura proyectada, viabilidad topográfica y beneficio social | Etapa 4 (4.4 a 4.6) | Tabla de métricas por escenario y \(N\); mapas de cobertura lado a lado |
| 5.4 Validación de resultados frente a simulaciones en herramientas tradicionales | Etapa 4 (4.1 a 4.3 y el último rombo) | Configuración de Radio Mobile; calibración del Escenario 0 y su concordancia con el mapa 4G de la operadora; respuesta a si el Escenario 1 supera al 2 y al 3 |
| 5.5 Discusión del valor diferencial del modelo propuesto | Todas, más el estado del arte | Escenario 1 frente a 2 y 3; comparación con trabajos como Sakti et al. o Fauzi et al.; la plataforma como forma de consultar los resultados |
Los títulos de las secciones son los de 05_Resultados.tex; el reparto
de contenidos entre ellas es una propuesta. También lo es dónde se describe la plataforma (Etapa
5): su diseño encaja en la sección
"Desarrollo de la plataforma de visualización interactiva" del Capítulo 4, y su resultado (enlace,
capturas y pruebas) en la discusión o en un anexo.
El plan se da por cumplido cuando cada objetivo específico tiene un producto y una evidencia en la memoria:
| Objetivo | Qué pedía | Producto o evidencia |
|---|---|---|
| OE1 | Obtener y estructurar los datos | Producto 1 (base de datos geoespacial) y Producto 2 (tres tablas de entrenamiento) |
| OE2 | Implementar el modelo de IA | Producto 3 (mapa de idoneidad) y Producto 4 (ubicaciones recomendadas) |
| OE3 | Evaluar frente a una herramienta tradicional | Simulación calibrada y tabla comparativa de los escenarios |
| OE4 | Plataforma web interactiva | tesisucuenca.com funcionando, con las pruebas aprobadas |
Una forma de resumir si el modelo aporta algo es su ventaja sobre la mejor de las otras dos formas de elegir sitios, para cada \(N\). \(\Delta A_{e,N}\) es el área que gana cobertura con RSRP ≥ −120 dBm en el escenario \(e\) con \(N\) radiobases nuevas:
$$V_N = \Delta A_{1,N} - \max\left(\Delta A_{2,N},\ \overline{\Delta A}_{3,N}\right)$$\(\overline{\Delta A}_{3,N}\) es la media de los 5 sorteos del Escenario 3 (el control al azar), igual que en el último rombo de la Etapa 4.
Si \(V_N \gt 0\) para \(N = 1, 2\) y \(3\), el modelo supera a la planificación tradicional y al azar. La misma cuenta se hace con la población que gana cobertura.
| \(N\) | \(\Delta A_1\) (modelo) | \(\Delta A_2\) (tradicional) | \(\overline{\Delta A}_3\) (azar, media) | \(V_N\) |
|---|---|---|---|---|
| 1 | 2.30 km² | 2.10 km² | 1.10 km² | +0.20 km² |
| 2 | 3.90 km² | 3.40 km² | 2.10 km² | +0.50 km² |
| 3 | 5.20 km² | 4.30 km² | 2.90 km² | +0.90 km² |
Valores ilustrativos: los mismos de la tabla de 4.6. En este caso la tercera radiobase del modelo agrega menos que la segunda (1.30 frente a 1.60 km²), y la ventaja con \(N = 1\) es pequeña (0.20 km²): justo el tipo de hallazgo que se discute.
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
# Tabla de la Etapa 4 -> tabla LaTeX para la sección 5.3
m = pd.read_csv("metricas_etapa4.csv") # tabla metricas de 4.5 (metricas.to_csv)
tabla = (m.assign(grupo=m["escenario"].str[:4]) # esc1, esc2, esc3 (5 sorteos)
.pivot_table(index="N", columns="grupo", values="delta_area_km2", aggfunc="mean"))
tabla.columns = ["Escenario 1", "Escenario 2", "Escenario 3"] # esc3 = media de los sorteos
tabla["V_N"] = tabla["Escenario 1"] - tabla[["Escenario 2", "Escenario 3"]].max(axis=1)
print(tabla.to_latex(float_format="%.2f",
caption="Área que gana cobertura 4G (km$^2$) según el número de radiobases nuevas",
label="tab:delta_area"))
# Mapa de idoneidad -> figura en PDF para la sección 5.2
malla = gpd.read_file("cobertura-azuay/data/el_valle/idoneidad_250m.geojson").to_crs(32717)
ax = malla.plot(column="idoneidad", cmap="Purples", legend=True, figsize=(6, 6))
ax.set_axis_off() # sin ejes ni marco, como pide la guía
plt.savefig("Memoria/fig/idoneidad_el_valle.pdf", bbox_inches="tight")
Las figuras siguen Memoria/ESTILO_FIGURAS.md: por ejemplo, Claro
siempre en rojo ladrillo (#C0392B), Movistar en verde bosque (#278642) y
CNT en azul acero (#2E75B6).