a69d14d38e
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>
123 lines
4.6 KiB
Python
123 lines
4.6 KiB
Python
"""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)}
|