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>
184 lines
6.4 KiB
Python
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
|