Saltar a contenido

Validaciones manuales

Cada fase tiene una puerta automática (tests/CI) y, en varias, una parte manual que no se puede automatizar. Esta página es el checklist de esa parte manual: qué hacer, con qué comandos, y qué tiene que verse para dar la fase por buena.

Convención: ✅ = criterio de aceptación. Si algo no se cumple, la fase no se cierra.

F1 — COG en QGIS

Los tests ya garantizan estructura (CRS, geotransform, overviews, calibración). QGIS valida lo que los tests no ven: que el raster cae donde debe en el mundo real y que un GIS de referencia interpreta el fichero sin ayuda.

Generar COGs de prueba desde las muestras del repo:

uv run l3proc process tests/data/AMX_N0B_2026_07_06_15_45_17 -o /tmp/cogs
uv run l3proc process tests/data/JUA_N0B_2026_07_06_15_43_47 -o /tmp/cogs

En QGIS:

  1. Cargar un basemap de referencia: menú XYZ Tiles → OpenStreetMap (arrastrar al lienzo).
  2. Arrastrar AMX_N0B_20260706_154517.tif al lienzo. QGIS debe aceptar el CRS sin preguntar (lo lee del fichero; aparece como proyección AEQD custom).
  3. ✅ El disco del radar queda centrado sobre Miami (KAMX está al suroeste del área urbana) y el diámetro abarca ~920 km (South Florida + Bahamas + Cuba occidental). Para TJUA: centrado en Puerto Rico.
  4. ✅ Los ecos coinciden con costa/geografía de forma plausible (celdas convectivas sobre tierra/mar, no desplazadas cientos de km ni rotadas).
  5. Herramienta Identify (Ctrl+Shift+I) sobre un eco: el valor de banda 1 es el nivel crudo (0–255). ✅ QGIS muestra el valor físico si se activa "aplicar scale/offset", o manualmente: dBZ = nivel × 0.5 − 33 (para N0B). Un eco fuerte debe dar 45–60 dBZ, no valores absurdos.
  6. ✅ Zoom out fluido: los overviews internos responden (no re-lee el raster completo a cada zoom).
  7. Propiedades de capa → Information: ✅ CRS +proj=aeqd +lat_0=<lat radar> +lon_0=<lon radar>, tamaño 3680×3680, resolución 250 m, NoData 0, metadatos SITE/PRODUCT/VOL_TIME/VCP presentes.

F2 — Cloudflare: setup y verificación R2↔D1

Setup una sola vez

  1. Crear el bucket R2 (dashboard o wrangler r2 bucket create nexrad-l3) y un API token R2 (Object Read & Write, scoped al bucket) → R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY + endpoint https://<account_id>.r2.cloudflarestorage.com.
  2. Crear la base D1: wrangler d1 create nexrad-l3. Guardar el database_id.
  3. Copiar los database_id en db/wrangler.jsonc y aplicar migraciones desde db/: cd db && npx wrangler d1 migrations apply nexrad-l3 --remote (detalle en db/README.md).
  4. Crear un API token de cuenta con permiso D1 — EditCLOUDFLARE_API_TOKEN.
  5. Para el test de integración D1 en CI: crear una segunda base nexrad-l3-test (para que CI no ensucie la real), aplicarle las mismas migraciones, y añadir en GitHub los secrets D1_TEST_DATABASE_ID y CLOUDFLARE_D1_API_TOKEN (token con D1 — Edit; nombre distinto del CLOUDFLARE_API_TOKEN de Pages, que solo tiene permiso de deploy). CLOUDFLARE_ACCOUNT_ID ya existe del setup de docs. Sin estos secrets, los tests de integración D1 se saltan solos (CI sigue verde).

Verificación de la puerta

Publicar una muestra real end-to-end:

export R2_ENDPOINT=... R2_BUCKET=nexrad-l3 R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=...
export CLOUDFLARE_ACCOUNT_ID=... D1_DATABASE_ID=... CLOUDFLARE_API_TOKEN=...
uv run l3proc process tests/data/AMX_N0B_2026_07_06_15_45_17 -o /tmp/cogs --publish
  1. ✅ En el dashboard R2 (o wrangler r2 object get): existe AMX/N0B/2026/07/06/AMX_N0B_20260706_154517.tif y su tamaño coincide con el fichero local.
  2. ✅ En D1 (wrangler d1 execute nexrad-l3 --remote --command "SELECT * FROM rasters ORDER BY id DESC LIMIT 1"): la fila apunta a esa misma clave R2, con size_bytes igual al tamaño del objeto y vol_time correcto.
  3. SELECT * FROM radars muestra el radar AMX con lat/lon/proj4 poblados dinámicamente (sin haberlo insertado a mano).
  4. ✅ Descargar el objeto R2 y abrirlo en QGIS: idéntico al COG local (la subida no corrompe).

F3 — Replay e2e local

El injector y el script de verificación son automáticos; lo manual es lanzarlo y leer el resultado:

  1. Arrancar el servicio watcher local apuntando a un directorio vacío.
  2. Correr el injector con 20 productos recientes del bucket público.
  3. ✅ El script de verificación reporta: 20 COGs en R2, 20 filas en D1, backlog vacío, 0 ficheros en el directorio de errores.
  4. Matar el watcher a mitad de una tanda y relanzarlo: ✅ los ficheros pendientes se procesan al arrancar (no se pierden productos entre reinicios).

F4 — Poller + Swarm

Setup una vez: crear los secrets de Swarm (comandos en la cabecera de docker-compose.yml) y tener el .env con las variables no-secretas (R2_ENDPOINT, R2_BUCKET, CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID, opcional NEXRAD_SITES).

  1. Deploy: set -a; source .env; set +a; docker stack config -c docker-compose.yml | docker stack deploy -c - nexrad. ✅ docker service ls muestra poller y procesador 1/1; docker ps los marca (healthy) tras el primer minuto.
  2. ✅ En logs del poller (docker service logs nexrad_poller): líneas poll: SITE_N0B_... entrando cada pocos minutos por sitio.
  3. ✅ En logs del procesador: cada producto → r2://... en ~2–3 s; los ficheros del volumen compartido desaparecen tras procesarse (backlog ~0).
  4. Puerta 24 h: dejar el stack corriendo un día. ✅ El monitor de frescura (o consulta manual a D1: SELECT site_id, MAX(vol_time) FROM rasters GROUP BY site_id) confirma rasters < 30 min de antigüedad para los 3 sitios durante todo el período (los huecos de 4–10 min entre volúmenes son normales; > 30 min no).
  5. Reinicio de nodo o docker service update --force nexrad_poller: ✅ ambos servicios vuelven solos, el watermark evita re-descargar historia y el flujo se recupera sin intervención.

F5 — Retención y alertas

Setup: bot de Telegram existente. Retención y monitor corren en el Worker de Cloudflare nexrad-l3-ops (workers/ops/; originalmente eran servicios del stack Swarm, migrados el 2026-07-10 para que el monitor sobreviva a la caída del VPS): sweep al minuto 17 de cada hora, monitor cada 5 min con umbral 30 min. Secrets de Telegram vía wrangler secret put (sin ellos el monitor queda en modo solo-log). Logs: npx wrangler tail nexrad-l3-ops.

  1. Insertar a mano en D1 un raster con vol_time > 72 h apuntando a un objeto R2 real (o esperar 3 días de operación): ✅ la siguiente pasada del sweep lo borra de R2 y D1 (en el tail del Worker: sweep: ... rasters=N).
  2. Borrar a mano una fila D1 de un raster vigente: ✅ la reconciliación reporta el objeto R2 huérfano en el log (reconcile: N huérfanos R2 ...) y con --fix lo borra.
  3. Borrar a mano un objeto R2 vigente: ✅ la reconciliación reporta la fila D1 colgante (y con --fix la borra).
  4. Parar el procesador (docker service scale nexrad_processor=0): ✅ en < ~35 min llega alerta Telegram 🔴 <sitio>: sin datos frescos.
  5. Rearrancarlo (docker service scale nexrad_processor=1): ✅ llega 🟢 <sitio>: recuperado cuando el sitio vuelve a verde.

F6 — Productos restantes y fenómenos

  1. Golden tests automáticos por producto; manual: abrir en QGIS un COG de cada nuevo producto raster (N0G/EET/DVL/DAA/DU3/DTA) y repetir el checklist de F1. Calibración plausible por unidad declarada: velocidad ±64 kt, topes 0–70 kft, VIL 0–80 kg/m², precip en mm. Ojo N0G: nivel 1 = range folded (violeta en paletas NWS), esperable en anillos lejanos.
  2. Fenómenos con caso real de tormenta (NST/NMDNHI/NTV no fluyen en el bucket, ver Productos): ✅ filas en phenomena con lat/lon dentro del área del radar, atributos coherentes (RV/DV en kt, MSI, flag tvs), celdas con ID estable entre volúmenes consecutivos. (Verificado 2026-07-10 con la tormenta de ICT/Wichita: 38 celdas + 5 mesos en la D1 real.)
  3. vwp poblado con perfiles a alturas crecientes y direcciones 0–360. (Verificado: 13 niveles 400–6000 ft de AMX.)
  4. Cruce visual: cargar el COG de reflectividad del mismo volumen en QGIS y los fenómenos como capa de puntos (exportar query a CSV → capa de texto delimitado). ✅ Los marcadores de meso/celda caen sobre o junto a los núcleos de eco fuerte.

Viento GFS — Worker nexrad-l3-wind

Setup: migración 0003_wind_grids.sql aplicada; Worker nexrad-l3-ops redesplegado antes (su reconciliación debe conocer wind_grids, o borra los JSON WIND/ como huérfanos); después npx wrangler deploy desde workers/wind/. Logs: npx wrangler tail nexrad-l3-wind.

  1. Sin red: npm test en workers/wind/ ✅ decoder GRIB2 reproduce el golden de eccodes sobre un subset real del filtro.
  2. Tras 2–4 corridas del cron (backfill capeado a MAX_FETCHES): ✅ SELECT site_id, COUNT(*), MIN(valid_time), MAX(valid_time) FROM wind_grids GROUP BY site_id muestra valid_times horarios continuos acercándose a la ventana de 72 h, con MAX(valid_time) ≥ now (lookahead 2 h).
  3. Validación cruzada contra la referencia Python (eccodes): uv run python scripts/validate_wind_worker.pytodo consistente (header exacto, u/v ≤ 0.011 m/s).
  4. En el navegador (o curl) un r2_key reciente: ✅ JSON válido, u.length === v.length === nx*ny, CORS OK desde el viewer.
  5. Cuando NOMADS publique un ciclo nuevo (~3.5–5 h tras 00/06/12/18Z): ✅ los valid_times solapados pasan al ciclo nuevo con forecast_hour menor y los objetos viejos desaparecen del bucket (tail: publicados=N).
  6. Corrida siguiente sin datos nuevos: ✅ publicados=0 (idempotencia).

Rayos GLM — Worker nexrad-l3-lightning

Setup (orden importa): migración 0004_lightning_buckets.sql aplicada → nexrad-l3-ops redesplegado (sweep/reconciliación/monitor con lightning_buckets; si no, la reconciliación borra los JSON LIGHTNING/ como huérfanos) → npm install && npx wrangler deploy desde workers/lightning/. Logs: npx wrangler tail nexrad-l3-lightning.

Pre-validado en local 2026-07-19 (D1/R2 locales de wrangler + GLM vivo): 5 cubos × 3 sitios, filas con strike_count 0 y r2_key NULL en JUA, JSON de AMX con 207 strikes = strike_count, offsets ascendentes en [0, 300), distancia máx 458.6 ≤ 460 km, tercera corrida sin escrituras. Contra producción:

  1. Sin red: npm test en workers/lightning/ ✅ (cubos, claves, frontera, recorte, formato).
  2. Tras ~30 min del deploy: SELECT site_id, COUNT(*), MIN(bucket_start), MAX(bucket_start) FROM lightning_buckets GROUP BY site_id → cubos de 300 s continuos por sitio (fila presente aun con 0 rayos); tras el backfill horario, acercándose a 72 h.
  3. Un r2_key no nulo reciente por GET público: JSON válido según contrato, strikes.length === strike_count, offsets ascendentes en [0, 300), todos los puntos ≤ 460 km, CORS OK desde el viewer.
  4. Tail de una corrida sin cubos nuevos: sin escrituras (idempotencia).
  5. Puerta del experto (tipo M4): tormenta activa visible en el raster de un sitio produce cubos con strike_count > 0 en la misma zona/hora del volumen.
  6. Monitor: al primer cubo ingerido, 🩺 con claves SITE:ltg en Telegram; parar el Worker > 30 min debe dar 🔴 y reanudarlo 🟢.