import { useEffect, useMemo, useRef, useState } from "react"; import type { Meta } from "./api"; import { CierreAnual, ProyeccionChart, nf } from "./charts"; const NOMBRE_MES = [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "setiembre", "octubre", "noviembre", "diciembre", ]; const mesLegible = (p: string) => { const [a, m] = p.split("-"); return `${NOMBRE_MES[+m - 1]} ${a}`; }; const FASES = [ { k: "en_cola", t: "En cola" }, { k: "iniciando", t: "Preparando serie" }, { k: "evaluando", t: "Validando modelos" }, { k: "seleccionando", t: "Eligiendo el mejor" }, { k: "proyectando", t: "Proyectando" }, { k: "listo", t: "Listo" }, ]; export default function Proyeccion({ meta }: { meta: Meta }) { /* Último mes cerrado: el mes con data siempre está en curso, así que el corte por defecto es el anterior. */ const ultimoCerrado = useMemo(() => { const [a, m] = meta.ultimo_mes.split("-").map(Number); return m === 1 ? `${a - 1}-12` : `${a}-${String(m - 1).padStart(2, "0")}`; }, [meta.ultimo_mes]); const [tipo, setTipo] = useState("HOMICIDIO"); const [sexo, setSexo] = useState(""); const [departamento, setDepartamento] = useState(""); const [lugar, setLugar] = useState(""); const [corte, setCorte] = useState(ultimoCerrado); const [hasta, setHasta] = useState(`${meta.anio_max}-12`); const [excluir, setExcluir] = useState(0); const [ventana, setVentana] = useState(3); const [origenes, setOrigenes] = useState(6); const [job, setJob] = useState(null); const [error, setError] = useState(null); const timer = useRef(null); // Meses de corte ofrecidos: los últimos 24 cerrados. const opcionesCorte = useMemo(() => { const out: string[] = []; const [a0, m0] = ultimoCerrado.split("-").map(Number); let a = a0, m = m0; for (let i = 0; i < 24; i++) { out.push(`${a}-${String(m).padStart(2, "0")}`); m -= 1; if (m === 0) { m = 12; a -= 1; } } return out; }, [ultimoCerrado]); const detener = () => { if (timer.current) { window.clearInterval(timer.current); timer.current = null; } }; useEffect(() => detener, []); async function ejecutar() { detener(); setError(null); setJob(null); const p = new URLSearchParams({ mes_corte: corte, hasta, excluir_meses: String(excluir), ventana: String(ventana), origenes: String(origenes), }); for (const [k, v] of Object.entries({ tipo, sexo, departamento, lugar })) if (v) p.set(k, v); try { const r = await fetch(`/api/forecast?${p}`, { method: "POST" }); if (!r.ok) throw new Error((await r.json()).detail ?? r.statusText); const j = await r.json(); setJob(j); if (j.estado === "listo") return; timer.current = window.setInterval(async () => { const e = await fetch(`/api/forecast/${j.id}`); if (!e.ok) return; const d = await e.json(); setJob(d); if (d.estado === "listo" || d.estado === "error") detener(); }, 1200); } catch (e: any) { setError(String(e.message ?? e)); } } const r = job?.estado === "listo" ? job.resultado : null; const corriendo = job && (job.estado === "en_cola" || job.estado === "corriendo"); const faseActual = job?.progreso?.fase ?? null; const iFase = FASES.findIndex((f) => f.k === faseActual); const totalProyectado = r ? Math.round(r.central.reduce((s: number, v: number) => s + v, 0)) : null; return ( <>
Ajustes de validación

SINADEF sigue recibiendo certificados con retraso, así que los meses recién cerrados están subvalorados. Excluirlos del entrenamiento evita que el modelo aprenda una caída que en realidad es registro pendiente.

{error &&
No se pudo iniciar: {error}
} {job && job.estado === "error" && (
La proyección falló: {job.error}
)} {job && (corriendo || r) && (
{FASES.map((f, i) => (
{f.t}
))}

{job.progreso?.mensaje} {job.progreso?.total ? ` · ${job.progreso.total} configuraciones` : ""} {job.desde_cache ? " · resultado en caché" : ""} {job.en_espera > 0 ? ` · ${job.en_espera} trabajo(s) por delante` : ""} {job.duracion != null ? ` · ${job.duracion}s` : ""}

)} {r && ( <>
Mejor modelo
{r.mejor.modelo}
entre {r.configuraciones_probadas} configuraciones de 6 familias
MAPE (validación)
{r.mejor.mape}%
naive estacional: {r.naive?.mape}% ·{" "} {r.gana_al_naive ? "le gana al naive" : "NO le gana al naive"}
Proyectado {mesLegible(r.meses[0])} – {mesLegible(r.meses.at(-1))}
{nf.format(totalProyectado ?? 0)}
{r.horizonte} meses proyectados
MASE
{r.mejor.mase}
<1 significa mejor que el naive estacional

Proyección de {r.filtros.tipo} hasta {mesLegible(r.meses.at(-1))}

Entrenado con {r.historico.length} meses (2017-01 a {mesLegible(r.mes_corte_usado)}). La banda es el rango donde cayó el 80% de los errores del modelo en la validación.

Observado Proyectado ▨ rango 80%
{r.cierre_anual && (

Cómo cerraría {r.cierre_anual.anio}, comparado con los años anteriores

{r.cierre_anual.meses_observados} meses ya registrados por SINADEF (hasta {mesLegible(r.cierre_anual.mes_corte)}) más {r.horizonte} meses proyectados. Mismos filtros de arriba.

Ya registrado Proyectado
Cierre estimado {r.cierre_anual.anio} {nf.format(r.cierre_anual.cierre)} {r.cierre_anual.rango_lo && ( rango 80%: {nf.format(r.cierre_anual.rango_lo)} – {nf.format(r.cierre_anual.rango_hi)} )}
{r.cierre_anual.vs_anterior && (
vs {r.cierre_anual.vs_anterior.anio} = 0 ? "up" : "down"}`}> {r.cierre_anual.vs_anterior.pct >= 0 ? "+" : ""} {r.cierre_anual.vs_anterior.pct}% {r.cierre_anual.vs_anterior.anio} cerró en {nf.format(r.cierre_anual.vs_anterior.valor)}
)} {r.cierre_anual.vs_promedio && (
vs promedio de años previos = 0 ? "up" : "down"}`}> {r.cierre_anual.vs_promedio.pct >= 0 ? "+" : ""} {r.cierre_anual.vs_promedio.pct}% promedio: {nf.format(r.cierre_anual.vs_promedio.valor)}
)}

Si el cierre real queda por debajo de este rango, la baja no se explica por la tendencia previa. Si queda dentro, el año se comportó como venía comportándose.

)}

Proyección mes a mes

{r.bandas?.p10 && } {r.meses.map((m: string, i: number) => ( {r.bandas?.p10 && ( )} ))} {r.bandas?.p10 && ( )}
Mes ProyectadoRango 80%
{mesLegible(m)} {nf.format(Math.round(r.central[i]))} {nf.format(Math.round(r.bandas.p10[i]))} – {nf.format(Math.round(r.bandas.p90[i]))}
Total {nf.format(totalProyectado ?? 0)} {nf.format(Math.round(r.bandas.p10.reduce((s: number, v: number) => s + v, 0)))} –{" "} {nf.format(Math.round(r.bandas.p90.reduce((s: number, v: number) => s + v, 0)))}

Mejor configuración por familia

Ordenadas por {r.criterio.toUpperCase()} en la validación.

{r.por_familia.map((f: any) => ( ))}
Familia MAPE MASE RMSE
{f.modelo === r.mejor.modelo && ( )} {f.modelo} {f.mape}% {f.mase} {f.rmse}

Resumen técnico

Serie
{r.filtros.tipo} · sexo {r.filtros.sexo} · departamento {r.filtros.departamento} · lugar {r.filtros.lugar}
Entrenamiento
{r.historico.length} meses, de {mesLegible(r.historico[0].mes)} a{" "} {mesLegible(r.mes_corte_usado)} {r.meses_excluidos > 0 && ` (se excluyeron los ${r.meses_excluidos} meses finales por registro tardío)`}
Validación
Rolling origin: {r.validacion.n_origenes} ventanas de {r.validacion.ventana_meses}{" "} meses ({r.validacion.meses_evaluados} meses evaluados), con orígenes entre{" "} {mesLegible(r.validacion.primer_origen)} y {mesLegible(r.validacion.ultimo_origen)}. Cada configuración se reajusta desde cero en cada ventana.
Búsqueda
{r.configuraciones_probadas} configuraciones probadas ({r.configuraciones_validas}{" "} convergieron) sobre SARIMA, ETS, Theta, Prophet, XGBoost y naive estacional.
Criterio de selección
{r.criterio.toUpperCase()} promedio entre ventanas. {r.hay_ceros ? " La serie tiene meses en cero, por lo que el MAPE no es confiable y se decidió por MASE." : " La serie no tiene ceros, así que el MAPE es válido."}
Modelo elegido
{r.mejor.modelo} {JSON.stringify(r.mejor.params)} — MAPE {r.mejor.mape}%, sMAPE{" "} {r.mejor.smape}%, MASE {r.mejor.mase}, RMSE {r.mejor.rmse}. Reentrenado con la serie completa antes de proyectar.
Referencia
Naive estacional (repetir los últimos 12 meses): MAPE {r.naive?.mape}%, MASE{" "} {r.naive?.mase}.{" "} {r.gana_al_naive ? "El modelo elegido lo supera, así que aporta información por encima de repetir el año pasado." : "El modelo elegido NO lo supera: para esta serie conviene desconfiar de la proyección."}
Intervalos
Empíricos, construidos con los errores relativos observados en la validación. Se usan en lugar de los intervalos analíticos de cada modelo porque solo así son comparables entre familias.
Advertencia
La proyección supone que las condiciones se mantienen. No incorpora cambios de política, intervenciones ni shocks. Los meses recientes de SINADEF siguen recibiendo registros con retraso, lo que tiende a deprimir el final de la serie de entrenamiento.
)} {!job && !error && (
Elige los filtros y el mes de corte, y ejecuta la proyección. Se evalúan ~46 configuraciones de 6 familias de modelos con validación rolling origin sobre toda la serie desde 2017. Toma alrededor de 30 segundos.
)} ); } /* ── Repositorio compartido de proyecciones ───────────────────────────── */ function Historial({ recargar }: { recargar: boolean }) { const [datos, setDatos] = useState(null); const [abierto, setAbierto] = useState(false); useEffect(() => { if (!abierto) return; fetch("/api/forecast-historial?limite=40") .then((r) => r.json()) .then(setDatos) .catch(() => {}); }, [abierto, recargar]); return (
setAbierto((e.target as HTMLDetailsElement).open)}> Proyecciones ya calculadas {datos ? ` (${datos.guardadas})` : ""}
{!datos ? (

Cargando…

) : ( <>

Repositorio compartido: cualquier persona que repita una de estas combinaciones la recibe al instante, sin volver a calcularla. Las marcadas como desactualizada se hicieron con una versión anterior de la base; se conservan como registro de qué se proyectó y cuándo, pero al pedirlas de nuevo se recalculan desde cero. {datos.segundos_de_computo_acumulados > 0 && ( <> Cómputo acumulado guardado: {Math.round(datos.segundos_de_computo_acumulados)} s. )}

{datos.proyecciones.map((p: any, i: number) => ( ))}
Calculada Filtros Corte Modelo MAPE Cierre Estado
{p.creado_en.replace("T", " ").slice(0, 16)} {[p.tipo, p.sexo, p.departamento, p.lugar] .filter((v) => v && !v.startsWith("(tod")) .join(" · ") || "sin filtros"} {mesLegible(p.mes_corte)} {p.modelo} {p.mape}% {p.cierre != null ? nf.format(Math.round(p.cierre)) : "—"} {p.vigente ? "vigente" : "desactualizada"}
)}
); }