Documentation Index
Fetch the complete documentation index at: https://mintlify.com/danizd/urbanviable/llms.txt
Use this file to discover all available pages before exploring further.
process_data.py is the core of the UrbanViable ETL. It reads all four raw data sources, joins them on the CUSEC census section identifier, normalises every variable to [0, 1] across Galicia, runs quality assertions, and writes the final galicia_scouting.geojson plus a last_update.json metadata file. Running it takes between a few minutes (sources 1 + 2 only) and several hours (all four sources including full Catastro ingestion).
Command
From the project root:Pipeline steps
The script scans
etl/data/secciones_censales/ for the first .shp file, loads it with GeoPandas, and verifies that the CUSEC field is present. It then:CUSEC to 10 digits and stores it as lowercase cusec (the join key used throughout).cusec against {"15", "27", "32", "36"}.area_km2, then reprojects to EPSG:4326 for the output.NPOB, POB_TOT, POBLACION, TOTAL, POB, POPULATION, or pop) to populate poblacion_abs and densidad. If none is found, both default to 0.pct_jovenes and pct_mayores to 0.0 as placeholders (overwritten in step 3 if IGE data is present).shapefiles = sorted(SECTIONS_DIR.glob("*.shp"))
gdf = gpd.read_file(shapefiles[0])
gdf["CUSEC"] = gdf["CUSEC"].astype(str).str.zfill(10)
gdf = gdf[gdf["CUSEC"].str[:2].isin(GALICIA_PROVINCIAS)].copy()
gdf["cusec"] = gdf["CUSEC"]
projected = gdf.to_crs(epsg=25830)
gdf["area_km2"] = projected.geometry.area / 1_000_000
gdf = gdf.to_crs(epsg=4326)
utf-8-sig first (to strip the BOM), then falling back to latin-1.parse_cusec_ine()."Renta neta media por hogar" (with fallback to "Renta neta media por persona") and selects the most recent year."12.345,67" → 12345.67) and coerces suppressed values (".") to 0.def parse_cusec_ine(text: str) -> Optional[str]:
"""
Extracts a 10-digit CUSEC from the INE location text field.
Supported formats:
- "0100101001 Abegondo sección 01001"
- "0100101001" (numeric-only)
"""
if pd.isna(text):
return None
s = str(text).strip()
match = re.match(r'(\d{10})', s)
if match:
return match.group(1)
return None
latin-1 encoding.is_cusec_10 predicate)."menor de 20 anos" for youth and "maior de 64 anos" for elderly population.pct_jovenes and pct_mayores columns onto the main GeoDataFrame.This step uses
osmium to extract commercial POIs (Points of Interest) from the OSM PBF binary. The POIHandler class iterates over every OSM node and keeps those tagged with any shop key, the specific amenity values listed below, or an office key:shop_tags = {"shop"}
amenity_tags = {
"restaurant", "cafe", "bar", "fast_food", "bank",
"pharmacy", "clinic", "school", "supermarket", "marketplace"
}
class POIHandler(osmium.SimpleHandler):
def __init__(self):
super().__init__()
self.points = []
def node(self, n):
tags = dict(n.tags)
is_poi = any(
key in shop_tags or key in amenity_tags or key == "office"
for key in tags
)
if is_poi:
self.points.append((n.location.lon, n.location.lat))
After extraction, the POIs are converted to a GeoDataFrame and joined to the census sections with
gpd.sjoin(..., predicate="within"). The count per section becomes actividad_abs; dividing by area_km2 gives densidad_actividad, which feeds the log normalisation in step 5.If the OSM file is missing or
osmium is not installed, actividad_abs and densidad_actividad default to 0.Function:
Input:
process_catastro(gdf)Input:
etl/data/catastro/**/*.ZIP (files whose name contains CONSTRU)The function scans
CATASTRO_DIR recursively for ZIP files matching the CONSTRU pattern (the Catastro construction layer). For each ZIP it:zip:// protocol.CONSTRU, TIPO, currentUse, USO, …) and year column (FECHAALTA, ANO_CONS, beginning, …).n_edificios, n_comerciales (buildings classified as non-residential), and anio_medio (mean construction year).ratio_comercial = n_comerciales / n_edificios and modernidad = anio_medio (more recent = higher score after Min-Max normalisation).agg["ratio_comercial"] = agg["n_comerciales"] / agg["n_edificios"].clip(lower=1)
agg["modernidad"] = agg["anio_medio"]
Normalisation
After all five join steps, the script normalises every raw variable to[0, 1] using Min-Max normalisation over all ~3,800 Galicia sections. The minmax_norm function is defined as:
actividad_norm uses a log-normalised variant to prevent urban centres from saturating the colour scale:
The COLS_OUTPUT contract
The script selects exactly 13 items from the GeoDataFrame before writing (11 data columns +geometry):
generate_tiles.sh --include flags.
Quality assertions
validate_output() runs before the GeoJSON is written. It raises ValueError immediately if any assertion fails — the file is not written with invalid data:
process_data.py prints the percentage of sections at zero for each _norm column. A figure above 20% triggers a console warning and suggests re-checking that data source.
last_update.json
After writing the GeoJSON, the script writes a small metadata file used by the frontend to display the “last updated” badge:etl/data/processed/ and both must be uploaded to the server after tile generation.
Expected output
Each pipeline step is independent and falls back gracefully. If
renta.csv is missing, renta_abs and renta_norm are 0 for all sections. If the OSM PBF file is absent, actividad_* columns are 0. If the Catastro directory contains no ZIPs, uso_comercial_norm and antiguedad_norm are 0. The pipeline always completes and always writes a structurally valid GeoJSON — it never crashes due to a missing optional source.