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,122 @@
"""extract_timeseries_raw — extrae la serie temporal CRUDA de una tabla.
Lector read-only inyectado: recibe `query_fn(sql) -> dict` con el mismo contrato
que duckdb_query_readonly / pg_query (y que el `_q` de profile_table):
`{"status": "ok", "rows": [{col: val, ...}, ...]}`. Esta funcion NO abre ninguna
conexion por su cuenta — solo usa `query_fn`. Construye UNA sola query ordenada
por la columna temporal y devuelve las fechas (`t`) mas cada columna numerica en
listas paralelas (`series`), listas para alimentar el render del capitulo
TIMESERIES de AutomaticEDA (linea valor-vs-tiempo + conteo por periodo) sin que
el capitulo toque la base de datos: recibe esto en `ctx['timeseries_raw']`.
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier excepcion y
degrada a `{"status": "error", "error": str, ...}`.
"""
from datetime import date, datetime
def _to_float(value):
"""Convierte un valor a float de forma defensiva. None si no es convertible."""
if value is None:
return None
if isinstance(value, bool):
# Un bool es subclase de int en Python; no es un valor de serie valido.
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip()
if not s:
return None
try:
return float(s)
except (TypeError, ValueError):
return None
def _to_iso(value):
"""Convierte un valor temporal a string ISO conservando el orden de la query.
date/datetime -> isoformat(); cualquier otro valor (string, etc.) -> str().
None se preserva como None.
"""
if value is None:
return None
if isinstance(value, (datetime, date)):
return value.isoformat()
return str(value)
def extract_timeseries_raw(query_fn, table, time_col, value_cols, max_rows=5000):
"""Extrae la serie temporal cruda (fechas + columnas numericas) de una tabla.
Args:
query_fn: callable lector read-only del backend activo. Recibe un string
SQL y devuelve un dict {"status": "ok", "rows": [{col: val, ...}]}
(mismo contrato que duckdb_query_readonly / el `_q` de profile_table).
No se abre ninguna conexion aqui: toda la lectura pasa por query_fn.
table: nombre de la tabla.
time_col: nombre de la columna de orden temporal.
value_cols: lista de nombres de columnas numericas a extraer.
max_rows: limite de filas (LIMIT). Default 5000.
Returns:
dict (nunca lanza):
{
"status": "ok" | "error",
"error": str, # solo si status == "error"
"time_col": str,
"t": [str, ...], # time_col como ISO string, en orden
"series": {col: [float|None, ...], ...}, # paralela a t por columna
"n": int # nº de filas devueltas
}
"""
base = {"status": "ok", "time_col": time_col, "t": [], "series": {}, "n": 0}
try:
if query_fn is None:
return {**base, "status": "error", "error": "query_fn es None"}
if not value_cols:
return {**base, "status": "error", "error": "value_cols vacío"}
if not table or not time_col:
return {
**base,
"status": "error",
"error": "table y time_col son obligatorios",
}
# Identificadores escapados con comillas dobles (como hace profile_table)
# para tolerar nombres con mayusculas/espacios/palabras reservadas.
cols_sql = ", ".join(f'"{c}"' for c in value_cols)
sql = (
f'SELECT "{time_col}", {cols_sql} FROM "{table}" '
f'WHERE "{time_col}" IS NOT NULL '
f'ORDER BY "{time_col}" '
f"LIMIT {int(max_rows)}"
)
q = query_fn(sql)
if not isinstance(q, dict) or q.get("status") != "ok":
err = (
q.get("error", "query_fn fallo")
if isinstance(q, dict)
else "query_fn no devolvio un dict"
)
return {**base, "status": "error", "error": err}
rows = q.get("rows", []) or []
t = []
series = {c: [] for c in value_cols}
for row in rows:
t.append(_to_iso(row.get(time_col)))
for c in value_cols:
series[c].append(_to_float(row.get(c)))
return {
"status": "ok",
"time_col": time_col,
"t": t,
"series": series,
"n": len(t),
}
except Exception as e: # noqa: BLE001 - dict-no-throw: degradar, nunca lanzar
return {**base, "status": "error", "error": str(e)}