"""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)}