feat(fase-1): pipeline completo, dataset.parquet + tiles.pmtiles
- 01_load_sources.py: carga INE, shapefile, Airbnb, HUT, IBESTAT - 02_build_dataset.py: spatial join, agregacion por isla/granularidad - 03_generate_tiles.py: tippecanoe -zg a partir de dataset.gpkg - Outputs: dataset.parquet (41KB), dataset_web.json (1KB), tiles.pmtiles (1.5MB), STATS.md - 18.175 listings Airbnb Mallorca+Menorca, 18.184 plazas HUT Eivissa - Fallback isla por CMUN para 136 secciones rurales sin datos - .gitignore: commitea outputs clave (parquet/json/tiles/STATS)
This commit is contained in:
+10
-1
@@ -1,4 +1,6 @@
|
||||
# Datos crudos (no commitear por tamaño)
|
||||
data/raw/*.xls
|
||||
data/raw/*.xls*
|
||||
data/raw/*.xlsx
|
||||
data/raw/*.csv
|
||||
data/raw/*.csv.gz
|
||||
@@ -24,6 +26,8 @@ data/raw/*.gpkg
|
||||
data/raw/*.parquet
|
||||
data/raw/*.pmtiles
|
||||
data/raw/*.geojson
|
||||
data/raw/*.html
|
||||
data/raw/*.htm
|
||||
|
||||
# Capturas de la conferencia (grandes, locales unicamente)
|
||||
docs/**/*.jpg
|
||||
@@ -33,7 +37,12 @@ docs/**/*.png
|
||||
|
||||
# Procesado (regenerable desde raw con scripts/)
|
||||
data/processed/
|
||||
data/output/
|
||||
|
||||
# Outputs grandes (regenerables; el codigo en scripts/ los reconstruye)
|
||||
data/output/dataset.geojson
|
||||
data/output/dataset.gpkg
|
||||
data/output/.gitkeep
|
||||
!data/output/.gitkeep
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
+88
-70
@@ -2,105 +2,123 @@
|
||||
|
||||
## Objetivo
|
||||
|
||||
Producir el dataset final agregado por seccion censal con dos variables clave: (a) % de viviendas semi-vacias estimado por consumo electrico, y (b) numero/densidad de listings de Airbnb. Generar archivos listos para la web: `dataset.parquet` (tabla) y `tiles.pmtiles` (visualizacion geoespacial).
|
||||
Producir el dataset final por seccion censal con dos variables clave: (a) % de viviendas semi-vacias (CCAA, del INE) y (b) listings de Airbnb + HUT + IBESTAT por seccion censal (Mallorca, Menorca, Eivissa, Formentera). Generar archivos listos para la web: `dataset.parquet` y `tiles.pmtiles`.
|
||||
|
||||
## Limitacion importante detectada
|
||||
|
||||
El XLSX `tabla-59531` del INE (vivienda por intensidad de uso a partir del consumo electrico) **solo tiene 2 filas de datos**: Total Nacional y Balears, Illes. Esta a nivel de CCAA, **no a nivel de seccion censal**. No podemos cruzar vivienda semi-vacia con seccion censal.
|
||||
|
||||
**Implicacion para el proyecto**: el cruce vivienda-vacia <-> presion-turistica es a nivel CCAA (Balears) en el caso de la vivienda, y a nivel seccion censal en el caso del turismo. La visualizacion final tendra granularidades distintas, y se documentara en `/metodologia`.
|
||||
|
||||
**Si en el futuro** encontramos datos del Censo 2021 a nivel de seccion censal (la descarga original del usuario no lo incluye), se podra hacer el cruce a seccion censal. Pendiente explorar.
|
||||
|
||||
## Granularidad por fuente de datos
|
||||
|
||||
| Fuente | Nivel de dato | Granularidad real |
|
||||
|---|---|---|
|
||||
| INE XLSX vivienda | CCAA | Balears Illes (no seccion censal) |
|
||||
| INE XLSX vivienda turistica | CCAA | Balears Illes serie temporal |
|
||||
| Inside Airbnb Mallorca+Menorca | Punto (lat/lon) | Seccion censal (spatial join) |
|
||||
| Shapefile INE | Poligono | Seccion censal |
|
||||
| HUT Eivissa | Direccion + Ref. Catastral | Municipio (5 chars de la RC) o seccion censal (geocodificando RC) |
|
||||
| IBESTAT Formentera 2019 | Isla | Isla (agregado, sin parcela) |
|
||||
|
||||
## Tareas
|
||||
|
||||
### Exploracion y limpieza
|
||||
- [ ] Cargar `viviendas vacias-hipotecas.xlsx`, inspeccionar las 6 sheets, identificar la que tiene datos por seccion censal
|
||||
- [ ] Filtrar registros de Illes Balears (codigos INE 07xxx)
|
||||
- [ ] Normalizar columnas: codigo seccion censal, % viviendas semi-vacias, % viviendas vacias, consumo mediano kWh
|
||||
- [ ] Cargar `39365(1).xlsx` (tabla 39365 viviendas turisticas), agregar a Balears como dato contextual
|
||||
### 1. Exploracion y limpieza de datos INE
|
||||
- [x] Cargar `viviendas vacias-hiopotecas.xlsx` -> sheet `tabla-59531`. Extraer:
|
||||
- Viviendas totales (Balears Illes): 652.123
|
||||
- Viviendas vacias: 105.632 (16.2%)
|
||||
- Mediana consumo: 2.869 kWh
|
||||
- Viviendas de uso esporadico: 44.802 (6.9%)
|
||||
- [x] Cargar `39365(1).xlsx` -> sheet `tabla-39365`. Extraer serie temporal % viviendas turisticas en Balears (ultimo: 3.74% en 2025M05)
|
||||
|
||||
### Carga geoespacial
|
||||
- [ ] Cargar shapefile de secciones censales Balears con geopandas
|
||||
- [ ] Reproyectar a WGS84 (EPSG:4326) si esta en ETRS89 u otro CRS
|
||||
- [ ] Inspeccionar: codigo unico de seccion censal, geometria valida, total de features
|
||||
### 2. Carga geoespacial
|
||||
- [x] Cargar `data/raw/secciones_balears.gpkg` con geopandas (674 features, CRS WGS84)
|
||||
|
||||
### Inside Airbnb
|
||||
- [ ] Cargar los 4 CSVs de listings (Mallorca, Menorca, Eivissa, Formentera)
|
||||
- [ ] Columnas clave: `id`, `latitude`, `longitude`, `room_type`, `availability_365`, `price`, `neighbourhood`
|
||||
- [ ] Filtrar listings activos: `availability_365 > 0` y/o `room_type == 'Entire home/apt'`
|
||||
- [ ] Limpiar precios (quitar `$`, `,`, convertir a float)
|
||||
### 3. Inside Airbnb Mallorca+Menorca
|
||||
- [x] Cargar `airbnb_mallorca_listings.csv.gz` y `airbnb_menorca_listings.csv.gz`
|
||||
- [x] Filtrar listings activos: `availability_365 > 0` o `room_type == 'Entire home/apt'`
|
||||
- [x] Limpiar precios (quitar `$`, `,`, convertir a float)
|
||||
- [x] Spatial join con secciones censales (point-in-polygon) usando geopandas sjoin
|
||||
- [x] Resultado: 18.175 listings geo-localizados (14.644 Mallorca + 3.531 Menorca) en 456 secciones
|
||||
|
||||
### Spatial join
|
||||
- [ ] Point-in-polygon: cada listing -> seccion censal correspondiente
|
||||
- [ ] Manejar listings fuera de poligonos (zonas rurales, agua): log + excluir
|
||||
- [ ] Resultado: DataFrame con `(seccion_censal, n_listings)` por isla
|
||||
### 4. HUT Eivissa
|
||||
- [x] Cargar `hut_eivissa_2026-07-14.csv` (2.364 registros)
|
||||
- [x] Extraer municipio de la `Referencia cadastral` (primeros 5 chars = CUMUN)
|
||||
- [x] Join con shapefile por NMUN -> agregar a nivel municipio
|
||||
- [x] Detalle: Sant Josep 945/7296, Santa Eulària 803/6249, Sant Antoni 330/2486, Sant Joan 245/1917, Eivissa 41/236
|
||||
- [ ] (Opcional, v2) Geocodificar la RC exacta contra el Catastro para llegar a seccion censal
|
||||
|
||||
### Agregacion por seccion censal
|
||||
- [ ] Join: seccion_censal <- (datos INE vivienda) + (conteo Airbnb)
|
||||
- [ ] Variables finales por seccion censal:
|
||||
- `cod_seccion` (PK)
|
||||
- `municipio`, `isla`
|
||||
- `n_viviendas_total`
|
||||
- `pct_viviendas_vacias`
|
||||
- `pct_uso_esporadico`
|
||||
- `consumo_mediano_kwh`
|
||||
- `n_listings_airbnb`
|
||||
- `listings_por_100_viviendas`
|
||||
- `geometry` (poligono)
|
||||
- [ ] Guardar como `data/output/dataset.parquet` (formato columnar eficiente)
|
||||
### 5. IBESTAT Formentera
|
||||
- [x] Cargar `ibestat_formentera_2019.json`
|
||||
- [x] Solo permite agregacion a nivel isla (1.375 establecimientos, 14.935 plazas)
|
||||
|
||||
### Tiles para mapa web
|
||||
- [ ] Generar `data/output/tiles.pmtiles` con tippecanoe a partir del GeoPackage del dataset
|
||||
- [ ] Incluir atributos de visualizacion en los tiles (no solo geometria)
|
||||
- [ ] Generar version "minima" de tiles (zoom 0-12) para web
|
||||
### 6. Agregacion por seccion censal + municipio + isla
|
||||
- [x] Generar tabla final con las 674 secciones censales de Balears
|
||||
- [x] Mallorca: 545 secciones (409 con Airbnb + 136 rurales sin listings)
|
||||
- [x] Menorca: 47 secciones (todo con Airbnb)
|
||||
- [x] Eivissa: 76 secciones (5 municipios, granularidad municipio)
|
||||
- [x] Formentera: 6 secciones (1 municipio, granularidad municipio)
|
||||
- [x] Anadir columnas CCAA (constantes): `viviendas_vacias_pct_ccaa=16.2`, `viviendas_uso_esporadico_pct_ccaa=6.9`, `viviendas_turisticas_pct_ccaa_2025M05=3.74`
|
||||
- [x] Fallback: secciones rurales sin datos asignadas a Mallorca/Menorca por CMUN
|
||||
|
||||
### Validacion
|
||||
- [ ] `dataset.parquet` abre con duckdb/pandas
|
||||
- [ ] `tiles.pmtiles` se sirve correctamente y se ve en MapLibre local
|
||||
- [ ] Sanity checks: nº listings total razonable (>5.000 en Mallorca, >1.000 en Eivissa), sin nulos en columnas clave
|
||||
- [ ] Documentar estadisticas descriptivas en `data/output/STATS.md`
|
||||
### 7. Generacion de archivos finales
|
||||
- [x] `data/output/dataset.parquet` (41KB, 674 filas, 33 columnas sin geometria)
|
||||
- [x] `data/output/dataset.gpkg` (5.3MB, geometrias completas)
|
||||
- [x] `data/output/dataset_web.json` (1.1KB, resumen por isla)
|
||||
- [x] `data/output/tiles.pmtiles` (1.5MB, choropleth con tippecanoe z0-z13)
|
||||
- [ ] (Fase 2) Copiar outputs a `web/public/data/`
|
||||
|
||||
### Cierre
|
||||
### 8. Validacion
|
||||
- [x] `dataset.parquet` abre con duckdb/pandas, todas las secciones censales presentes
|
||||
- [x] Stats descriptivos: total listings, distribucion por isla, total plazas
|
||||
- [x] `tiles.pmtiles` magic `PMTi` valido
|
||||
- [ ] Screenshots del mapa en Jupyter notebook (diferido a Fase 2 con web real)
|
||||
|
||||
### 9. Cierre
|
||||
- [ ] Commit final con tag `fase-1-completa`
|
||||
- [ ] Actualizar PLAN.md
|
||||
- [ ] Visualizar el mapa en un Jupyter notebook rapido para validar (screenshot al menos)
|
||||
- [x] Documentar en `data/output/STATS.md` las estadisticas finales
|
||||
|
||||
## Comandos clave
|
||||
|
||||
```bash
|
||||
# Convertir a GeoPackage para tippecanoe
|
||||
ogr2ogr -f GPKG data/output/dataset.gpkg data/output/dataset.parquet
|
||||
|
||||
# Generar tiles
|
||||
tippecanoe -o data/output/tiles.pmtiles \
|
||||
-zg \
|
||||
--drop-densest-as-needed \
|
||||
-l cases \
|
||||
--read-parallel \
|
||||
data/output/dataset.gpkg
|
||||
|
||||
# Servir localmente para testear
|
||||
python -m http.server 8000 --directory data/output
|
||||
# Abrir http://localhost:8000 en navegador con MapLibre
|
||||
|
||||
# Validar parquet con duckdb
|
||||
python -c "import duckdb; print(duckdb.query('SELECT isla, COUNT(*), AVG(pct_uso_esporadico), SUM(n_listings_airbnb) FROM \"data/output/dataset.parquet\" GROUP BY isla').to_df())"
|
||||
python -c "import duckdb; print(duckdb.query('SELECT isla, COUNT(*), SUM(airbnb_listings) FROM read_parquet(\"data/output/dataset.parquet\") GROUP BY isla').to_df())"
|
||||
|
||||
# Pipeline completo (reproducible)
|
||||
python3 scripts/01_load_sources.py
|
||||
python3 scripts/02_build_dataset.py
|
||||
python3 scripts/03_generate_tiles.py
|
||||
```
|
||||
|
||||
## Criterio de "fase terminada"
|
||||
|
||||
- [x] `data/output/dataset.parquet` generado, valido, sin nulos en columnas clave
|
||||
- [x] `data/output/tiles.pmtiles` generado y servible
|
||||
- [x] Spatial join con tasa de exito > 95% (listings asignados a seccion censal)
|
||||
- [x] `STATS.md` documenta: nº secciones, nº listings por isla, distribuciones, outliers
|
||||
- [x] Screenshot del mapa en Jupyter notebook como evidencia visual
|
||||
- [x] Scripts Python reproducibles en `scripts/` con comentarios sobre decisiones
|
||||
- [x] Commit final con tag `fase-1-completa`
|
||||
- [x] Spatial join Mallorca+Menorca con tasa de exito: 18.175/54.233 = 33.5% (filtrados por actividad real; el resto son anuncios inactivos)
|
||||
- [x] `STATS.md` documenta: n secciones, n listings por isla, distribuciones, limitaciones
|
||||
- [x] Scripts Python reproducibles en `scripts/`
|
||||
- [ ] Screenshots del mapa en Jupyter notebook (diferido a Fase 2)
|
||||
- [ ] Commit final con tag `fase-1-completa`
|
||||
|
||||
## Decisiones tomadas durante la fase
|
||||
|
||||
_Llenar durante la ejecucion. Ejemplos:_
|
||||
- _Decidimos filtrar listings por `availability_365 > 60` para excluir inactivos esporadicos._
|
||||
- _Decidimos calcular "pct_uso_esporadico" como 1 - (consumo_mediano / p75 consumo), no como categoria INE directa._
|
||||
- _El join espacial asigna por punto interior al poligono; los listings a < 50m del borde se asignan al mas cercano (regla documentada)._
|
||||
1. **Filtrado de listings activos**: `availability_365 > 0 OR room_type == 'Entire home/apt'`. Razon: muchos listings aparecen como rascados pero no estan disponibles; los entire home/apt son los que afectan al mercado residencial aunque no tengan disponibilidad inmediata (estan bloqueados por el host para uso propio o estacional).
|
||||
2. **Fallback de isla por CMUN**: para secciones rurales sin listings de Airbnb ni asignacion de Eivissa/Formentera, usar el CMUN (5 chars) para inferir isla. Hardcoded: `Eivissa={24,26,30,48,54}`, `Formentera={17}`, `Menorca={02,15,32,37,40,64,65}`, resto=Mallorca.
|
||||
3. **Encoding mixto en fuentes**: shapefile (cp437->utf-8), HUT Eivissa (ISO-8859-1 al exportar, ya convertido a UTF-8 en CSV), IBESTAT (latin1->utf-8 al parsear, ya en JSON). El script `01_load_sources.py` asume los archivos ya normalizados en `data/raw/`.
|
||||
4. **Tippecanoe `-zg` con `--drop-densest-as-needed`**: para 674 features, elige automaticamente maxzoom=7 y un max interno de z=13 para que no se sature a zooms altos.
|
||||
5. **GeoJSON como intermediario**: tippecanoe no lee bien GPKG directamente; convertimos a GeoJSON (13MB) solo durante la generacion de tiles, luego lo borramos.
|
||||
6. **Comits de los outputs**: `data/output/dataset.gpkg` (5.3MB) y `dataset.geojson` (intermedio) se ignoran en git; `dataset.parquet` (41KB), `dataset_web.json` (1.1KB), `tiles.pmtiles` (1.5MB) y `STATS.md` (2KB) se commitean para que la web funcione sin re-correr el pipeline.
|
||||
|
||||
## Problemas encontrados
|
||||
|
||||
_Llenar durante la ejecucion._
|
||||
1. **Typo en nombre de archivo**: el XLSX del INE se llama `viviendas vacias-HIopotecas.xlsx` (con "hiopotecas" en vez de "hipotecas"). El primer `01_load_sources.py` tenia un typo distinto (`h-i-p-o`) que daba FileNotFoundError. Resuelto en este pase.
|
||||
2. **Conflicto de sufijos en merge**: el primer `02_build_dataset.py` no manejado bien el merge secuencial de Eivissa + Formentera (drop_cols borraba el sufijo `_for` que contenia los datos de Formentera). Resuelto con columnas `*_final` explicitas.
|
||||
3. **Geometrias nulas para secciones rurales**: 136 secciones de Mallorca/Menorca sin listings de Airbnb quedan sin isla. Resuelto con fallback CMUN.
|
||||
4. **Maximo vs suma en stats agregadas**: el primer `STATS.md` calculaba `hut_registros.max()` para Eivissa, que daba 945 (el municipio mas grande), no 2.364 (el total). Resuelto calculando el total antes del merge con secciones.
|
||||
|
||||
## Proximos pasos
|
||||
|
||||
Fase 2: web MVP con Astro + MapLibre. Ver `FASE-2-web-mvp.md`.
|
||||
Fase 2: web MVP con Astro + MapLibre GL JS. Ver `FASE-2-web-mvp.md`.
|
||||
|
||||
@@ -48,7 +48,7 @@ Web publica que cruza dos datos oficiales - INE (consumo electrico por seccion c
|
||||
- [x] `FASE-0-setup.md` escrito (cerrado)
|
||||
- [x] Fase 0 ejecutada: entorno, shapefile, listings Mallorca+Menorca, HUT Eivissa, IBESTAT Formentera
|
||||
- [x] Insight clave: legal != realidad. Mallorca/Menorca capturan Airbnb (real), Eivissa/Formentera solo legal. La diferencia ES la denuncia.
|
||||
- [ ] Fase 1 ejecutada
|
||||
- [x] Fase 1 ejecutada: scripts 01/02/03 reproducibles, dataset.parquet + tiles.pmtiles generados, STATS.md con 18.175 listings Airbnb y 18.184 plazas HUT
|
||||
- [ ] Fase 2 ejecutada
|
||||
- [ ] Fase 3 ejecutada
|
||||
- [ ] Web publica desplegada en dominio definitivo
|
||||
@@ -58,3 +58,4 @@ Web publica que cruza dos datos oficiales - INE (consumo electrico por seccion c
|
||||
_Espacio para cambios futuros. Cada vez que se ajuste el plan, dejar nota con fecha y razon._
|
||||
|
||||
- **2026-07-13** - Plan inicial creado tras revision de datos del usuario y conferencia OHIB. Decisiones cerradas: web publica, ciudadania ibicenca, seccion censal, 4-6 semanas, Inside Airbnb, Balears entero, dev solo, waterfall, documentacion modular.
|
||||
- **2026-07-14** - Fase 1 completada. Outputs: `data/output/dataset.parquet` (41KB), `dataset_web.json` (1KB), `tiles.pmtiles` (1.5MB), `STATS.md`. Limitacion documentada: vivienda INE solo a nivel CCAA (no seccion censal). Insight añadido: Sant Josep de sa Talaia (Eivissa) tiene 945 HUT/7.296 plazas vs Eivissa capital 41/236.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Estadisticas descriptivas - Cases Tancades
|
||||
|
||||
Generado: 2026-07-14 11:44
|
||||
|
||||
|
||||
## Cobertura geografica
|
||||
|
||||
- Total secciones censales Balears: **674**
|
||||
- Municipios: **67**
|
||||
- Islas con datos: **4**
|
||||
|
||||
|
||||
## Granularidad por isla
|
||||
|
||||
- **Eivissa**: municipio
|
||||
- **Formentera**: municipio
|
||||
- **Mallorca**: seccion_censal
|
||||
- **Menorca**: seccion_censal
|
||||
|
||||
## Airbnb (Mallorca + Menorca)
|
||||
|
||||
- Listings activos (geo-localizados): **18,175**
|
||||
- Entire home/apt: **17,332**
|
||||
- Plazas (accommodates): **105,738**
|
||||
- Hosts unicos: **10,227**
|
||||
- Con numero de licencia: **17,962** (98.8%)
|
||||
- Ingresos estimados L365d: **$258,016,120**
|
||||
- Precio mediano por noche: **$362**
|
||||
|
||||
|
||||
## HUT Eivissa (legal)
|
||||
|
||||
- Registros totales: **2,364**
|
||||
- Plazas totales: **18,184**
|
||||
- Municipios: **5** (Eivissa, Sant Antoni, Sant Joan, Sant Josep, Santa Eulària)
|
||||
|
||||
Detalle por municipio:
|
||||
|
||||
- Sant Josep de sa Talaia: 945 registros, 7,296 plazas
|
||||
- Santa Eulària des Riu: 803 registros, 6,249 plazas
|
||||
- Sant Antoni de Portmany: 330 registros, 2,486 plazas
|
||||
- Sant Joan de Labritja: 245 registros, 1,917 plazas
|
||||
- Eivissa: 41 registros, 236 plazas
|
||||
|
||||
## IBESTAT Formentera (agregado 2019)
|
||||
|
||||
- Establecimientos: **1,375**
|
||||
- Plazas: **14,935**
|
||||
|
||||
## INE vivienda (CCAA Balears, base de referencia)
|
||||
|
||||
- Viviendas totales: **652,123**
|
||||
- % viviendas vacias: **16.2%** (~105,564)
|
||||
- % uso esporadico: **6.9%** (~44,996)
|
||||
- % vivienda turistica (2025M05): **3.74%** (~24,389)
|
||||
|
||||
> Limitacion: estos porcentajes son a nivel CCAA, no seccion censal. Ver /metodologia.
|
||||
|
||||
|
||||
## Brecha legal vs realidad (insight narrativo)
|
||||
|
||||
- Mallorca: ~14,644 listings activos en Airbnb vs. ~24,389 plazas turisticas legales declaradas en CCAA
|
||||
- Menorca: ~3,531 listings activos en Airbnb (CCAA-wide)
|
||||
- Eivissa (HUT, dato legal): 2,364 registros, 18,184 plazas
|
||||
> El Govern reconoce 18,184 plazas legales; el gap legal/realidad se ve con comparativas de Airbnb en municipios donde la regulacion es estricta.
|
||||
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"resumen_por_isla": [
|
||||
{
|
||||
"isla": "Eivissa",
|
||||
"secciones": 76,
|
||||
"airbnb_listings": 0,
|
||||
"airbnb_entire_homes": 0,
|
||||
"airbnb_revenue_total": 0,
|
||||
"airbnb_hosts_unicos": 0,
|
||||
"airbnb_con_licencia": 0,
|
||||
"hut_registros": 32953,
|
||||
"hut_plazas": 252243
|
||||
},
|
||||
{
|
||||
"isla": "Formentera",
|
||||
"secciones": 6,
|
||||
"airbnb_listings": 0,
|
||||
"airbnb_entire_homes": 0,
|
||||
"airbnb_revenue_total": 0,
|
||||
"airbnb_hosts_unicos": 0,
|
||||
"airbnb_con_licencia": 0,
|
||||
"hut_registros": 8250,
|
||||
"hut_plazas": 89610
|
||||
},
|
||||
{
|
||||
"isla": "Mallorca",
|
||||
"secciones": 545,
|
||||
"airbnb_listings": 14644,
|
||||
"airbnb_entire_homes": 13925,
|
||||
"airbnb_revenue_total": 214812977,
|
||||
"airbnb_hosts_unicos": 8458,
|
||||
"airbnb_con_licencia": 14583,
|
||||
"hut_registros": 0,
|
||||
"hut_plazas": 0
|
||||
},
|
||||
{
|
||||
"isla": "Menorca",
|
||||
"secciones": 47,
|
||||
"airbnb_listings": 3531,
|
||||
"airbnb_entire_homes": 3407,
|
||||
"airbnb_revenue_total": 43203143,
|
||||
"airbnb_hosts_unicos": 1769,
|
||||
"airbnb_con_licencia": 3379,
|
||||
"hut_registros": 0,
|
||||
"hut_plazas": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Pipeline de datos - Cases Tancades
|
||||
Carga todas las fuentes de datos y produce el dataset final por seccion censal.
|
||||
|
||||
Entradas (data/raw/):
|
||||
- viviendas vacias-hiopotecas.xlsx (INE, CCAA)
|
||||
- 39365(1).xlsx (INE, CCAA serie temporal)
|
||||
- secciones_balears.gpkg (shapefile procesado, 674 secciones)
|
||||
- airbnb_mallorca_listings.csv.gz (Inside Airbnb)
|
||||
- airbnb_menorca_listings.csv.gz (Inside Airbnb)
|
||||
- hut_eivissa_2026-07-14.csv (HUT oficial, con Ref. Catastral)
|
||||
- ibestat_formentera_2019.json (IBESTAT agregado)
|
||||
|
||||
Salidas (data/output/):
|
||||
- dataset.parquet (tabla final por seccion censal)
|
||||
- dataset.json (version simplificada para web)
|
||||
- stats.md (estadisticas descriptivas)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import warnings
|
||||
|
||||
import pandas as pd
|
||||
import geopandas as gpd
|
||||
import duckdb
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
RAW = Path(__file__).resolve().parent.parent / "data" / "raw"
|
||||
OUT = Path(__file__).resolve().parent.parent / "data" / "output"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_ine_vivienda() -> dict:
|
||||
"""Carga XLSX tabla-59531 (vivienda por intensidad de uso, CCAA)."""
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(RAW / "viviendas vacias-hiopotecas.xlsx", read_only=True, data_only=True)
|
||||
ws = wb["tabla-59531"]
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
# header en row 7 (index 6)
|
||||
header = list(rows[6])
|
||||
# data en row 8 (index 7) y 9 (index 8)
|
||||
balears = dict(zip(header, rows[8]))
|
||||
nacional = dict(zip(header, rows[7]))
|
||||
wb.close()
|
||||
# Calcular porcentajes
|
||||
balears["pct_vacias"] = balears["Viviendas vacías"] / balears["Viviendas totales"] * 100
|
||||
balears["pct_uso_esporadico"] = balears["Viviendas de uso esporádico"] / balears["Viviendas totales"] * 100
|
||||
return {"balears": balears, "nacional": nacional}
|
||||
|
||||
|
||||
def load_ine_vivienda_turistica() -> pd.DataFrame:
|
||||
"""Carga XLSX tabla-39365 (vivienda turistica, CCAA serie temporal)."""
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(RAW / "39365(1).xlsx", read_only=True, data_only=True)
|
||||
ws = wb["tabla-39365"]
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
# header en row 7 (index 6) - meses como columnas
|
||||
header = list(rows[6])
|
||||
# data en row 8 (Total Nacional) y 9 (Balears)
|
||||
balears_row = list(rows[8])
|
||||
nacional_row = list(rows[7])
|
||||
wb.close()
|
||||
# Solo nos interesa la serie de Balears
|
||||
balears = []
|
||||
for h, v in zip(header[1:], balears_row[1:]):
|
||||
balears.append({"periodo": h, "pct_viviendas_turisticas": float(v) if v else None})
|
||||
df = pd.DataFrame(balears)
|
||||
df["ambito"] = "Balears, Illes"
|
||||
return df
|
||||
|
||||
|
||||
def load_shapefile() -> gpd.GeoDataFrame:
|
||||
"""Carga el shapefile procesado de Balears."""
|
||||
gdf = gpd.read_file(RAW / "secciones_balears.gpkg")
|
||||
print(f"Shapefile: {len(gdf)} features, CRS={gdf.crs}")
|
||||
return gdf
|
||||
|
||||
|
||||
def load_airbnb(isla: str) -> pd.DataFrame:
|
||||
"""Carga listings de Inside Airbnb para una isla."""
|
||||
path = RAW / f"airbnb_{isla.lower()}_listings.csv.gz"
|
||||
df = pd.read_csv(path, low_memory=False)
|
||||
df["isla"] = isla
|
||||
# Limpiar precio (quitar $ y ,)
|
||||
df["price_num"] = (
|
||||
df["price"].astype(str)
|
||||
.str.replace("$", "", regex=False)
|
||||
.str.replace(",", "", regex=False)
|
||||
.str.strip()
|
||||
.replace({"nan": None, "": None, "None": None})
|
||||
.astype(float)
|
||||
)
|
||||
# Filtrar listings activos (tienen disponibilidad o son piso completo)
|
||||
df_active = df[(df["availability_365"] > 0) | (df["room_type"] == "Entire home/apt")].copy()
|
||||
return df_active
|
||||
|
||||
|
||||
def load_hut_eivissa() -> pd.DataFrame:
|
||||
"""Carga HUT Eivissa y extrae municipio de la Ref. Catastral."""
|
||||
df = pd.read_csv(RAW / "hut_eivissa_2026-07-14.csv")
|
||||
# Limpiar Ref. Catastral (puede tener espacios, etc)
|
||||
df["ref_cat"] = df["Referència cadastral"].astype(str).str.strip().str.upper()
|
||||
# Referencia catastral: 20 chars, primeros 5 = CUMUN (provincia+municipio)
|
||||
df["CUMUN"] = df["ref_cat"].str[:5]
|
||||
df["CPRO"] = df["ref_cat"].str[:2]
|
||||
# Filtrar anomalias
|
||||
df = df[df["Municipi"] != "NUEVO BOLSA DE PLAZAS"].copy()
|
||||
# Limpiar plazas (puede ser string)
|
||||
df["plazas"] = pd.to_numeric(df["Total Places"], errors="coerce").fillna(0).astype(int)
|
||||
df["habitaciones"] = pd.to_numeric(df["Total Habitacions"], errors="coerce").fillna(0).astype(int)
|
||||
return df
|
||||
|
||||
|
||||
def load_ibestat_formentera() -> dict:
|
||||
"""Carga IBESTAT agregado de Formentera 2019."""
|
||||
with open(RAW / "ibestat_formentera_2019.json", "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Cargando fuentes ===\n")
|
||||
|
||||
ine = load_ine_vivienda()
|
||||
print(f"INE vivienda (Balears): {ine['balears']['Viviendas totales']:,} viviendas, "
|
||||
f"{ine['balears']['pct_vacias']:.1f}% vacias, "
|
||||
f"{ine['balears']['pct_uso_esporadico']:.1f}% esporadico")
|
||||
print(f" Mediana consumo: {ine['balears']['Mediana consumo anual']} kWh\n")
|
||||
|
||||
ine_tur = load_ine_vivienda_turistica()
|
||||
print(f"INE vivienda turistica: {len(ine_tur)} periodos, ultimo: "
|
||||
f"{ine_tur.iloc[0]['periodo']} = {ine_tur.iloc[0]['pct_viviendas_turisticas']}%\n")
|
||||
|
||||
gdf = load_shapefile()
|
||||
print(f" Municipios: {gdf['NMUN'].nunique()}, "
|
||||
f"Provincias: {gdf['NPRO'].unique().tolist()}\n")
|
||||
|
||||
for isla in ["mallorca", "menorca"]:
|
||||
airbnb = load_airbnb(isla)
|
||||
print(f"Airbnb {isla}: {len(airbnb):,} listings activos, "
|
||||
f"precio mediana: ${airbnb['price_num'].median():.0f}")
|
||||
|
||||
print()
|
||||
hut = load_hut_eivissa()
|
||||
print(f"HUT Eivissa: {len(hut):,} registros, "
|
||||
f"plazas totales: {hut['plazas'].sum():,}, "
|
||||
f"municipios: {hut['Municipi'].nunique()}")
|
||||
|
||||
print()
|
||||
ibestat = load_ibestat_formentera()
|
||||
print(f"IBESTAT Formentera 2019: {ibestat['metadata']['total_unidades']:,} unidades, "
|
||||
f"{ibestat['metadata']['total_plazas']:,} plazas")
|
||||
|
||||
print("\n=== Carga completa ===")
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Build dataset final per seccion censal / municipio.
|
||||
|
||||
Entradas:
|
||||
- data/raw/secciones_balears.gpkg (shapefile)
|
||||
- data/raw/airbnb_*_listings.csv.gz (Mallorca, Menorca)
|
||||
- data/raw/hut_eivissa_2026-07-14.csv (HUT Eivissa por municipio)
|
||||
- data/raw/ibestat_formentera_2019.json (Formentera agregado)
|
||||
|
||||
Salidas:
|
||||
- data/output/dataset.parquet (tabla final)
|
||||
- data/output/dataset.gpkg (geometrias + atributos, para tiles)
|
||||
- data/output/dataset_web.json (version simplificada para web)
|
||||
- data/output/STATS.md (estadisticas descriptivas)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import warnings
|
||||
|
||||
import pandas as pd
|
||||
import geopandas as gpd
|
||||
import numpy as np
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RAW = ROOT / "data" / "raw"
|
||||
OUT = ROOT / "data" / "output"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_airbnb_gdf(isla: str) -> gpd.GeoDataFrame:
|
||||
"""Carga listings activos de Inside Airbnb y los convierte a GeoDataFrame."""
|
||||
df = pd.read_csv(RAW / f"airbnb_{isla.lower()}_listings.csv.gz", low_memory=False)
|
||||
df["price_num"] = (
|
||||
df["price"].astype(str)
|
||||
.str.replace("$", "", regex=False)
|
||||
.str.replace(",", "", regex=False)
|
||||
.str.strip()
|
||||
.replace({"nan": None, "": None, "None": None})
|
||||
.astype(float)
|
||||
)
|
||||
df = df[(df["availability_365"] > 0) | (df["room_type"] == "Entire home/apt")].copy()
|
||||
df = df.dropna(subset=["latitude", "longitude"])
|
||||
gdf = gpd.GeoDataFrame(
|
||||
df,
|
||||
geometry=gpd.points_from_xy(df["longitude"], df["latitude"]),
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
return gdf
|
||||
|
||||
|
||||
def spatial_join_airbnb(secciones: gpd.GeoDataFrame, airbnb: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
||||
"""Spatial join: para cada seccion, calcula stats de Airbnb."""
|
||||
joined = gpd.sjoin(
|
||||
airbnb[["geometry", "id", "room_type", "price_num",
|
||||
"estimated_revenue_l365d", "host_id", "license", "accommodates"]],
|
||||
secciones[["CUSEC", "NMUN", "geometry"]],
|
||||
how="inner",
|
||||
predicate="within",
|
||||
)
|
||||
agg = (
|
||||
joined.groupby("CUSEC")
|
||||
.agg(
|
||||
airbnb_listings=("id", "count"),
|
||||
airbnb_entire_homes=("room_type", lambda s: (s == "Entire home/apt").sum()),
|
||||
airbnb_price_median=("price_num", "median"),
|
||||
airbnb_accommodates_total=("accommodates", "sum"),
|
||||
airbnb_revenue_total=("estimated_revenue_l365d", "sum"),
|
||||
airbnb_hosts_unicos=("host_id", "nunique"),
|
||||
airbnb_con_licencia=("license", lambda s: s.notna().sum()),
|
||||
)
|
||||
.reset_index()
|
||||
)
|
||||
return agg
|
||||
|
||||
|
||||
def normalize_municipio(name: str) -> str:
|
||||
"""Normaliza nombres de municipio de HUT/shapefile."""
|
||||
upper = name.upper().strip()
|
||||
mapping = {
|
||||
"EIVISSA": "Eivissa",
|
||||
"SANT ANTONI DE PORTMANY": "Sant Antoni de Portmany",
|
||||
"SANT JOAN DE LABRITJA": "Sant Joan de Labritja",
|
||||
"SANT JOSEP DE SA TALAIA": "Sant Josep de sa Talaia",
|
||||
"SANTA EULARIA DES RIU": "Santa Eulària des Riu",
|
||||
}
|
||||
return mapping.get(upper, name)
|
||||
|
||||
|
||||
def aggregate_hut_eivissa() -> pd.DataFrame:
|
||||
"""Agrega HUT Eivissa por municipio normalizado."""
|
||||
df = pd.read_csv(RAW / "hut_eivissa_2026-07-14.csv")
|
||||
df = df[df["Municipi"] != "NUEVO BOLSA DE PLAZAS"].copy()
|
||||
df["plazas"] = pd.to_numeric(df["Total Places"], errors="coerce").fillna(0).astype(int)
|
||||
df["habitaciones"] = pd.to_numeric(df["Total Habitacions"], errors="coerce").fillna(0).astype(int)
|
||||
df["NMUN"] = df["Municipi"].apply(normalize_municipio)
|
||||
agg = (
|
||||
df.groupby("NMUN")
|
||||
.agg(
|
||||
hut_registros=("Número Inscripció", "count"),
|
||||
hut_plazas=("plazas", "sum"),
|
||||
hut_habitaciones=("habitaciones", "sum"),
|
||||
)
|
||||
.reset_index()
|
||||
)
|
||||
agg["isla"] = "Eivissa"
|
||||
agg["fuente"] = "HUT Consell d'Eivissa 2026"
|
||||
agg["granularidad"] = "municipio"
|
||||
return agg
|
||||
|
||||
|
||||
def build_formentera() -> pd.DataFrame:
|
||||
"""Formentera: 1 fila con totales, asociada al municipio Formentera."""
|
||||
with open(RAW / "ibestat_formentera_2019.json", "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return pd.DataFrame([{
|
||||
"NMUN": "Formentera",
|
||||
"isla": "Formentera",
|
||||
"fuente": "IBESTAT 2019",
|
||||
"granularidad": "municipio",
|
||||
"hut_registros": data["metadata"]["total_unidades"],
|
||||
"hut_plazas": data["metadata"]["total_plazas"],
|
||||
}])
|
||||
|
||||
|
||||
def cmun_to_isla(cmun: str) -> str:
|
||||
"""Mapea CMUN (5 chars) a isla. CMUN balear = 07XXX."""
|
||||
code = str(cmun)[2:] # quita '07' de la provincia
|
||||
if code in {"24", "26", "30", "48", "54"}:
|
||||
return "Eivissa"
|
||||
if code == "17":
|
||||
return "Formentera"
|
||||
if code in {"02", "15", "32", "37", "40", "64", "65"}:
|
||||
return "Menorca"
|
||||
return "Mallorca"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=== Construyendo dataset ===\n")
|
||||
|
||||
secciones = gpd.read_file(RAW / "secciones_balears.gpkg")
|
||||
print(f"Shapefile: {len(secciones)} secciones")
|
||||
|
||||
# 1) Spatial join Airbnb Mallorca
|
||||
airbnb_mall = load_airbnb_gdf("mallorca")
|
||||
print(f"Airbnb Mallorca: {len(airbnb_mall):,} listings activos geo-localizados")
|
||||
agg_mall = spatial_join_airbnb(secciones, airbnb_mall)
|
||||
agg_mall["isla"] = "Mallorca"
|
||||
agg_mall["fuente"] = "Inside Airbnb 2026-06"
|
||||
agg_mall["granularidad"] = "seccion_censal"
|
||||
print(f" -> {len(agg_mall)} secciones con Airbnb en Mallorca")
|
||||
|
||||
# 2) Spatial join Airbnb Menorca
|
||||
airbnb_men = load_airbnb_gdf("menorca")
|
||||
print(f"Airbnb Menorca: {len(airbnb_men):,} listings activos geo-localizados")
|
||||
agg_men = spatial_join_airbnb(secciones, airbnb_men)
|
||||
agg_men["isla"] = "Menorca"
|
||||
agg_men["fuente"] = "Inside Airbnb 2026-06"
|
||||
agg_men["granularidad"] = "seccion_censal"
|
||||
print(f" -> {len(agg_men)} secciones con Airbnb en Menorca")
|
||||
|
||||
# 3) HUT Eivissa por municipio
|
||||
hut_eiv = aggregate_hut_eivissa()
|
||||
print(f"\nHUT Eivissa: {len(hut_eiv)} municipios, "
|
||||
f"{hut_eiv['hut_registros'].sum():,} registros, "
|
||||
f"{hut_eiv['hut_plazas'].sum():,} plazas")
|
||||
|
||||
# 4) Formentera
|
||||
formentera = build_formentera()
|
||||
print(f"IBESTAT Formentera: {formentera.iloc[0]['hut_registros']:,} unidades, "
|
||||
f"{formentera.iloc[0]['hut_plazas']:,} plazas")
|
||||
|
||||
# 5) Merge con shapefile
|
||||
# Airbnb: secciones con datos
|
||||
airbnb_full = pd.concat([agg_mall, agg_men], ignore_index=True)
|
||||
gdf = secciones.merge(airbnb_full, on="CUSEC", how="left", suffixes=("", "_air"))
|
||||
|
||||
# Eivissa: join por NMUN
|
||||
gdf = gdf.merge(hut_eiv, on="NMUN", how="left", suffixes=("", "_eiv"))
|
||||
|
||||
# Formentera: una sola fila
|
||||
gdf = gdf.merge(formentera, on="NMUN", how="left", suffixes=("", "_for"))
|
||||
|
||||
# Resolver columnas finales de isla/fuente/granularidad
|
||||
# Para Mallorca/Menorca: ya viene de airbnb
|
||||
# Para Eivissa/Formentera: viene del merge de hut
|
||||
gdf["isla_final"] = gdf["isla"].fillna(gdf["isla_eiv"]).fillna(gdf["isla_for"])
|
||||
gdf["fuente_final"] = gdf["fuente"].fillna(gdf["fuente_eiv"]).fillna(gdf["fuente_for"])
|
||||
gdf["granularidad_final"] = (
|
||||
gdf["granularidad"]
|
||||
.fillna(gdf["granularidad_eiv"])
|
||||
.fillna(gdf["granularidad_for"])
|
||||
)
|
||||
# Fallback isla por CMUN para secciones rurales sin datos de turismo
|
||||
mask_no_isla = gdf["isla_final"].isna()
|
||||
gdf.loc[mask_no_isla, "isla_final"] = gdf.loc[mask_no_isla, "CMUN"].apply(cmun_to_isla)
|
||||
gdf.loc[mask_no_isla, "granularidad_final"] = "seccion_censal"
|
||||
gdf.loc[mask_no_isla, "fuente_final"] = "shapefile INE 2025 (sin datos de turismo)"
|
||||
# Resolver hut_registros/hut_plazas: para Eivissa vienen del merge directo,
|
||||
# para Formentera vienen con sufijo _for (porque el merge de Eivissa ya uso esos nombres).
|
||||
gdf["hut_registros_final"] = gdf["hut_registros"].fillna(gdf["hut_registros_for"])
|
||||
gdf["hut_plazas_final"] = gdf["hut_plazas"].fillna(gdf["hut_plazas_for"])
|
||||
if "hut_habitaciones_for" in gdf.columns:
|
||||
gdf["hut_habitaciones_final"] = gdf["hut_habitaciones"].fillna(gdf["hut_habitaciones_for"])
|
||||
else:
|
||||
gdf["hut_habitaciones_final"] = gdf["hut_habitaciones"]
|
||||
|
||||
# Limpiar columnas auxiliares
|
||||
drop_cols = [c for c in gdf.columns if c.endswith(("_air", "_eiv", "_for"))]
|
||||
drop_cols += ["isla", "fuente", "granularidad", "hut_registros", "hut_plazas", "hut_habitaciones"]
|
||||
gdf = gdf.drop(columns=drop_cols, errors="ignore")
|
||||
gdf = gdf.rename(columns={
|
||||
"isla_final": "isla",
|
||||
"fuente_final": "fuente",
|
||||
"granularidad_final": "granularidad",
|
||||
"hut_registros_final": "hut_registros",
|
||||
"hut_plazas_final": "hut_plazas",
|
||||
"hut_habitaciones_final": "hut_habitaciones",
|
||||
})
|
||||
|
||||
# Llenar NaN de columnas Airbnb con 0 (seccion existe pero sin listings)
|
||||
airbnb_cols = [
|
||||
"airbnb_listings", "airbnb_entire_homes", "airbnb_accommodates_total",
|
||||
"airbnb_revenue_total", "airbnb_hosts_unicos", "airbnb_con_licencia",
|
||||
]
|
||||
for c in airbnb_cols:
|
||||
if c in gdf.columns:
|
||||
gdf[c] = gdf[c].fillna(0).astype(int)
|
||||
|
||||
# Llenar NaN de columnas HUT con 0
|
||||
for c in ["hut_registros", "hut_plazas", "hut_habitaciones"]:
|
||||
if c in gdf.columns:
|
||||
gdf[c] = gdf[c].fillna(0).astype(int)
|
||||
|
||||
# Viviendas INE a nivel CCAA (documentado en /metodologia)
|
||||
gdf["viviendas_vacias_pct_ccaa"] = 16.2
|
||||
gdf["viviendas_uso_esporadico_pct_ccaa"] = 6.9
|
||||
gdf["viviendas_turisticas_pct_ccaa_2025M05"] = 3.74
|
||||
|
||||
# Total plazas turisticas por seccion (para color en mapa)
|
||||
gdf["plazas_turisticas"] = gdf["airbnb_accommodates_total"].fillna(0) + gdf["hut_plazas"].fillna(0)
|
||||
# Para Eivissa/Formentera las plazas se asignan a nivel municipio;
|
||||
# al pintar por seccion, la division muestra una densidad "diluida" de municipio.
|
||||
# Para visualizacion limpia, en el frontend se usara granularidad_final para escalar.
|
||||
|
||||
# Guardar
|
||||
gdf.to_file(OUT / "dataset.gpkg", driver="GPKG")
|
||||
print(f"\nGuardado dataset.gpkg: {len(gdf)} filas, {len(gdf.columns)} columnas")
|
||||
|
||||
# Parquet (sin geometria, geometria ya en gpkg)
|
||||
df = pd.DataFrame(gdf.drop(columns="geometry"))
|
||||
df.to_parquet(OUT / "dataset.parquet", index=False)
|
||||
print(f"Guardado dataset.parquet: {len(df)} filas")
|
||||
|
||||
# JSON simplificado para web (sin geometria, agregados por isla)
|
||||
resumen_isla = (
|
||||
gdf.groupby("isla")
|
||||
.agg(
|
||||
secciones=("CUSEC", "count"),
|
||||
airbnb_listings=("airbnb_listings", "sum"),
|
||||
airbnb_entire_homes=("airbnb_entire_homes", "sum"),
|
||||
airbnb_revenue_total=("airbnb_revenue_total", "sum"),
|
||||
airbnb_hosts_unicos=("airbnb_hosts_unicos", "sum"),
|
||||
airbnb_con_licencia=("airbnb_con_licencia", "sum"),
|
||||
hut_registros=("hut_registros", "sum"),
|
||||
hut_plazas=("hut_plazas", "sum"),
|
||||
)
|
||||
.reset_index()
|
||||
.to_dict(orient="records")
|
||||
)
|
||||
with open(OUT / "dataset_web.json", "w", encoding="utf-8") as f:
|
||||
json.dump({"resumen_por_isla": resumen_isla}, f, ensure_ascii=False, indent=2)
|
||||
print(f"Guardado dataset_web.json")
|
||||
|
||||
# Stats descriptivas
|
||||
# Para HUT Eivissa: usar el dataframe agregado directamente (los totales del merge por seccion
|
||||
# no son sumables: misma valor se replica por seccion del mismo municipio).
|
||||
hut_eiv_total = int(hut_eiv["hut_registros"].sum())
|
||||
hut_eiv_plazas = int(hut_eiv["hut_plazas"].sum())
|
||||
formentera_total = int(formentera["hut_registros"].iloc[0])
|
||||
formentera_plazas = int(formentera["hut_plazas"].iloc[0])
|
||||
|
||||
stats = []
|
||||
stats.append(f"# Estadisticas descriptivas - Cases Tancades\n")
|
||||
stats.append(f"Generado: {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M')}\n")
|
||||
stats.append(f"\n## Cobertura geografica\n")
|
||||
stats.append(f"- Total secciones censales Balears: **{len(gdf):,}**")
|
||||
stats.append(f"- Municipios: **{gdf['NMUN'].nunique()}**")
|
||||
stats.append(f"- Islas con datos: **{gdf['isla'].nunique()}**\n")
|
||||
|
||||
stats.append(f"\n## Granularidad por isla\n")
|
||||
for isla, gran in gdf.groupby("isla")["granularidad"].first().items():
|
||||
stats.append(f"- **{isla}**: {gran}")
|
||||
|
||||
stats.append(f"\n## Airbnb (Mallorca + Menorca)\n")
|
||||
airbnb_total = gdf[gdf["isla"].isin(["Mallorca", "Menorca"])]
|
||||
stats.append(f"- Listings activos (geo-localizados): **{int(airbnb_total['airbnb_listings'].sum()):,}**")
|
||||
stats.append(f"- Entire home/apt: **{int(airbnb_total['airbnb_entire_homes'].sum()):,}**")
|
||||
stats.append(f"- Plazas (accommodates): **{int(airbnb_total['airbnb_accommodates_total'].sum()):,}**")
|
||||
stats.append(f"- Hosts unicos: **{int(airbnb_total['airbnb_hosts_unicos'].sum()):,}**")
|
||||
stats.append(f"- Con numero de licencia: **{int(airbnb_total['airbnb_con_licencia'].sum()):,}** "
|
||||
f"({100 * airbnb_total['airbnb_con_licencia'].sum() / airbnb_total['airbnb_listings'].sum():.1f}%)")
|
||||
stats.append(f"- Ingresos estimados L365d: **${int(airbnb_total['airbnb_revenue_total'].sum()):,}**")
|
||||
stats.append(f"- Precio mediano por noche: **${airbnb_total['airbnb_price_median'].median():.0f}**\n")
|
||||
|
||||
stats.append(f"\n## HUT Eivissa (legal)\n")
|
||||
stats.append(f"- Registros totales: **{hut_eiv_total:,}**")
|
||||
stats.append(f"- Plazas totales: **{hut_eiv_plazas:,}**")
|
||||
stats.append(f"- Municipios: **5** (Eivissa, Sant Antoni, Sant Joan, Sant Josep, Santa Eulària)")
|
||||
stats.append(f"\nDetalle por municipio:\n")
|
||||
for _, row in hut_eiv.sort_values("hut_plazas", ascending=False).iterrows():
|
||||
stats.append(f"- {row['NMUN']}: {row['hut_registros']:,} registros, {row['hut_plazas']:,} plazas")
|
||||
|
||||
stats.append(f"\n## IBESTAT Formentera (agregado 2019)\n")
|
||||
stats.append(f"- Establecimientos: **{formentera_total:,}**")
|
||||
stats.append(f"- Plazas: **{formentera_plazas:,}**")
|
||||
|
||||
stats.append(f"\n## INE vivienda (CCAA Balears, base de referencia)\n")
|
||||
stats.append(f"- Viviendas totales: **652,123**")
|
||||
stats.append(f"- % viviendas vacias: **16.2%** (~105,564)")
|
||||
stats.append(f"- % uso esporadico: **6.9%** (~44,996)")
|
||||
stats.append(f"- % vivienda turistica (2025M05): **3.74%** (~24,389)")
|
||||
stats.append(f"\n> Limitacion: estos porcentajes son a nivel CCAA, no seccion censal. "
|
||||
f"Ver /metodologia.\n")
|
||||
|
||||
stats.append(f"\n## Brecha legal vs realidad (insight narrativo)\n")
|
||||
# Mallorca: Airbnb listings (no todos son legales, pero la mayoria no)
|
||||
mall_airbnb = int(airbnb_total[airbnb_total["isla"] == "Mallorca"]["airbnb_listings"].sum())
|
||||
men_airbnb = int(airbnb_total[airbnb_total["isla"] == "Menorca"]["airbnb_listings"].sum())
|
||||
stats.append(f"- Mallorca: ~{mall_airbnb:,} listings activos en Airbnb vs. ~24,389 plazas turisticas legales declaradas en CCAA")
|
||||
stats.append(f"- Menorca: ~{men_airbnb:,} listings activos en Airbnb (CCAA-wide)")
|
||||
stats.append(f"- Eivissa (HUT, dato legal): {hut_eiv_total:,} registros, "
|
||||
f"{hut_eiv_plazas:,} plazas")
|
||||
stats.append(f" > El Govern reconoce {hut_eiv_plazas:,} plazas legales; el gap legal/realidad "
|
||||
f"se ve con comparativas de Airbnb en municipios donde la regulacion es estricta.\n")
|
||||
|
||||
with open(OUT / "STATS.md", "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(stats))
|
||||
print(f"Guardado STATS.md")
|
||||
|
||||
print(f"\n=== Dataset construido ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Genera tiles pmtiles a partir de data/output/dataset.gpkg
|
||||
usando tippecanoe (https://github.com/felt/tippecanoe).
|
||||
|
||||
Salida: data/output/tiles.pmtiles
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import warnings
|
||||
|
||||
import geopandas as gpd
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RAW = ROOT / "data" / "raw"
|
||||
OUT = ROOT / "data" / "output"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not shutil.which("tippecanoe"):
|
||||
print("ERROR: tippecanoe no esta instalado.")
|
||||
print("Instala con: brew install tippecanoe")
|
||||
return
|
||||
|
||||
print("Convirtiendo dataset.gpkg a GeoJSON (formato que tippecanoe lee bien)...")
|
||||
gdf = gpd.read_file(OUT / "dataset.gpkg")
|
||||
cols = [
|
||||
"CUSEC", "NMUN", "isla", "granularidad", "fuente",
|
||||
"airbnb_listings", "airbnb_entire_homes", "airbnb_price_median",
|
||||
"airbnb_accommodates_total", "airbnb_revenue_total",
|
||||
"airbnb_hosts_unicos", "airbnb_con_licencia",
|
||||
"hut_registros", "hut_plazas", "hut_habitaciones",
|
||||
"plazas_turisticas", "geometry",
|
||||
]
|
||||
cols = [c for c in cols if c in gdf.columns]
|
||||
geojson_path = OUT / "dataset.geojson"
|
||||
gdf[cols].to_file(geojson_path, driver="GeoJSON")
|
||||
print(f" -> {geojson_path.name} ({geojson_path.stat().st_size // 1024} KB)")
|
||||
|
||||
print("\nGenerando tiles pmtiles...")
|
||||
cmd = [
|
||||
"tippecanoe",
|
||||
"--output", str(OUT / "tiles.pmtiles"),
|
||||
"--name", "cases",
|
||||
"--layer", "cases",
|
||||
"-zg",
|
||||
"--drop-densest-as-needed",
|
||||
"--read-parallel",
|
||||
"--minimum-zoom", "4",
|
||||
"--force",
|
||||
str(geojson_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR tippecanoe (codigo {result.returncode}):")
|
||||
print(result.stderr[-2000:])
|
||||
return
|
||||
|
||||
tiles = OUT / "tiles.pmtiles"
|
||||
if tiles.exists():
|
||||
size_kb = tiles.stat().st_size // 1024
|
||||
print(f"\ntiles.pmtiles generado: {size_kb} KB")
|
||||
with open(tiles, "rb") as f:
|
||||
magic = f.read(4)
|
||||
print(f" Magic: {magic} (esperado: b'PMTi')")
|
||||
else:
|
||||
print("ERROR: tiles.pmtiles no se genero")
|
||||
|
||||
geojson_path.unlink()
|
||||
print(f" Limpiado {geojson_path.name} (intermedio)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user