Files
fn_registry/python/functions/datascience/profile_datetime.py
T
egutierrez a69d14d38e 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>
2026-06-30 15:35:42 +02:00

184 lines
6.4 KiB
Python

"""Perfil minimo de una columna fecha/datetime para la cabecera TIMESERIES (grupo eda).
Funcion pura y determinista que resume una columna temporal: rango (min/max),
numero de fechas distintas, frecuencia inferida (daily/weekly/monthly/quarterly/
yearly/irregular), regularidad de los pasos, huecos respecto a la rejilla inferida
y paso mediano entre fechas consecutivas. Cierra el `datetime{}=None` que hoy deja
pendiente el TableProfile de AutomaticEDA.
Acepta valores heterogeneos (``datetime.date``, ``datetime.datetime`` y strings
ISO como ``"2021-06-28"``, ``"2021-06-28T00:00:00"`` o ``"2021-06-28 12:00:00"``),
parsea de forma defensiva, ignora lo que no se puede parsear y NUNCA lanza.
Solo usa stdlib (``datetime`` + ``statistics``).
"""
from __future__ import annotations
import statistics
from datetime import date, datetime
def _parse_one(v) -> datetime | None:
"""Parsea un valor a ``datetime`` naive, o devuelve None si no es una fecha.
Acepta ``datetime.datetime``, ``datetime.date`` y strings ISO. Cualquier
datetime con zona horaria se normaliza a naive (se descarta el tzinfo) para
poder mezclarlo con fechas naive sin que las restas lancen ``TypeError``.
"""
if v is None or isinstance(v, bool):
return None
# datetime es subclase de date: comprobar datetime primero.
if isinstance(v, datetime):
return v.replace(tzinfo=None)
if isinstance(v, date):
return datetime(v.year, v.month, v.day)
if isinstance(v, str):
s = v.strip()
if not s:
return None
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
return dt.replace(tzinfo=None)
return None
def _infer_freq(median_step_days: float) -> str:
"""Clasifica la frecuencia a partir del paso mediano (en dias) entre fechas.
Bandas con tolerancia: ~1 dia -> daily, ~7 -> weekly, 28-31 -> monthly,
89-92 -> quarterly, 360-366 -> yearly. Cualquier paso fuera de las bandas
(incluido sub-diario) -> irregular.
"""
m = median_step_days
if 0.5 <= m <= 1.5:
return "daily"
if 6.0 <= m <= 8.0:
return "weekly"
if 28.0 <= m <= 31.0:
return "monthly"
if 89.0 <= m <= 92.0:
return "quarterly"
if 360.0 <= m <= 366.0:
return "yearly"
return "irregular"
def profile_datetime(values: list) -> dict:
"""Perfila una columna de fechas para la cabecera del capitulo TIMESERIES.
Funcion pura y determinista: no hace I/O, no muta el input y nunca lanza.
El analisis de frecuencia, regularidad y huecos se hace sobre las **fechas
distintas ordenadas** (se deduplica antes de calcular los pasos): los valores
repetidos generarian pasos de 0 dias que distorsionarian el mediano y la
inferencia. ``n`` cuenta los valores parseables (con duplicados) y
``n_distinct`` las fechas unicas.
Args:
values: lista de valores fecha. Acepta ``datetime.date``,
``datetime.datetime`` y strings ISO (``"2021-06-28"``,
``"2021-06-28T00:00:00"``, ``"2021-06-28 12:00:00"``). Los valores
None, vacios o no parseables se ignoran. Si ``values`` es None o no
iterable se trata como lista vacia.
Returns:
Siempre un dict con esta forma::
{
"min": str | None, # fecha minima ISO date (YYYY-MM-DD)
"max": str | None, # fecha maxima ISO date
"n": int, # nº de valores fecha parseables
"n_distinct": int, # nº de fechas distintas
"span_days": float | None, # (max - min) en dias
"freq": str, # daily|weekly|monthly|quarterly|
# yearly|irregular|unknown
"is_regular": bool, # pasos ~constantes (tolerancia ±25%)
"n_gaps": int, # saltos > ~1.5x el paso mediano
"median_step_days": float | None, # paso mediano entre fechas
"note": str # "" o nota corta
}
Con menos de 2 valores parseables (o una sola fecha distinta) devuelve
``freq="unknown"``, ``is_regular=False``, ``n_gaps=0``,
``median_step_days=None`` y la nota correspondiente, manteniendo min/max
y span_days coherentes cuando hay al menos una fecha.
"""
base = {
"min": None,
"max": None,
"n": 0,
"n_distinct": 0,
"span_days": None,
"freq": "unknown",
"is_regular": False,
"n_gaps": 0,
"median_step_days": None,
"note": "",
}
if values is None:
values = []
try:
iterator = list(values)
except TypeError:
iterator = []
parsed: list[datetime] = []
for v in iterator:
dt = _parse_one(v)
if dt is not None:
parsed.append(dt)
n = len(parsed)
base["n"] = n
if n == 0:
base["note"] = "datos insuficientes"
return base
distinct = sorted(set(parsed))
n_distinct = len(distinct)
dt_min = min(parsed)
dt_max = max(parsed)
base["n_distinct"] = n_distinct
base["min"] = dt_min.date().isoformat()
base["max"] = dt_max.date().isoformat()
base["span_days"] = round((dt_max - dt_min).total_seconds() / 86400.0, 6)
# Sin al menos dos fechas distintas no hay pasos que medir.
if n_distinct < 2:
base["note"] = "datos insuficientes" if n < 2 else "una sola fecha distinta"
return base
steps = [
(distinct[i + 1] - distinct[i]).total_seconds() / 86400.0
for i in range(n_distinct - 1)
]
median_step = float(statistics.median(steps))
base["median_step_days"] = round(median_step, 6)
freq = _infer_freq(median_step)
base["freq"] = freq
# Regularidad: >=80% de los pasos dentro de ±25% del paso mediano.
if median_step > 0:
tol = 0.25 * median_step
within = sum(1 for s in steps if abs(s - median_step) <= tol)
base["is_regular"] = (within / len(steps)) >= 0.8
else:
base["is_regular"] = False
# Huecos: pasos que superan ~1.5x el mediano. Solo tiene sentido cuando la
# frecuencia es una rejilla regular conocida (no irregular/unknown).
if freq not in ("unknown", "irregular") and median_step > 0:
threshold = 1.5 * median_step
base["n_gaps"] = sum(1 for s in steps if s > threshold)
else:
base["n_gaps"] = 0
return base