feat(eda): capítulo TIMESERIES del AutomaticEDA (evolución + análisis de serie)

Capítulo nuevo build_timeseries(profile, ctx) -> Chapter|None del motor
AutomaticEDA. Cuando la tabla tiene columna de fecha/datetime, grafica la
evolución de cada columna numérica por periodo (valor agregado + conteo de filas)
y los paneles de descomposición STL y autocorrelación (ACF), con el análisis de
la serie: estacionariedad (ADF+KPSS), autocorrelación (Ljung-Box), fuerzas de
tendencia/estacionalidad (Hyndman) y la transformación sugerida (retornos o
diferencias) para evitar correlaciones espurias. Sin columna temporal devuelve
None. Consolida series OHLC casi idénticas en un único gráfico conservando el
análisis de cada columna.

La serie cruda llega por ctx['timeseries_raw'] (mismo patrón que modelos con
raw_numeric); las figuras son perezosas (Figure.make) y el paginador del núcleo
garantiza no-corte en PDF y PPTX. CHAPTER_VERSION 1.0.0.

Cubre los MUST del diseño (report 2043): MUST-9.1 (línea valor-vs-tiempo + conteo
por periodo), MUST-9.2 (paneles STL + ACF), MUST-9.3 (perfil datetime +
consolidación OHLC).

Funciones nuevas del registry (grupo eda), delegadas a fn-constructor, no inline:
- detect_time_column (pure): detecta la columna temporal y las numéricas
- profile_datetime (pure): rango/frecuencia/regularidad/huecos de la fecha
- resample_timeseries (pure): agrega la serie por periodo + conteo
- extract_timeseries_raw (impure): lee la serie cruda ordenada de DuckDB/PG

Verificación: 69 tests verdes (capítulo 9 + funciones 28 + núcleo/renderers);
golden real sobre seattle-weather (estacional) y aapl (OHLC) con PDF+PPTX sin
cortar nada (cols_cortadas=[]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:35:42 +02:00
parent 415154d9a3
commit a69d14d38e
15 changed files with 2324 additions and 0 deletions
@@ -0,0 +1,112 @@
"""Detecta la columna temporal y las columnas numericas de un TableProfile (grupo eda).
Funcion pura y determinista: a partir de la lista de columnas de un TableProfile
producido por el grupo de capacidad `eda` (cada elemento es un ColumnProfile dict),
decide cual es la columna de orden temporal y que columnas numericas hay disponibles
para graficar una serie en el tiempo. Es la pieza que usa el capitulo TIMESERIES del
AutomaticEDA para decidir si la tabla admite analisis de serie temporal.
Lectura 100% defensiva al estilo "dict-no-throw" del grupo eda: nunca lanza
excepcion, siempre devuelve el mismo conjunto de claves.
"""
# semantic_type que el profiler (infer_semantic_type) emite para fechas/datetimes.
_DATETIME_SEMANTICS = ("datetime_iso", "date_eu")
def detect_time_column(columns: list) -> dict:
"""Detecta la columna temporal y las numericas de una lista de ColumnProfile.
Recorre los ColumnProfile de un TableProfile y clasifica cada columna como
temporal o numerica leyendo de forma defensiva sus claves. Una columna es
temporal si su ``inferred_type == "datetime"`` o si su ``semantic_type`` esta
en {``"datetime_iso"``, ``"date_eu"``}. La columna temporal elegida
(``time_col``) es la PRIMERA temporal en el orden de la lista. Las numericas
(``numeric_cols``) son las de ``inferred_type == "numeric"``, en orden.
Funcion pura: no hace I/O, no muta el input, es determinista.
Args:
columns: lista de ColumnProfile dict del grupo eda. Cada elemento suele
tener claves como ``name``, ``inferred_type``, ``semantic_type`` y
``numeric``. Los elementos que no sean dict se ignoran. Si ``columns``
es None, no es lista o esta vacia, se devuelve el dict "no aplica".
Returns:
Siempre un dict con las mismas claves::
{
"time_col": str | None, # columna temporal elegida (None si no hay)
"time_semantic": str, # semantic_type de la temporal ("" si no aplica)
"numeric_cols": [str, ...], # columnas con inferred_type == "numeric"
"n_datetime_cols": int, # nº de columnas temporales detectadas
"datetime_cols": [str, ...],# todas las temporales, en orden de aparicion
"reason": str, # frase corta (en espanol) que explica la eleccion
}
"""
# Caso "no aplica": entrada invalida o vacia.
if not isinstance(columns, list) or not columns:
return {
"time_col": None,
"time_semantic": "",
"numeric_cols": [],
"n_datetime_cols": 0,
"datetime_cols": [],
"reason": "no se detecto columna de fecha/datetime",
}
datetime_cols: list[str] = []
datetime_semantics: list[str] = []
numeric_cols: list[str] = []
for col in columns:
# Ignora elementos que no sean dict sin fallar.
if not isinstance(col, dict):
continue
name = col.get("name")
if name is None:
name = ""
else:
name = str(name)
inferred_type = col.get("inferred_type") or ""
semantic_type = col.get("semantic_type") or ""
is_datetime = inferred_type == "datetime" or semantic_type in _DATETIME_SEMANTICS
if is_datetime:
datetime_cols.append(name)
datetime_semantics.append(semantic_type)
if inferred_type == "numeric":
numeric_cols.append(name)
if not datetime_cols:
return {
"time_col": None,
"time_semantic": "",
"numeric_cols": numeric_cols,
"n_datetime_cols": 0,
"datetime_cols": [],
"reason": "no se detecto columna de fecha/datetime",
}
time_col = datetime_cols[0]
time_semantic = datetime_semantics[0]
if len(datetime_cols) == 1:
reason = f"columna temporal '{time_col}' detectada"
else:
reason = (
f"{len(datetime_cols)} columnas temporales; se elige la primera "
f"'{time_col}'"
)
return {
"time_col": time_col,
"time_semantic": time_semantic,
"numeric_cols": numeric_cols,
"n_datetime_cols": len(datetime_cols),
"datetime_cols": datetime_cols,
"reason": reason,
}