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