Código fuente para dane_data.geih.agregados

"""
Resultados de la GEIH calculados año por año y guardados en disco.

Las funciones ``serie_*`` dan lo mismo que cargar todos los años con ``load`` y calcular
encima, pero leen un año a la vez: la memoria no depende de cuántos años se pidan. Cada
año queda en ``~/.cache/dane_data/geih/<tipo>/`` y se recalcula solo si cambian sus
archivos, la solicitud (filtros, meses, variables) o el código de dane-data. ``exportar``
escribe los microdatos armonizados mes a mes, por la misma razón.
"""

from __future__ import annotations

import hashlib
import json
import logging
import uuid
from functools import lru_cache
from pathlib import Path
from typing import Callable, Iterable

import numpy as np
import pandas as pd

from .. import config
from ..referencias import MESES
from ..stats import _as_list, peso_mensual_promedio, weighted_quantile
from .indicadores import _COLUMNAS, indicadores
from .loader import (_HARMONIZE_VERSION, CATEGORIAS, MesGEIH, _firma_archivos, _guardar_parquet, _root, find_months,
                     load)

log = logging.getLogger("dane_data.geih")

# Lo que lee `indicadores` más las desagregaciones y filtros de la app.
VARIABLES_INDICADORES = ["anio", "mes", "fex", "pet", "fuerza_trabajo", "ocupado", "desocupado", "inactivo",
                         "formal", "ingreso_laboral", "sexo", "zona", "edad", "grupo_edad", "nivel_educativo",
                         "departamento"]
POR_PRECALCULADOS = (None, "sexo", "zona", "grupo_edad", "nivel_educativo", "departamento")
POBLACIONES = ("pet", "fuerza_trabajo", "ocupado", "desocupado", "inactivo")


[documentos] def filtrar(df: pd.DataFrame, filtros: dict | None = None, poblacion: str | None = None) -> pd.DataFrame: """ Filas que cumplen los filtros y pertenecen a una población. Es el formato de filtros que aceptan ``serie_indicadores``, ``serie_tabla``, ``serie_frecuencias`` y ``exportar``. Parámetros ---------- df Microdatos armonizados. filtros ``{columna: [valores]}`` para conservar esos valores, o ``{columna: (mínimo, máximo)}`` para un rango con los extremos incluidos. Por ejemplo, ``{"sexo": ["Mujer"], "edad": (15, 28)}``. Si la columna no existe (una variable que ese año no trae), ninguna fila cumple el filtro. poblacion Una de ``POBLACIONES``: ``"pet"``, ``"fuerza_trabajo"``, ``"ocupado"``, ``"desocupado"`` o ``"inactivo"``. Devuelve -------- DataFrame Las filas que cumplen todo, o ``df`` mismo si todas cumplen. Notas ----- El resultado anota en ``attrs["meses_por_anio"]`` cuántos meses tenía cada año antes de filtrar (si se filtra por ``mes``, solo esos). Así, si el filtro deja un mes sin nadie, ``dd.tabla`` e ``indicadores`` lo cuentan como 0 en el promedio mensual de los niveles, como el DANE, y dan lo mismo que las funciones ``serie_*``. Ejemplos -------- >>> import pandas as pd >>> import dane_data as dd >>> df = pd.DataFrame({"sexo": ["Mujer", "Hombre", "Mujer"], "edad": [17, 30, 45], "ocupado": [1, 1, 0]}) >>> dd.geih.filtrar(df, {"sexo": ["Mujer"], "edad": (15, 28)}) sexo edad ocupado 0 Mujer 17 1 >>> dd.geih.filtrar(df, poblacion="ocupado") sexo edad ocupado 0 Mujer 17 1 1 Hombre 30 1 """ mask = _mascara(df, filtros, poblacion) if mask.all(): return df res = df[mask] if {"anio", "mes"} <= set(df.columns): # Los meses de antes de filtrar (o los de un filtrar anterior), para peso_mensual_promedio previos = None if filtros and "mes" in filtros else df.attrs.get("meses_por_anio") res.attrs = {**res.attrs, "meses_por_anio": previos or _meses_cargados(df, filtros)} return res
def _mascara(df: pd.DataFrame, filtros: dict | None, poblacion: str | None = None) -> pd.Series: mask = pd.Series(True, index=df.index) for col, cond in (filtros or {}).items(): if col not in df.columns: mask &= False elif isinstance(cond, tuple): mask &= df[col].between(*cond) else: # Un solo valor ("Mujer", 11) cuenta como lista: list("Mujer") lo partiría en letras mask &= df[col].isin([cond] if isinstance(cond, str) or not hasattr(cond, "__iter__") else list(cond)) if poblacion: mask &= df[poblacion].eq(1).fillna(False).astype(bool) return mask # --------------------------------------------------------------------------- # # Caché por año # --------------------------------------------------------------------------- # @lru_cache(maxsize=1) def _firma_codigo() -> str: """Hash del código que produce los resultados: si cambia, la caché se recalcula sola.""" from .. import __version__ base = Path(__file__).resolve().parent.parent h = hashlib.sha1(__version__.encode()) for rel in ("geih/loader.py", "geih/indicadores.py", "geih/agregados.py", "stats.py", "referencias.py", "_io.py"): try: h.update((base / rel).read_bytes()) except OSError: pass return h.hexdigest()[:16] def _meses(meses) -> list[int] | None: if meses is None: return None return sorted({int(m) for m in ([meses] if isinstance(meses, (int, str)) else meses)}) def _meses_por_anio(anios, meses, empalme: bool, root) -> dict[int, list[MesGEIH]]: """Meses completos en disco (los que usa ``load``), agrupados por año.""" meses = _meses(meses) por_anio: dict[int, list[MesGEIH]] = {} for m in find_months(anios, empalme=empalme, root=root): if m.completo and (meses is None or m.mes in meses): por_anio.setdefault(m.anio, []).append(m) if not por_anio: raise FileNotFoundError( f"No hay datos GEIH para años={anios}, meses={meses} en {_root(empalme, root)}. " "Descárguelos con dane_data.geih.download(...) o revise dane_data.set_paths(...)." ) return por_anio def _meses_cargados(df: pd.DataFrame, filtros: dict | None) -> dict[int, int]: """ ``{año: meses}`` sobre los que se promedian los niveles: los cargados, aunque un filtro deje alguno sin filas (ese mes cuenta como 0, como en el promedio de los totales mensuales del DANE). Si se filtra por ``mes``, solo esos meses. """ d = df[["anio", "mes"]] if filtros and "mes" in filtros: d = d[_mascara(d, {"mes": filtros["mes"]})] return {int(a): int(n) for a, n in d.groupby("anio", observed=True)["mes"].nunique().items()} def _clave_json(obj): """La solicitud lista para ``json.dumps``, que convierte las tuplas en listas: en ``filtros`` una tupla es un rango (``between``) y una lista son valores (``isin``), así que se marcan distinto.""" if isinstance(obj, dict): return {str(k): _clave_json(v) for k, v in obj.items()} if isinstance(obj, tuple): return {"__rango__": [_clave_json(v) for v in obj]} if isinstance(obj, list): return [_clave_json(v) for v in obj] return obj def _por_anio(tipo: str, anios, solicitud: dict, calcular: Callable[[int], pd.DataFrame], *, meses=None, empalme: bool = False, root=None, progress=None) -> list[pd.DataFrame]: """``calcular(anio)`` para cada año, leído de la caché si ya se hizo con los mismos archivos y solicitud.""" por_anio = _meses_por_anio(anios, meses, empalme, root) texto = json.dumps(_clave_json(solicitud), sort_keys=True, default=str, ensure_ascii=False) serie = "empalme" if empalme else "principal" partes = [] for i, (anio, ms) in enumerate(sorted(por_anio.items())): firma = [_HARMONIZE_VERSION, _firma_codigo(), texto, *(s for m in ms for s in _firma_archivos(m))] digest = hashlib.sha1("|".join(firma).encode()).hexdigest()[:16] ruta = config.CACHE_DIR / "geih" / tipo / f"{serie}_{anio}_{digest}.parquet" tabla = None if ruta.exists(): try: tabla = pd.read_parquet(ruta) except Exception: # caché corrupta: se regenera tabla = None if tabla is None: if progress: progress(i / len(por_anio), f"Calculando {anio} (solo la primera vez)") tabla = calcular(anio) try: _guardar_parquet(tabla, ruta) except Exception as e: # disco lleno, sin permisos, etc. log.debug(f"No se pudo guardar caché {ruta}: {e}") partes.append(tabla) if progress: progress(1.0, "Listo") return partes # --------------------------------------------------------------------------- # # Indicadores laborales # --------------------------------------------------------------------------- #
[documentos] def serie_indicadores( anios: Iterable[int] | int, por: str | None = None, anual: bool = True, empalme: bool = False, root=None, progress=None, filtros: dict | None = None, meses: Iterable[int] | int | None = None, ) -> pd.DataFrame: """ Indicadores del mercado laboral de muchos años, sin cargar los microdatos en memoria. Da exactamente lo mismo que ``indicadores(filtrar(load(anios, months=meses), filtros), por=por, anual=anual)``, pero carga un año a la vez y guarda el resultado de cada año en disco. Parámetros ---------- anios Año o lista de años, por ejemplo ``range(2007, 2027)``. por Desagregación: una de ``POR_PRECALCULADOS`` (``None``, ``"sexo"``, ``"zona"``, ``"grupo_edad"``, ``"nivel_educativo"``, ``"departamento"``). anual Si True, un resultado por año (promedio de los meses); si False, uno por mes. empalme Usar la serie de empalme (2010–2020). root Carpeta raíz. Por defecto, la configurada. progress Función ``progress(fraccion, mensaje)`` que recibe el avance. filtros Filtros con el formato de ``filtrar``, por ejemplo ``{"edad": (15, 28)}``. meses Mes o lista de meses de cada año. Por defecto, todos los que haya. Devuelve -------- DataFrame Las mismas columnas de ``indicadores``: ``anio`` (y ``mes`` si ``anual=False``), la variable de ``por``, niveles, tasas, ingresos y ``n_muestra``. Lanza ----- ValueError Si ``por`` no es una de ``POR_PRECALCULADOS``. FileNotFoundError Si no hay datos para esos años. Véase también ------------- indicadores : El cálculo, sobre microdatos ya cargados. serie_tabla : Tablas ponderadas de muchos años. Notas ----- Cada año se calcula una vez, con todas las desagregaciones de ``POR_PRECALCULADOS``, anual y mensual, y se guarda en ``~/.cache/dane_data/geih/indicadores/``. La primera vez tarda unos segundos por año; después es instantáneo, también al cambiar ``por`` o ``anual``. Se recalcula solo si cambian los archivos del año, los filtros o meses, o el código de ``dane-data``. Para desagregar por otras variables use ``indicadores`` sobre ``load(..., variables=[*VARIABLES_INDICADORES, "mi_variable"])``. Ejemplos -------- >>> import dane_data as dd >>> dd.geih.serie_indicadores(2024)[["anio", "tgp", "to", "td", "tasa_informalidad"]].round(1) # doctest: +SKIP anio tgp to td tasa_informalidad 0 2024 63.9 57.4 10.2 57.1 Desempleo juvenil (15 a 28 años): >>> dd.geih.serie_indicadores([2023, 2024, 2025], filtros={"edad": (15, 28)})[["anio", "td"]].round(1) # doctest: +SKIP anio td 0 2023 17.2 1 2024 17.4 2 2025 15.3 Mes a mes y por sexo: >>> dd.geih.serie_indicadores(range(2021, 2026), por="sexo", anual=False) # doctest: +SKIP """ if por not in POR_PRECALCULADOS: raise ValueError(f"por={por!r} no está precalculado (opciones: {list(POR_PRECALCULADOS)}). Use " "dd.geih.indicadores(dd.geih.load(..., variables=dd.geih.VARIABLES_INDICADORES), por=...).") variables = list(dict.fromkeys([*VARIABLES_INDICADORES, *(filtros or {})])) def calcular(anio): df = filtrar(load(anio, months=meses, empalme=empalme, root=root, variables=variables), filtros) partes = [] for p in POR_PRECALCULADOS: for a in (True, False): r = indicadores(df, por=p, anual=a) if p: r = r.rename(columns={p: "grupo"}).astype({"grupo": object}) partes.append(r.assign(desagregacion=p or "total", anual=a)) return pd.concat(partes, ignore_index=True) tablas = _por_anio("indicadores", anios, {"filtros": filtros or {}, "meses": _meses(meses)}, calcular, meses=meses, empalme=empalme, root=root, progress=progress) res = pd.concat([t[(t["desagregacion"] == (por or "total")) & (t["anual"] == anual)] for t in tablas], ignore_index=True) keys = ["anio"] + ([] if anual else ["mes"]) if not anual: res["mes"] = res["mes"].astype("int64") if por: res[por] = res["grupo"].astype(CATEGORIAS[por]) keys.append(por) return res[keys + _COLUMNAS]
# --------------------------------------------------------------------------- # # Tablas ponderadas # --------------------------------------------------------------------------- #
[documentos] def serie_frecuencias( anios: Iterable[int] | int, variable: str | None = None, por: str | Iterable[str] | None = None, poblacion: str | None = None, filtros: dict | None = None, meses: Iterable[int] | int | None = None, columns="core", empalme: bool = False, root=None, progress=None, ) -> pd.DataFrame: """ Distribución ponderada de una variable en muchos años, calculada año por año. Cuenta, para cada año y cada combinación de ``por``, cuántas personas (expandidas) tienen cada valor de ``variable``. Es la base de ``serie_tabla``: de estas frecuencias salen totales, porcentajes, medias y medianas exactas, también al juntar varios años. Parámetros ---------- anios Año o lista de años. variable Variable cuya distribución se cuenta. Las filas donde está vacía se descartan. ``None`` cuenta solo los grupos de ``por``. por Variable o lista de variables de agrupación (``anio`` siempre se incluye). poblacion Restringir a una de ``POBLACIONES`` (por ejemplo ``"ocupado"``). filtros Filtros con el formato de ``filtrar``. meses Mes o lista de meses de cada año. columns Variables del DANE que no están en ``CORE_RAW`` y hay que leer, como en ``load``. empalme Usar la serie de empalme. root Carpeta raíz. Por defecto, la configurada. progress Función ``progress(fraccion, mensaje)`` que recibe el avance. Devuelve -------- DataFrame Una fila por año, grupo y valor, con ``peso`` (personas, promedio mensual del año) y ``n_muestra``. Véase también ------------- tabla_de_frecuencias : Convertir las frecuencias en una tabla. serie_tabla : Las dos cosas en un paso. Ejemplos -------- Mediana del ingreso laboral de los ocupados de 2024 y 2025 juntos: >>> import dane_data as dd >>> f = dd.geih.serie_frecuencias([2024, 2025], "ingreso_laboral", poblacion="ocupado") # doctest: +SKIP >>> dd.weighted_quantile(f["ingreso_laboral"], f["peso"], 0.5) # doctest: +SKIP 1300000.0 """ por = [p for p in _as_list(por) if p != "anio"] claves = ["anio", *por] + ([variable] if variable else []) necesarias = list(dict.fromkeys(["fex", *claves, *(filtros or {}), *([poblacion] if poblacion else [])])) def calcular(anio): df = load(anio, months=meses, columns=columns, empalme=empalme, root=root, variables=necesarias) for c in claves: # una variable DANE que ese año no existe if c not in df.columns: df[c] = np.nan d = filtrar(df, filtros, poblacion) x = d[claves].assign(peso=peso_mensual_promedio(d).to_numpy(), n_muestra=1) if variable: x = x[x[variable].notna()] return x.groupby(claves, observed=True, dropna=False).agg( peso=("peso", "sum"), n_muestra=("n_muestra", "sum")).reset_index() solicitud = {"variable": variable, "por": sorted(por), "poblacion": poblacion, "filtros": filtros or {}, "meses": _meses(meses), "columns": columns} tablas = _por_anio("frecuencias", anios, solicitud, calcular, meses=meses, empalme=empalme, root=root, progress=progress) return pd.concat(tablas, ignore_index=True)
[documentos] def serie_tabla( anios: Iterable[int] | int, por: str | Iterable[str] | None = None, variable: str | None = None, estadistico: str = "total", poblacion: str | None = None, filtros: dict | None = None, meses: Iterable[int] | int | None = None, dropna: bool = False, columns="core", empalme: bool = False, root=None, progress=None, ) -> pd.DataFrame: """ Tabla ponderada de muchos años, sin cargar los microdatos en memoria. Da exactamente lo mismo que ``dd.tabla(filtrar(load(anios, months=meses), filtros, poblacion), por, variable, estadistico)``, pero calcula las frecuencias de un año a la vez y las guarda en disco. Parámetros ---------- anios Año o lista de años. por Variable o lista de variables que definen los grupos. Incluya ``"anio"`` para una fila por año; sin él, los años se juntan. variable Variable numérica para ``"media"`` y ``"mediana"``. estadistico ``"total"``, ``"porcentaje"``, ``"media"`` o ``"mediana"``, como en ``dd.tabla``. poblacion Restringir a una de ``POBLACIONES`` (por ejemplo ``"ocupado"``). filtros Filtros con el formato de ``filtrar``. meses Mes o lista de meses de cada año. dropna Dejar fuera a las personas con valor vacío en alguna variable de ``por`` (por ejemplo, la rama de actividad de quien no trabaja). columns Variables del DANE que no están en ``CORE_RAW`` y hay que leer, como en ``load``. empalme Usar la serie de empalme. root Carpeta raíz. Por defecto, la configurada. progress Función ``progress(fraccion, mensaje)`` que recibe el avance. Devuelve -------- DataFrame Igual que ``dd.tabla``: los grupos, el estadístico y ``n_muestra``. Lanza ----- ValueError Si ``estadistico`` no es uno de los cuatro, o falta ``variable`` para la media o la mediana. Véase también ------------- dane_data.tabla : La misma tabla sobre microdatos cargados. serie_frecuencias : Las frecuencias de las que sale la tabla. Notas ----- Los totales de cada año son promedios mensuales; si ``por`` no incluye ``anio``, el total junta los años (es la suma de los promedios de cada año). Las medias y medianas que juntan años son exactas, porque salen de la distribución completa y no de promedios de cada año. Ejemplos -------- >>> import dane_data as dd >>> dd.geih.serie_tabla([2024, 2025], por="sexo", variable="ingreso_laboral", ... estadistico="mediana", poblacion="ocupado") # doctest: +SKIP sexo mediana n_muestra 0 Hombre 1300000.0 385000 1 Mujer 1300000.0 303382 Distribución de los ocupados por nivel educativo, año por año: >>> dd.geih.serie_tabla(range(2015, 2026), por=["anio", "nivel_educativo"], estadistico="porcentaje", ... poblacion="ocupado", dropna=True) # doctest: +SKIP """ por = _as_list(por) if estadistico not in ("total", "porcentaje", "media", "mediana"): raise ValueError("estadistico debe ser 'total', 'porcentaje', 'media' o 'mediana'.") if estadistico in ("media", "mediana") and variable is None: raise ValueError("Indique `variable` para media/mediana.") f = serie_frecuencias(anios, variable, por, poblacion, filtros, meses, columns, empalme, root, progress) if dropna and por: f = f.dropna(subset=por) return tabla_de_frecuencias(f, por, variable, estadistico)
[documentos] def tabla_de_frecuencias(f: pd.DataFrame, por: str | Iterable[str] | None = None, variable: str | None = None, estadistico: str = "total") -> pd.DataFrame: """ Tabla ponderada a partir de frecuencias. Es el segundo paso de ``serie_tabla``: convierte la salida de ``serie_frecuencias`` (o cualquier tabla con ``peso`` y ``n_muestra``) en la tabla que daría ``dd.tabla``. Parámetros ---------- f Frecuencias con las columnas de ``por``, ``variable``, ``peso`` y ``n_muestra``. por Variable o lista de variables que definen los grupos. variable Variable numérica para ``"media"`` y ``"mediana"``. estadistico ``"total"``, ``"porcentaje"``, ``"media"`` o ``"mediana"``. Devuelve -------- DataFrame Los grupos, el estadístico y ``n_muestra``. Véase también ------------- serie_frecuencias : De dónde salen las frecuencias. serie_tabla : Los dos pasos juntos. Ejemplos -------- >>> import pandas as pd >>> import dane_data as dd >>> f = pd.DataFrame({"anio": [2023, 2023, 2024, 2024], "sexo": ["Hombre", "Mujer", "Hombre", "Mujer"], ... "peso": [60.0, 40.0, 70.0, 30.0], "n_muestra": [6, 4, 7, 3]}) >>> dd.geih.tabla_de_frecuencias(f, por=["anio", "sexo"], estadistico="porcentaje") anio sexo total n_muestra porcentaje 0 2023 Hombre 60.0 6 60.0 1 2023 Mujer 40.0 4 40.0 2 2024 Hombre 70.0 7 70.0 3 2024 Mujer 30.0 3 30.0 Una mediana que junta años sale de la distribución completa: >>> f = pd.DataFrame({"anio": [2023, 2023, 2024], "ingreso": [1.0, 2.0, 3.0], ... "peso": [10.0, 30.0, 20.0], "n_muestra": [1, 3, 2]}) >>> dd.geih.tabla_de_frecuencias(f, variable="ingreso", estadistico="mediana") mediana n_muestra 0 2.0 6 """ por = _as_list(por) if estadistico in ("total", "porcentaje"): if not por: res = pd.DataFrame({"total": [f["peso"].sum()], "n_muestra": [int(f["n_muestra"].sum())]}) else: res = f.groupby(por, observed=True, dropna=False).agg( total=("peso", "sum"), n_muestra=("n_muestra", "sum")).reset_index() if estadistico == "porcentaje": outer = por[:-1] # dropna=False como en la agregación: un grupo exterior vacío (NaN) también suma 100 % denom = (res.groupby(outer, observed=True, dropna=False)["total"].transform("sum") if outer else res["total"].sum()) res["porcentaje"] = 100 * res["total"] / denom return res if estadistico == "media": def fn(x): return float(np.average(x[variable], weights=x["peso"])) if x["peso"].sum() > 0 else np.nan else: def fn(x): return weighted_quantile(x[variable], x["peso"], 0.5) if not por: return pd.DataFrame({estadistico: [fn(f)], "n_muestra": [int(f["n_muestra"].sum())]}) g = f.groupby(por, observed=True, dropna=False) stat = g[[variable, "peso"]].apply(fn).rename(estadistico) n = g["n_muestra"].sum().rename("n_muestra") return pd.concat([stat, n], axis=1).reset_index()
# --------------------------------------------------------------------------- # # Exportar microdatos # --------------------------------------------------------------------------- #
[documentos] def exportar( anios: Iterable[int] | int, ruta, variables: Iterable[str] | None = None, poblacion: str | None = None, filtros: dict | None = None, meses: Iterable[int] | int | None = None, columns="core", empalme: bool = False, root=None, progress=None, ) -> int: """ Exporta los microdatos armonizados a un CSV, un mes a la vez. Escribe una persona por fila y mes. Como lee y escribe un mes a la vez, sirve para muchos años aunque no quepan en memoria. El archivo se escribe primero con otro nombre y se renombra al terminar, así que nunca queda un CSV a medias. Parámetros ---------- anios Año o lista de años. ruta Archivo CSV de destino (UTF-8 con BOM, para que Excel lea bien las tildes). variables Columnas a escribir (``anio`` y ``mes`` siempre). Por defecto, todas las armonizadas. poblacion Restringir a una de ``POBLACIONES`` (por ejemplo ``"ocupado"``). filtros Filtros con el formato de ``filtrar``. meses Mes o lista de meses de cada año. columns Variables del DANE que no están en ``CORE_RAW``: hay que pedirlas aquí además de en ``variables``. empalme Usar la serie de empalme. root Carpeta raíz. Por defecto, la configurada. progress Función ``progress(fraccion, mensaje)`` que recibe el avance. Devuelve -------- int Número de filas escritas. Véase también ------------- load : Cargar los mismos datos en memoria. filtrar : El formato de ``filtros``. Ejemplos -------- >>> import dane_data as dd >>> dd.geih.exportar(range(2015, 2026), "ocupados.csv", poblacion="ocupado") # doctest: +SKIP >>> dd.geih.exportar(2024, "salarios.csv", variables=["fex", "sexo", "ingreso_laboral", "P6500"], ... columns=["P6500"], poblacion="ocupado") # doctest: +SKIP """ ruta = Path(ruta) por_anio = _meses_por_anio(anios, meses, empalme, root) lista = [m for _, ms in sorted(por_anio.items()) for m in ms] necesarias = leer = None # sin variables: se leen y escriben todas las armonizadas if variables is not None: necesarias = list(dict.fromkeys(["anio", "mes", *_as_list(variables)])) leer = list(dict.fromkeys([*necesarias, *(filtros or {}), *([poblacion] if poblacion else [])])) ruta.parent.mkdir(parents=True, exist_ok=True) tmp = ruta.with_name(f"{ruta.name}.{uuid.uuid4().hex}.tmp") filas = 0 try: for i, m in enumerate(lista): if progress: progress(i / len(lista), f"Exportando {MESES[m.mes]} {m.anio}") df = filtrar(load(m.anio, months=m.mes, columns=columns, empalme=empalme, root=root, variables=leer), filtros, poblacion) if necesarias is None: # las armonizadas (minúscula), iguales en todos los meses necesarias = [c for c in df.columns if c == c.lower()] df.reindex(columns=necesarias).to_csv(tmp, mode="w" if i == 0 else "a", header=i == 0, index=False, encoding="utf-8-sig" if i == 0 else "utf-8") filas += len(df) tmp.replace(ruta) finally: tmp.unlink(missing_ok=True) if progress: progress(1.0, "Listo") return filas