"""Agrega una serie temporal por periodo para el capitulo TIMESERIES (grupo eda). Funcion pura y determinista: recibe las fechas y los valores YA leidos (nunca toca una base de datos ni hace I/O) y los agrega por bucket temporal para poder graficar la evolucion de la serie y, en paralelo, el CONTEO de observaciones por periodo (densidad temporal). Estilo "dict-no-throw" del grupo eda: NUNCA lanza excepcion, siempre devuelve el mismo conjunto de claves. Lectura y parseo de fechas 100% defensivos. Solo usa la libreria estandar (``datetime``, ``statistics``, ``re``). """ from __future__ import annotations import datetime import re import statistics # Frecuencias soportadas, de mas fina a mas gruesa. _FREQS = ("daily", "weekly", "monthly", "quarterly", "yearly") # Agregaciones soportadas. _AGGS = ("mean", "sum", "median", "last", "min", "max") # Acepta el inicio de una fecha ISO con cualquier separador posterior # (incluido un caracter raro entre la fecha y la hora). _DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})") def _to_date(x) -> "datetime.date | None": """Parsea defensivamente un valor a ``datetime.date``; devuelve None si falla.""" if x is None: return None # datetime es subclase de date: comprobarlo primero. if isinstance(x, datetime.datetime): return x.date() if isinstance(x, datetime.date): return x s = str(x).strip() if not s: return None # Camino feliz: ISO completo (con o sin hora, con o sin 'Z' final). try: s2 = s[:-1] if s.endswith("Z") else s return datetime.datetime.fromisoformat(s2).date() except ValueError: pass # Fallback robusto: extrae el prefijo YYYY-MM-DD con cualquier separador. m = _DATE_RE.match(s) if m: try: return datetime.date(int(m.group(1)), int(m.group(2)), int(m.group(3))) except ValueError: return None return None def _to_number(x) -> "float | None": """Convierte a float si es numerico finito; devuelve None en otro caso.""" if x is None: return None if isinstance(x, bool): # bool es subclase de int: lo tratamos como no-numerico para una serie. return None try: f = float(x) except (TypeError, ValueError): return None # Descarta NaN / Inf (no agregables de forma estable). if f != f or f in (float("inf"), float("-inf")): return None return f def _infer_freq(dates_sorted: list) -> str: """Infiere la frecuencia desde el delta mediano (en dias) entre fechas.""" if len(dates_sorted) < 2: return "daily" diffs = [ (dates_sorted[i + 1] - dates_sorted[i]).days for i in range(len(dates_sorted) - 1) ] diffs = [d for d in diffs if d > 0] # ignora duplicados del mismo dia if not diffs: return "daily" med = statistics.median(diffs) if med <= 3: return "daily" if med <= 16: return "weekly" if med <= 75: return "monthly" if med <= 200: return "quarterly" return "yearly" def _bucket_start(d: "datetime.date", freq: str) -> "datetime.date": """Trunca una fecha al inicio de su bucket segun la frecuencia.""" if freq == "weekly": return d - datetime.timedelta(days=d.weekday()) # lunes ISO if freq == "monthly": return datetime.date(d.year, d.month, 1) if freq == "quarterly": first_month = ((d.month - 1) // 3) * 3 + 1 return datetime.date(d.year, first_month, 1) if freq == "yearly": return datetime.date(d.year, 1, 1) return d # daily (o cualquier otra cosa): la propia fecha def _downsample_indices(n: int, max_points: int) -> list: """Indices equiespaciados conservando primero y ultimo (<= max_points).""" if max_points <= 0 or max_points >= n: return list(range(n)) if max_points == 1: return [0] idx = sorted({round(i * (n - 1) / (max_points - 1)) for i in range(max_points)}) return idx def _empty(freq_req: str, agg: str) -> dict: """Resultado canonico cuando no hay datos suficientes.""" eff_freq = freq_req if freq_req in _FREQS else "auto" return { "t": [], "v": [], "count": [], "freq": eff_freq, "agg": agg if agg in _AGGS else "mean", "n_in": 0, "n_buckets": 0, "downsampled": False, "note": "datos insuficientes", } def resample_timeseries( t: list, v: list, freq: str = "auto", agg: str = "mean", max_points: int = 400, ) -> dict: """Agrega una serie temporal por periodo (buckets) para graficarla. Empareja ``t[i]`` con ``v[i]`` por indice, descarta los pares cuya fecha no parsea, trunca cada fecha al inicio de su bucket segun ``freq`` y agrupa. Por cada bucket devuelve el valor agregado (``agg`` sobre los valores numericos validos) y el CONTEO de observaciones con fecha valida (densidad temporal), independientemente de si su valor numerico es ``None``. Funcion pura: no hace I/O, no muta los inputs, es determinista, NUNCA lanza. Args: t: lista de fechas paralela a ``v``. Acepta strings ISO (``"YYYY-MM-DD"`` o ``"YYYY-MM-DDTHH:MM:SS"``, con ``Z`` opcional), ``datetime.date`` o ``datetime.datetime``. Se parsea defensivamente; las fechas que no parsean se descartan junto con su valor. v: lista de valores numericos (float/int). Puede contener ``None`` o valores no numericos: estos se ignoran en la agregacion, pero la fila sigue contando en ``count`` (siempre que su fecha sea valida). freq: ``"auto"`` (infiere del delta mediano entre fechas) o uno de ``"daily"``, ``"weekly"``, ``"monthly"``, ``"quarterly"``, ``"yearly"``. Una frecuencia desconocida cae a ``"auto"``. agg: agregacion por bucket: ``"mean"``, ``"sum"``, ``"median"``, ``"last"`` (valor de la observacion cronologicamente mas reciente), ``"min"`` o ``"max"``. Una agregacion desconocida cae a ``"mean"``. max_points: si tras agregar hay mas buckets que este limite, se hace downsampling uniforme (1 de cada k buckets equiespaciados, conservando el primero y el ultimo) para no saturar el grafico. Returns: Siempre un dict con las mismas claves:: { "t": [str, ...], # etiqueta ISO YYYY-MM-DD de cada bucket "v": [float|None, ...], # valor agregado por bucket (None si vacio) "count": [int, ...], # nº de observaciones con fecha valida "freq": str, # frecuencia efectivamente usada "agg": str, # agregacion usada "n_in": int, # nº de pares (t,v) con fecha valida "n_buckets": int, # nº de buckets antes del downsample "downsampled": bool, # True si se aplico downsampling "note": str, # "" o nota (p.ej. "datos insuficientes") } """ agg = agg if agg in _AGGS else "mean" freq_req = freq if isinstance(freq, str) else "auto" # Validacion de entrada: deben ser listas de igual longitud y no vacias. if ( not isinstance(t, list) or not isinstance(v, list) or len(t) == 0 or len(t) != len(v) ): return _empty(freq_req, agg) # Empareja por indice y descarta fechas no parseables. parsed: list = [] # (date, original_index, number_or_None) for i, (ti, vi) in enumerate(zip(t, v)): d = _to_date(ti) if d is None: continue parsed.append((d, i, _to_number(vi))) n_in = len(parsed) if n_in == 0: return _empty(freq_req, agg) # Resuelve la frecuencia efectiva. if freq_req in _FREQS: eff_freq = freq_req else: dates_sorted = sorted(d for d, _, _ in parsed) eff_freq = _infer_freq(dates_sorted) # Agrupa por bucket. buckets: dict = {} for d, idx, num in parsed: b = _bucket_start(d, eff_freq) slot = buckets.get(b) if slot is None: slot = {"count": 0, "vals": [], "last_key": None, "last_val": None} buckets[b] = slot slot["count"] += 1 if num is not None: slot["vals"].append(num) key = (d, idx) if slot["last_key"] is None or key > slot["last_key"]: slot["last_key"] = key slot["last_val"] = num ordered = sorted(buckets.items(), key=lambda kv: kv[0]) n_buckets = len(ordered) def _aggregate(vals: list, last_val) -> "float | None": if not vals: return None if agg == "sum": return float(sum(vals)) if agg == "median": return float(statistics.median(vals)) if agg == "last": return float(last_val) if last_val is not None else None if agg == "min": return float(min(vals)) if agg == "max": return float(max(vals)) return float(statistics.fmean(vals)) # mean (default) t_out = [b.isoformat() for b, _ in ordered] v_out = [_aggregate(s["vals"], s["last_val"]) for _, s in ordered] c_out = [s["count"] for _, s in ordered] downsampled = False if n_buckets > max_points > 0: keep = _downsample_indices(n_buckets, max_points) t_out = [t_out[i] for i in keep] v_out = [v_out[i] for i in keep] c_out = [c_out[i] for i in keep] downsampled = True return { "t": t_out, "v": v_out, "count": c_out, "freq": eff_freq, "agg": agg, "n_in": n_in, "n_buckets": n_buckets, "downsampled": downsampled, "note": "", }