dane-data 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dane_data/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ dane-data — microdatos del DANE (GEIH y EAM) listos para analizar.
3
+
4
+ import dane_data as dd
5
+ dd.set_paths(geih="~/datos/GEIH/data", eam="~/datos/EAM/data", save=True)
6
+
7
+ df = dd.geih.load(2025, months=[1, 2, 3])
8
+ dd.geih.indicadores(df, por="sexo")
9
+
10
+ eam = dd.eam.load(range(2015, 2025))
11
+ dd.eam.resumen(eam, por=["anio", "division"])
12
+ """
13
+
14
+ from . import eam, geih
15
+ from ._hooks import DownloadCancelled
16
+ from .config import get_path, set_paths, show_paths
17
+ from .referencias import DEPARTAMENTOS, MESES, SMLV, buscar_variable, describir, diccionario
18
+ from .stats import tabla, weighted_mean, weighted_quantile
19
+
20
+ __version__ = "0.2.1"
21
+
22
+ __all__ = [
23
+ "geih", "eam", "set_paths", "get_path", "show_paths", "tabla", "weighted_mean",
24
+ "weighted_quantile", "buscar_variable", "describir", "diccionario", "SMLV",
25
+ "DEPARTAMENTOS", "MESES", "DownloadCancelled",
26
+ ]
dane_data/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Permite `python -m dane_data app` (equivale al comando `dane-data`)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
dane_data/_hooks.py ADDED
@@ -0,0 +1,24 @@
1
+ """Ganchos de progreso y cancelación compartidos por los descargadores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ ProgressFn = Callable[[Optional[float], str], None]
8
+
9
+
10
+ class DownloadCancelled(Exception):
11
+ """El usuario pidió detener la descarga."""
12
+
13
+
14
+ class ProgressMixin:
15
+ progress: ProgressFn | None = None
16
+ should_stop: Callable[[], bool] | None = None
17
+
18
+ def _emit(self, fraction: float | None, message: str) -> None:
19
+ if self.progress is not None:
20
+ self.progress(fraction, message)
21
+
22
+ def _check_stop(self) -> None:
23
+ if self.should_stop is not None and self.should_stop():
24
+ raise DownloadCancelled("Descarga cancelada por el usuario.")
dane_data/_io.py ADDED
@@ -0,0 +1,128 @@
1
+ """Lectura robusta de los archivos planos del DANE.
2
+
3
+ Los archivos cambian de separador (``;``, ``,``, tabulador), de codificación
4
+ (UTF-8 con BOM, Latin-1) y de separador decimal (coma en la GEIH anterior a
5
+ 2020 y en algunas celdas de la EAM) entre años. ``read_table`` detecta todo eso
6
+ automáticamente.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Callable, Iterable
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+
18
+ def sniff(path: Path) -> tuple[str, str]:
19
+ """Detecta (separador, codificación) leyendo el encabezado."""
20
+ # Siempre cerrado: en Windows un archivo abierto no se puede reemplazar al volver a descargar.
21
+ with open(path, "rb") as fh:
22
+ raw = fh.read(64_000)
23
+ try:
24
+ text = raw.decode("utf-8-sig")
25
+ encoding = "utf-8-sig"
26
+ except UnicodeDecodeError:
27
+ # Puede ser un corte a mitad de carácter multibyte: reintentar sin el final
28
+ try:
29
+ text = raw[:-3].decode("utf-8-sig")
30
+ encoding = "utf-8-sig"
31
+ except UnicodeDecodeError:
32
+ text = raw.decode("latin-1")
33
+ encoding = "latin-1"
34
+ header = text.splitlines()[0] if text else ""
35
+ sep = max((";", ",", "\t", "|"), key=header.count)
36
+ return sep, encoding
37
+
38
+
39
+ def _coerce_numeric(df: pd.DataFrame) -> pd.DataFrame:
40
+ """
41
+ Convierte a número las columnas de texto que en realidad son numéricas:
42
+ celdas en blanco escritas como ``" "`` y coma decimal (``"71,22"``).
43
+ """
44
+ for col in df.columns:
45
+ s = df[col]
46
+ if not (pd.api.types.is_object_dtype(s) or pd.api.types.is_string_dtype(s)):
47
+ continue
48
+ s = s.astype("string").str.strip().replace("", pd.NA)
49
+ nonnull = s.notna().sum()
50
+ if nonnull == 0:
51
+ df[col] = np.nan
52
+ continue
53
+ # Basta con que aparezca una coma, no la mayoría: en la EAM (1998, 1999, 2002,
54
+ # 2006) casi todo es entero y solo las plantas anonimizadas por promedio traen
55
+ # coma decimal ("83779570,6666667"); decidir con una muestra las volvía NaN.
56
+ # Si la columna es texto genuino, la conversión de abajo falla y no se toca.
57
+ if s.str.contains(",", regex=False).any():
58
+ s = s.str.replace(",", ".", regex=False)
59
+ converted = pd.to_numeric(s, errors="coerce").astype("float64")
60
+ # Solo si no se pierden datos (texto genuino se deja como está)
61
+ if converted.notna().sum() >= nonnull * 0.99:
62
+ if converted.notna().all() and (converted % 1 == 0).all():
63
+ converted = converted.astype("int64")
64
+ df[col] = converted
65
+ return df
66
+
67
+
68
+ def normalize_columns(cols: Iterable[str], case: str = "upper") -> list[str]:
69
+ out = []
70
+ for c in cols:
71
+ c = str(c).replace("", "").strip()
72
+ out.append(c.upper() if case == "upper" else c.lower())
73
+ return out
74
+
75
+
76
+ def columnas(path: Path, case: str = "upper") -> list[str]:
77
+ """Nombres de columna normalizados de un archivo del DANE (texto o Stata), sin leer los datos."""
78
+ path = Path(path)
79
+ if path.suffix.lower() == ".dta":
80
+ with pd.read_stata(path, iterator=True) as reader:
81
+ return normalize_columns(reader.variable_labels(), case)
82
+ sep, enc = sniff(path)
83
+ with open(path, encoding=enc, errors="replace") as fh:
84
+ return normalize_columns(fh.readline().rstrip("\r\n").split(sep), case)
85
+
86
+
87
+ def read_table(
88
+ path: Path,
89
+ columns: Iterable[str] | Callable[[str], bool] | None = None,
90
+ case: str = "upper",
91
+ ) -> pd.DataFrame:
92
+ """
93
+ Lee un CSV/TXT del DANE detectando separador, codificación y coma decimal.
94
+ También lee Stata (``.dta``), el formato que se usa cuando el CSV del ANDA está dañado.
95
+
96
+ ``columns`` puede ser una lista de nombres (en cualquier mayúscula/minúscula)
97
+ o una función ``nombre_normalizado -> bool``. Los nombres de columna del
98
+ resultado quedan normalizados (``case='upper'`` o ``'lower'``).
99
+ """
100
+ path = Path(path)
101
+ usecols = None
102
+ if columns is not None:
103
+ if callable(columns):
104
+ pred = columns
105
+ else:
106
+ wanted = {normalize_columns([c], case)[0] for c in columns}
107
+ pred = lambda c: c in wanted # noqa: E731
108
+ usecols = lambda c: pred(normalize_columns([c], case)[0]) # noqa: E731
109
+
110
+ if path.suffix.lower() == ".dta":
111
+ with pd.read_stata(path, iterator=True) as reader:
112
+ nombres = list(reader.variable_labels())
113
+ elegidas = [c for c in nombres if usecols is None or usecols(c)]
114
+ if not elegidas:
115
+ return pd.DataFrame()
116
+ df = pd.read_stata(path, columns=elegidas, convert_categoricals=False)
117
+ df.columns = normalize_columns(df.columns, case)
118
+ return _coerce_numeric(df.loc[:, ~df.columns.duplicated()])
119
+
120
+ sep, encoding = sniff(path)
121
+ kwargs = dict(sep=sep, usecols=usecols, low_memory=False)
122
+ try:
123
+ df = pd.read_csv(path, encoding=encoding, **kwargs)
124
+ except UnicodeDecodeError:
125
+ df = pd.read_csv(path, encoding="latin-1", **kwargs)
126
+ df.columns = normalize_columns(df.columns, case)
127
+ df = df.loc[:, ~df.columns.duplicated()]
128
+ return _coerce_numeric(df)
@@ -0,0 +1,8 @@
1
+ [theme]
2
+ primaryColor = "#2a78d6"
3
+
4
+ [browser]
5
+ gatherUsageStats = false
6
+
7
+ [client]
8
+ toolbarMode = "minimal"
@@ -0,0 +1,22 @@
1
+ """Punto de entrada de la app: `dane-data app` o `streamlit run src/dane-data/app/Inicio.py`."""
2
+
3
+ import streamlit as st
4
+
5
+ st.set_page_config(page_title="dane-data · Microdatos DANE", page_icon="📊", layout="wide")
6
+
7
+ paginas = {
8
+ "": [st.Page("paginas/inicio.py", title="Inicio", icon="🏠", default=True)],
9
+ "GEIH · Hogares y mercado laboral": [
10
+ st.Page("paginas/geih_mercado.py", title="Indicadores laborales", icon="👥"),
11
+ st.Page("paginas/geih_explorar.py", title="Tablas y microdatos", icon="🔎"),
12
+ ],
13
+ "EAM · Industria manufacturera": [
14
+ st.Page("paginas/eam_industria.py", title="Indicadores industriales", icon="🏭"),
15
+ st.Page("paginas/eam_microdatos.py", title="Panel y microdatos", icon="🧱"),
16
+ ],
17
+ "Herramientas": [
18
+ st.Page("paginas/descargar.py", title="Descargar datos", icon="⬇️"),
19
+ st.Page("paginas/diccionario.py", title="Diccionario de variables", icon="📚"),
20
+ ],
21
+ }
22
+ st.navigation(paginas).run()
dane_data/app/_ui.py ADDED
@@ -0,0 +1,361 @@
1
+ """Piezas compartidas de la app: gráficos, caché de datos, descargas y avisos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import logging
7
+ from collections import deque
8
+
9
+ import pandas as pd
10
+ import plotly.express as px
11
+ import plotly.graph_objects as go
12
+ import streamlit as st
13
+
14
+ import dane_data as dd
15
+
16
+ # Paleta categórica validada (orden fijo; nunca se recicla). Ver skill dataviz.
17
+ PALETA = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"]
18
+ COLOR_UNICO = PALETA[0]
19
+ MAX_SERIES = len(PALETA)
20
+
21
+
22
+ # --------------------------------------------------------------------------- #
23
+ # Formato
24
+ # --------------------------------------------------------------------------- #
25
+ def fmt_num(x: float, dec: int = 0) -> str:
26
+ if pd.isna(x):
27
+ return "—"
28
+ return f"{x:,.{dec}f}".replace(",", "X").replace(".", ",").replace("X", ".")
29
+
30
+
31
+ def fmt_pesos(x: float) -> str:
32
+ return "—" if pd.isna(x) else "$ " + fmt_num(x)
33
+
34
+
35
+ def fmt_pct(x: float) -> str:
36
+ return "—" if pd.isna(x) else fmt_num(x, 1) + " %"
37
+
38
+
39
+ def fmt_grande(x: float) -> str:
40
+ """12.345.678 → '12,3 millones'."""
41
+ if pd.isna(x):
42
+ return "—"
43
+ for div, suf in ((1e12, "billones"), (1e9, "mil millones"), (1e6, "millones"), (1e3, "mil")):
44
+ if abs(x) >= div:
45
+ return f"{fmt_num(x / div, 1)} {suf}"
46
+ return fmt_num(x)
47
+
48
+
49
+ # --------------------------------------------------------------------------- #
50
+ # Gráficos
51
+ # --------------------------------------------------------------------------- #
52
+ def _estilo(fig: go.Figure, titulo_y: str | None = None, pct: bool = False) -> go.Figure:
53
+ fig.update_layout(
54
+ separators=",.", # decimales con coma y miles con punto (español)
55
+ margin=dict(l=10, r=10, t=40, b=10),
56
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0, title=None),
57
+ hoverlabel=dict(namelength=-1),
58
+ font=dict(size=13),
59
+ )
60
+ fig.update_xaxes(showgrid=False, title=None)
61
+ # Eje desde cero: evita que cambios de décimas parezcan enormes.
62
+ fig.update_yaxes(gridcolor="rgba(128,128,128,0.18)", zeroline=False, title=titulo_y, rangemode="tozero",
63
+ ticksuffix=" %" if pct else None, tickformat=",~f")
64
+ return fig
65
+
66
+
67
+ def linea(df: pd.DataFrame, x: str, y: str, color: str | None = None, titulo_y: str | None = None,
68
+ pct: bool = False) -> go.Figure:
69
+ fig = px.line(df, x=x, y=y, color=color, markers=True, color_discrete_sequence=PALETA)
70
+ fig.update_traces(line=dict(width=2), marker=dict(size=8))
71
+ fig = _estilo(fig, titulo_y, pct)
72
+ fig.update_layout(hovermode="x unified")
73
+ if pd.api.types.is_integer_dtype(df[x]) or x == "anio":
74
+ fig.update_xaxes(dtick=1 if df[x].nunique() <= 15 else None, tickformat="d")
75
+ return fig
76
+
77
+
78
+ def barras(df: pd.DataFrame, x: str, y: str, color: str | None = None, titulo_y: str | None = None,
79
+ pct: bool = False, horizontal: bool = False) -> go.Figure:
80
+ if horizontal:
81
+ fig = px.bar(df, y=x, x=y, color=color, orientation="h", barmode="group",
82
+ color_discrete_sequence=PALETA)
83
+ fig.update_yaxes(categoryorder="total ascending", title=None)
84
+ fig.update_xaxes(gridcolor="rgba(128,128,128,0.18)", title=titulo_y, ticksuffix=" %" if pct else None,
85
+ tickformat=",~f")
86
+ fig.update_layout(separators=",.", margin=dict(l=10, r=10, t=40, b=10), legend=dict(orientation="h", y=1.02, title=None),
87
+ height=max(350, 24 * df[x].nunique() * (df[color].nunique() if color else 1)))
88
+ else:
89
+ fig = px.bar(df, x=x, y=y, color=color, barmode="group", color_discrete_sequence=PALETA)
90
+ fig = _estilo(fig, titulo_y, pct)
91
+ fig.update_traces(marker_line_width=0)
92
+ fig.update_layout(bargap=0.25, bargroupgap=0.08)
93
+ return fig
94
+
95
+
96
+ def grafico(fig: go.Figure) -> None:
97
+ st.plotly_chart(fig, width="stretch", config={"displaylogo": False, "locale": "es"})
98
+
99
+
100
+ # --------------------------------------------------------------------------- #
101
+ # Datos (caché en memoria de la sesión del servidor)
102
+ # --------------------------------------------------------------------------- #
103
+ def _barra(texto: str):
104
+ barra = st.progress(0.0, text=texto)
105
+ return barra, lambda f, m: barra.progress(min(f, 1.0), text=m)
106
+
107
+
108
+ # Los resultados de la GEIH salen de dd.geih.serie_*: se calculan año por año y quedan en disco, así
109
+ # que nunca hay más de un año de microdatos en memoria. `meses_disp` va en la llave para que un mes
110
+ # recién descargado se vea.
111
+ @st.cache_data(max_entries=50, show_spinner=False)
112
+ def serie_geih(anios: tuple, por: str | None, anual: bool, empalme: bool, filtros: dict, meses: tuple | None,
113
+ meses_disp: tuple, rutas: tuple) -> pd.DataFrame:
114
+ barra, progreso = _barra("Preparando indicadores…")
115
+ res = dd.geih.serie_indicadores(list(anios), por=por, anual=anual, empalme=empalme, progress=progreso,
116
+ filtros=filtros or None, meses=list(meses) if meses else None)
117
+ barra.empty()
118
+ return res
119
+
120
+
121
+ @st.cache_data(max_entries=100, show_spinner=False)
122
+ def tabla_geih(anios: tuple, por: tuple, variable: str | None, estadistico: str, poblacion: str | None,
123
+ filtros: dict, meses: tuple | None, dropna: bool, columnas: tuple, empalme: bool,
124
+ meses_disp: tuple, rutas: tuple) -> pd.DataFrame:
125
+ barra, progreso = _barra("Preparando la tabla…")
126
+ res = dd.geih.serie_tabla(list(anios), list(por), variable, estadistico, poblacion, filtros or None,
127
+ list(meses) if meses else None, dropna, list(columnas) or "core", empalme,
128
+ progress=progreso)
129
+ barra.empty()
130
+ return res
131
+
132
+
133
+ @st.cache_data(max_entries=50, show_spinner=False)
134
+ def frecuencias_geih(anios: tuple, variable: str | None, por: tuple, poblacion: str | None, filtros: dict,
135
+ meses: tuple | None, columnas: tuple, empalme: bool, meses_disp: tuple,
136
+ rutas: tuple) -> pd.DataFrame:
137
+ barra, progreso = _barra("Preparando la distribución…")
138
+ res = dd.geih.serie_frecuencias(list(anios), variable, list(por), poblacion, filtros or None,
139
+ list(meses) if meses else None, list(columnas) or "core", empalme,
140
+ progress=progreso)
141
+ barra.empty()
142
+ return res
143
+
144
+
145
+ @st.cache_data(max_entries=5, show_spinner="Leyendo un mes de microdatos…")
146
+ def muestra_geih(anio: int, mes: int, variables: tuple, columnas: tuple, empalme: bool, rutas: tuple) -> pd.DataFrame:
147
+ """Un solo mes de microdatos (vista previa, tipos de las variables adicionales)."""
148
+ return dd.geih.load(anio, months=mes, columns=list(columnas) or "core", empalme=empalme,
149
+ variables=list(variables))
150
+
151
+
152
+ def args_py(**kw) -> str:
153
+ """Argumentos para el código equivalente: solo los que no tienen su valor por defecto."""
154
+ return "".join(f", {k}={v!r}" for k, v in kw.items() if v not in (None, False, {}, (), [], "core"))
155
+
156
+
157
+ @st.cache_resource(max_entries=2, show_spinner=False)
158
+ def cargar_eam(anios: tuple, extra: tuple, rutas: tuple) -> pd.DataFrame:
159
+ return dd.eam.load(list(anios), columns=list(extra) or "core")
160
+
161
+
162
+ def rutas_actuales() -> tuple:
163
+ """Parte de la llave de caché: si el usuario cambia de carpeta, se recarga."""
164
+ return tuple(sorted(dd.show_paths().items()))
165
+
166
+
167
+ @st.cache_data(ttl=30, show_spinner=False)
168
+ def disponibles_geih(empalme: bool, rutas: tuple) -> pd.DataFrame:
169
+ return dd.geih.available(empalme=empalme)
170
+
171
+
172
+ @st.cache_data(ttl=30, show_spinner=False)
173
+ def disponibles_eam(rutas: tuple) -> pd.DataFrame:
174
+ return dd.eam.available()
175
+
176
+
177
+ # --------------------------------------------------------------------------- #
178
+ # Exportar
179
+ # --------------------------------------------------------------------------- #
180
+ def botones_descarga(df: pd.DataFrame, nombre: str, key: str) -> None:
181
+ c1, c2, c3 = st.columns(3)
182
+ c1.download_button("⬇️ CSV", df.to_csv(index=False).encode("utf-8-sig"), f"{nombre}.csv",
183
+ "text/csv", key=f"{key}_csv", width="stretch")
184
+ if len(df) < 1_000_000:
185
+ buf = io.BytesIO()
186
+ df.to_excel(buf, index=False)
187
+ c2.download_button("⬇️ Excel", buf.getvalue(), f"{nombre}.xlsx", key=f"{key}_xlsx", width="stretch")
188
+ else:
189
+ c2.caption("Excel no admite más de 1 millón de filas.")
190
+ try:
191
+ buf = io.BytesIO()
192
+ d = df.copy()
193
+ for c in d.columns: # Stata no acepta categorías/nullable ni nombres largos
194
+ if isinstance(d[c].dtype, pd.CategoricalDtype) or pd.api.types.is_string_dtype(d[c]):
195
+ d[c] = d[c].astype(object).where(d[c].notna(), "").astype(str)
196
+ elif pd.api.types.is_extension_array_dtype(d[c]):
197
+ d[c] = d[c].astype("float64")
198
+ d.columns = [c[:32] for c in d.columns]
199
+ d.to_stata(buf, write_index=False, version=118)
200
+ c3.download_button("⬇️ Stata (.dta)", buf.getvalue(), f"{nombre}.dta", key=f"{key}_dta", width="stretch")
201
+ except Exception as e: # noqa: BLE001
202
+ c3.caption(f"Stata no disponible: {e}")
203
+
204
+
205
+ def codigo(texto: str) -> None:
206
+ with st.expander("🐍 Ver el código Python equivalente"):
207
+ st.caption("Copie esto en un notebook para reproducir o extender el análisis con la librería `dane-data`.")
208
+ st.code(texto.strip(), language="python")
209
+
210
+
211
+ # --------------------------------------------------------------------------- #
212
+ # Logs de descarga en pantalla
213
+ # --------------------------------------------------------------------------- #
214
+ class LogEnPantalla(logging.Handler):
215
+ def __init__(self, contenedor, lineas: int = 12):
216
+ super().__init__(level=logging.INFO)
217
+ self.contenedor = contenedor
218
+ self.buffer: deque[str] = deque(maxlen=lineas)
219
+ self.setFormatter(logging.Formatter("%(asctime)s %(message)s", "%H:%M:%S"))
220
+
221
+ def emit(self, record):
222
+ msg = self.format(record).strip()
223
+ if msg:
224
+ self.buffer.append(msg)
225
+ self.contenedor.code("\n".join(self.buffer), language=None)
226
+
227
+
228
+ def sin_datos(encuesta: str) -> None:
229
+ st.warning(f"No se encontraron datos de la {encuesta} en la carpeta configurada.")
230
+ st.page_link("paginas/descargar.py", label="Ir a ⬇️ Descargar datos", icon="⬇️")
231
+ st.page_link("paginas/inicio.py", label="o configurar la carpeta en 🏠 Inicio", icon="🏠")
232
+ st.stop()
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # Selector GEIH (barra lateral compartida)
237
+ # --------------------------------------------------------------------------- #
238
+ AYUDA_ANIOS = ("Los resultados se calculan año por año y quedan guardados: la primera vez tardan unos "
239
+ "segundos por año y después son instantáneos.")
240
+
241
+
242
+ def _rangos(anios) -> str:
243
+ """[2007, 2008, 2009, 2021] -> "2007–2009 y 2021"."""
244
+ tramos = []
245
+ for a in sorted(anios):
246
+ if tramos and a == tramos[-1][1] + 1:
247
+ tramos[-1][1] = a
248
+ else:
249
+ tramos.append([a, a])
250
+ textos = [f"{i}–{f}" if f > i else str(i) for i, f in tramos]
251
+ return textos[0] if len(textos) == 1 else ", ".join(textos[:-1]) + " y " + textos[-1]
252
+
253
+
254
+ def aviso_marcos(anios, empalme: bool) -> None:
255
+ """Advierte cuando los años elegidos mezclan marcos de la GEIH: la serie salta por método, no por el mercado."""
256
+ grupos: dict[str, list[int]] = {}
257
+ for a in anios:
258
+ grupos.setdefault(dd.geih.marco_muestral(a, empalme), []).append(a)
259
+ if len(grupos) < 2:
260
+ return
261
+ partes = [f"{_rangos(v)} {'usan' if len(v) > 1 else 'usa'} {dd.geih.MARCOS[k]}" for k, v in grupos.items()]
262
+ texto = ("⚠️ **Los años elegidos mezclan dos versiones de la GEIH que no son comparables.** "
263
+ + "; ".join(partes) + ". Entre esos años la serie salta por el cambio de método, no por el mercado "
264
+ "laboral (la población en edad de trabajar cambia ~5 %).")
265
+ if any(2010 <= a <= 2020 for a in anios):
266
+ texto += " Para comparar 2010–2020 sin saltos use la serie de empalme."
267
+ st.warning(texto)
268
+
269
+
270
+ def seleccion_geih(clave: str = "geih", varios_anios: bool = True, ayuda_anios: str = AYUDA_ANIOS) -> dict:
271
+ """Dibuja la barra lateral de la GEIH y devuelve la selección, sin cargar datos."""
272
+ with st.sidebar:
273
+ st.header("Datos GEIH")
274
+ serie = st.radio("Serie", ["Principal", "Empalme (2010–2020)"], horizontal=True, key=f"{clave}_serie",
275
+ help="La serie de empalme re-pondera 2010–2020 con el censo 2018, para comparar "
276
+ "con los datos desde 2021 sin saltos.")
277
+ empalme = serie != "Principal"
278
+ av = disponibles_geih(empalme, rutas_actuales())
279
+ if av.empty:
280
+ sin_datos("GEIH")
281
+ av = av[av.completo]
282
+ anios_disp = sorted(av.anio.unique())
283
+ defecto = anios_disp[-2:] if varios_anios else anios_disp[-1:]
284
+ anios = st.multiselect("Años", anios_disp, default=defecto, key=f"{clave}_anios", help=ayuda_anios)
285
+ if not anios:
286
+ st.info("Elija al menos un año.")
287
+ st.stop()
288
+ meses_disp = sorted(av[av.anio.isin(anios)].mes.unique())
289
+ todos = st.toggle("Todos los meses", True, key=f"{clave}_todos")
290
+ meses = None
291
+ if not todos:
292
+ meses = st.multiselect("Meses", meses_disp, default=meses_disp[:1], format_func=dd.MESES.get,
293
+ key=f"{clave}_meses")
294
+ if not meses:
295
+ st.stop()
296
+
297
+ st.header("Filtros")
298
+ sexo = st.multiselect("Sexo", ["Hombre", "Mujer"], key=f"{clave}_sexo", placeholder="Todos")
299
+ zona = st.multiselect("Zona", ["Urbano (cabecera)", "Rural (centros poblados y disperso)"],
300
+ key=f"{clave}_zona", placeholder="Todas")
301
+ edad = st.slider("Edad", 0, 100, (0, 100), key=f"{clave}_edad")
302
+ dptos = st.multiselect("Departamento", sorted(dd.DEPARTAMENTOS.values()), key=f"{clave}_dpto",
303
+ placeholder="Todos")
304
+
305
+ partes = [f"{'empalme ' if empalme else ''}{', '.join(map(str, sorted(anios)))}"]
306
+ if meses:
307
+ partes.append(", ".join(dd.MESES[m] for m in sorted(meses)))
308
+ for nombre, val in (("sexo", sexo), ("zona", zona), ("dpto", dptos)):
309
+ if val:
310
+ partes.append(f"{nombre}: {', '.join(val)}")
311
+ if edad != (0, 100):
312
+ partes.append(f"edad {edad[0]}–{edad[1]}")
313
+
314
+ elegidos = av[av.anio.isin(anios)]
315
+ seleccion = {
316
+ "anios": sorted(map(int, anios)), "meses": sorted(map(int, meses)) if meses else None, "empalme": empalme,
317
+ "sexo": sexo, "zona": zona, "edad": edad, "dptos": dptos, "descripcion": " · ".join(partes),
318
+ "meses_disponibles": tuple(zip(elegidos.anio.tolist(), elegidos.mes.tolist())),
319
+ "filtrada": bool(meses or sexo or zona or dptos or edad != (0, 100)),
320
+ # en el formato de dd.geih.filtrar
321
+ "filtros": {k: v for k, v in (("sexo", list(sexo)), ("zona", list(zona)),
322
+ ("edad", tuple(edad) if edad != (0, 100) else None),
323
+ ("departamento", list(dptos))) if v},
324
+ }
325
+ aviso_marcos(seleccion["anios"], empalme)
326
+ return seleccion
327
+
328
+
329
+ # --------------------------------------------------------------------------- #
330
+ # Selector EAM (barra lateral compartida)
331
+ # --------------------------------------------------------------------------- #
332
+ def selector_eam(extra: tuple = (), clave: str = "eam"):
333
+ with st.sidebar:
334
+ st.header("Datos EAM")
335
+ av = disponibles_eam(rutas_actuales())
336
+ if av.empty:
337
+ sin_datos("EAM")
338
+ anios_disp = sorted(av.anio.unique())
339
+ lo, hi = st.select_slider("Años", options=anios_disp,
340
+ value=(max(anios_disp[0], anios_disp[-1] - 10), anios_disp[-1]), key=f"{clave}_rango")
341
+ anios = [a for a in anios_disp if lo <= a <= hi]
342
+ st.caption("CIIU Rev. 2 hasta 2000, Rev. 3 2001–2011 y Rev. 4 desde 2012 (según los archivos del DANE): "
343
+ "los sectores no son comparables entre revisiones.")
344
+ df = cargar_eam(tuple(anios), tuple(sorted(extra)), rutas_actuales())
345
+ with st.sidebar:
346
+ st.header("Filtros")
347
+ sectores = st.multiselect("Sector (división CIIU)", sorted(df["sector"].dropna().unique()),
348
+ key=f"{clave}_sector", placeholder="Todos")
349
+ dptos = st.multiselect("Departamento", sorted(df["departamento"].dropna().unique()),
350
+ key=f"{clave}_dpto", placeholder="Todos")
351
+ if sectores:
352
+ df = df[df["sector"].isin(sectores)]
353
+ if dptos:
354
+ df = df[df["departamento"].isin(dptos)]
355
+ carga = f"eam = dd.eam.load(range({lo}, {hi + 1})" + (f", columns={sorted(extra)!r}" if extra else "") + ")"
356
+ if sectores:
357
+ carga += f'\neam = eam[eam["sector"].isin({sectores!r})]'
358
+ if dptos:
359
+ carga += f'\neam = eam[eam["departamento"].isin({dptos!r})]'
360
+ desc = f"{lo}–{hi}" + (f" · {', '.join(sectores)}" if sectores else "") + (f" · {', '.join(dptos)}" if dptos else "")
361
+ return df, desc, {"carga": carga, "anios": anios}