""" Repositorio persistente de proyecciones. El caché en memoria de jobs.py se pierde en cada reinicio y solo guarda las últimas 32. Aquí las proyecciones quedan en disco, compartidas entre todos los usuarios, y con dos propiedades que importan: * **Invalidación por versión de datos.** La clave incluye una firma de la base (última fecha y número de filas). El ETL diario corrige meses históricos de forma retroactiva, así que una proyección calculada con la base de ayer no debe servirse hoy: la firma cambia y se recalcula sola. * **Historial con fecha de emisión.** Nada se sobrescribe. Queda registrado qué se proyectó y cuándo, lo que permite después contrastar el pronóstico contra lo que realmente ocurrió. Va en su propio archivo y no dentro de sinadef.duckdb porque el ETL reemplaza esa base entera con os.replace(); guardar aquí el historial lo borraría a diario. """ from __future__ import annotations import hashlib import json import threading from datetime import datetime from pathlib import Path import duckdb BASE_DIR = Path(__file__).resolve().parent STORE_PATH = BASE_DIR / "proyecciones.duckdb" _lock = threading.Lock() _iniciado = False def _conn(): global _iniciado c = duckdb.connect(str(STORE_PATH)) if not _iniciado: c.execute(""" CREATE TABLE IF NOT EXISTS proyecciones ( clave VARCHAR, firma_datos VARCHAR, creado_en TIMESTAMP, duracion_s DOUBLE, tipo VARCHAR, sexo VARCHAR, departamento VARCHAR, lugar VARCHAR, mes_corte VARCHAR, hasta VARCHAR, modelo VARCHAR, mape DOUBLE, mase DOUBLE, cierre DOUBLE, params VARCHAR, resultado VARCHAR ) """) _iniciado = True return c def firma_datos(fecha_max: str, filas: int) -> str: """Identifica la versión de la base: si cambia, lo cacheado ya no aplica.""" return hashlib.sha256(f"{fecha_max}|{filas}".encode()).hexdigest()[:12] def clave(params: dict, firma: str) -> str: crudo = json.dumps(params, sort_keys=True, ensure_ascii=False, default=str) return hashlib.sha256(f"{crudo}|{firma}".encode()).hexdigest()[:16] def buscar(clave_: str) -> dict | None: """Devuelve la proyección guardada más reciente para esa clave exacta.""" with _lock: c = _conn() try: fila = c.execute( """SELECT resultado, creado_en, duracion_s FROM proyecciones WHERE clave = ? ORDER BY creado_en DESC LIMIT 1""", [clave_], ).fetchone() finally: c.close() if not fila: return None res = json.loads(fila[0]) res["_guardada_en"] = fila[1].isoformat(timespec="seconds") res["_duracion_original"] = fila[2] return res def guardar(clave_: str, firma: str, params: dict, resultado: dict, duracion: float) -> None: # Dos formatos conviven en esta tabla: la proyección mensual (varios modelos # compitiendo, con "mejor" y "cierre_anual") y el nowcast diario (un GLM, # con "total" y métricas de backtest). Se normalizan a las mismas columnas. mejor = resultado.get("mejor") if mejor: modelo = mejor["modelo"] mape, mase = mejor.get("mape"), mejor.get("mase") cierre = (resultado.get("cierre_anual") or {}).get("cierre") else: mod = resultado.get("modelo") or {} bt = resultado.get("backtest") or {} modelo = f"GLM {mod.get('familia', 'nowcast')}" mape, mase = bt.get("mape_ventana"), None cierre = resultado.get("total") with _lock: c = _conn() try: c.execute( """INSERT INTO proyecciones VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", [ clave_, firma, datetime.now(), duracion, params.get("tipo") or "(todas)", params.get("sexo") or "(todos)", params.get("departamento") or "(todos)", params.get("lugar") or "(todos)", params.get("mes_corte") or params.get("mes"), params.get("hasta"), modelo, float(mape) if mape is not None else None, float(mase) if mase is not None else None, float(cierre) if cierre is not None else None, json.dumps(params, ensure_ascii=False, default=str), json.dumps(resultado, ensure_ascii=False, default=str), ], ) finally: c.close() def historial(limite: int = 50, solo_vigentes: str | None = None) -> list[dict]: """ Proyecciones guardadas, de la más reciente a la más antigua. Con `solo_vigentes` (una firma de datos) se filtran las calculadas con la versión actual de la base; sin él se ve todo el histórico, incluidas las hechas con datos anteriores. """ with _lock: c = _conn() try: where = "WHERE firma_datos = ?" if solo_vigentes else "" params = [solo_vigentes] if solo_vigentes else [] filas = c.execute( f"""SELECT creado_en, tipo, sexo, departamento, lugar, mes_corte, hasta, modelo, mape, mase, cierre, duracion_s, firma_datos, clave FROM proyecciones {where} ORDER BY creado_en DESC LIMIT {int(limite)}""", params, ).fetchall() finally: c.close() cols = ["creado_en", "tipo", "sexo", "departamento", "lugar", "mes_corte", "hasta", "modelo", "mape", "mase", "cierre", "duracion_s", "firma_datos", "clave"] salida = [] for f in filas: d = dict(zip(cols, f)) d["creado_en"] = d["creado_en"].isoformat(timespec="seconds") salida.append(d) return salida def estadisticas() -> dict: with _lock: c = _conn() try: n, primeras, ahorro = c.execute( """SELECT COUNT(*), MIN(creado_en), COALESCE(SUM(duracion_s), 0) FROM proyecciones""" ).fetchone() finally: c.close() return { "guardadas": n, "desde": primeras.isoformat(timespec="seconds") if primeras else None, "segundos_de_computo_acumulados": round(ahorro or 0, 1), }