Saltar a contenido

Contrato de datos (consumido)

El contrato lo posee y congela nexrad-l3-pipeline: schema Postgres en db/pg_migrations/ de ese repo (migrado de D1, decisión 38 — db/migrations/ queda como referencia histórica) + convención de claves R2. Este proyecto lo lee y no lo cambia; cualquier cambio necesario es una migración negociada del lado del pipeline. Los contract tests de este repo (F1) asertan las formas de las que el viewer depende y fallan CI ante drift.

Esta página es la vista del consumidor: qué lee el viewer de cada tabla y qué asume. La fuente de verdad es el SQL del pipeline.

Convenciones globales

  • Timestamps TEXT ISO-8601 UTC sin sufijo de zona (YYYY-MM-DDTHH:MM:SS), comparables lexicográficamente.
  • Retención: 72 h — el viewer nunca asume historia más allá de 3 días.
  • Calibración raster: físico = nivel · value_scale + value_offset para niveles ≥ 2; nivel 0 = below threshold (nodata), 1 = range folded.

Postgres

radars — catálogo dinámico

Columna Uso en el viewer
site_id id de 3 chars del feed (AMX, JUA); clave de todas las consultas
icao display (KAMX, TJUA) cuando existe — observado null en todo el feed del demo (jul-2026): el viewer cae a site_id
lat, lon centrado de vista, círculo de cobertura, cálculos VWP
height_m altitud de la antena en metros sobre el nivel del mar (ver aviso abajo)
proj4 definición AEQD registrada tal cual con proj4.defs — el viewer no construye proyecciones
last_seen_at frescura ("minutos desde el último scan"), semáforo de estado por radar

Sin radares hardcodeados en ninguno de los dos proyectos: lo que hay en la tabla es lo que el viewer ofrece.

height_m cambió de unidad el 2026-11-05 (mismo nombre, otros valores)

El PDB del Level III trae la altura de antena en pies (ICD 2620001) y el pipeline la guardaba sin convertir: la columna decía metros y contenía pies (issue #8 del pipeline, arreglado + migración de datos 0003_radar_height_feet_to_m.sql). Desde esa fecha los valores son metros de verdad y las filas viejas quedaron convertidas. Sin cambio de schema: no hay drift que detectar, solo valores ~3,28 veces menores. Las fixtures grabadas (server/dal/fixtures/radars.json) ya están en metros, y zRadarRow acota height_m a [-100, 4000] como guardia de regresión — ningún WSR-88D pasa de ~3250 m, así que una vuelta a pies rompe CI en los radares de montaña (no en los costeros: 111 pies son 111 m plausibles).

El viewer hoy solo transporta la columna (DAL → tipo); ningún componente calcula con ella. Si algún día se usa para altura de haz o corrección de terreno, la unidad es metros.

products — descriptores mínimos

code (NEXRAD, p.ej. 153), mnemonic (N0B), unit, kind (raster | phenomena | vwp). Nombre display, categoría, rango y paleta no están aquí: viven en el catálogo estático del viewer keyed por code (decisión 5).

rasters — metadata de cada COG

Consultas del viewer: lista de datetimes por (site_id, product_code, día), más-cercano / siguiente / anterior a un instante (sobre vol_time, índice idx_rasters_lookup). Columnas usadas: r2_key (→ URL del COG), vol_time, value_scale/value_offset, max_level (recorte de leyenda), width/height/cell_m (extensión), el_angle, vcp.

phenomena — celdas, meso, TVS, granizo

Una fila por (site, vol_time, kind, cell_id) con lat/lon, azimuth_deg/range_km y attrs JSON. Consultas del viewer: por (site_id, vol_time) para el overlay del frame mostrado, por (site_id, cell_id) cross-volumen para las series de tendencia de los charts (el cell_id del RPG es estable entre volúmenes), y DISTINCT vol_time por (site_id, día UTC) como índice del join temporal (los fenómenos tienen vol_times propios; el frame mostrado se casa en cliente con el volumen más cercano — decisión 24).

Claves de attrs (parte del contrato)

Estado observado en el feed real (grabación de fixtures, jul-2026). La tabla canónica vive en db/README.md del pipeline; los tipos + parsers tolerantes del viewer en shared/contract/attrs.ts (stormCellAttrs()/mesoAttrs() — toda clave es opcional, un campo corrupto se descarta sin tumbar la fila):

kind Clave Contenido
storm_cell azran_nm [az_deg, range_nm] posición radar-céntrica
storm_cell movement_deg, movement_kt vector de movimiento (movement_deg convención "desde")
storm_cell new celda nueva en este volumen (sin tracks)
storm_cell past, forecast arrays de posiciones [[x_km, y_km], …] AEQD de los packets 23/24 (SCIT); past reciente→viejo, forecast cercano→lejano — semántica deducida de las grabaciones, pendiente de confirmar con el experto (puerta M4; test canario en tests/unit/tracks.spec.ts)
storm_cell dbz_max, dbz_max_height_kft reflectividad máxima y su altura, del GAB de NST — el GAB pagina de a 6 celdas: puede faltar
meso radius_km, azran_nm, storm_id geometría del mesociclón y cell_id de la celda NST asociada (el cell_id de la fila meso es el ID del mesociclón, no la celda)
meso strength_rank, msi, tvs rango de intensidad, MSI, flag TVS (bool) del tabular NMD — tvs es LA señal TVS del feed
meso low_level_rv_kt, low_level_dv_kt, base_kft, depth_kft, depth_stmrel_pct, max_rv_kft, max_rv_kt, movement_deg, movement_kt resto del tabular NMD

Fuera de alcance — el feed no lo distribuye (acordado con el pipeline, jul-2026): vil_kg_m2, top_kft (producto SS/62) y poh_pct/posh_pct/hail_size_in (HI/59) — esos productos no fluyen en el bucket de Unidata; el NST no los trae. VIL y echo top sí existen como rasters de grilla (DVL, EET). Si algún día aterrizan, attrs.ts suma claves opcionales sin migración. Las claves de la tabla están presentes en las fixtures grabadas (server/dal/fixtures/phenomena.json) que los contract tests y tests/unit/attrs.spec.ts validan.

vwp — perfiles de viento

Una fila por (site_id, vol_time, height_ft): wind_dir_deg, wind_speed_kt, rms_kt. El viewer deriva u/v en cliente; no hay componente vertical w en el contrato. Consultas: por (site_id, vol_time) para el perfil, y DISTINCT vol_time por (site_id, día UTC) como índice del join temporal (decisión 24).

wind_grids — viento GFS 10 m en grilla

Una fila por (site_id, valid_time, level): cycle_time, forecast_hour, model, r2_key → JSON u/v en R2 (gzip + immutable desde el edge — el ciclo y el nivel van en la key). Consulta del viewer: por (site_id, nivel, día UTC ± 2 h) como índice del join temporal (padding mayor que phen/vwp porque la tolerancia es 1 h). Schema del pipeline: db/pg_migrations/0001_init.sql (snapshot en tests/contract/schema/, drift check activo). Spec completa (keys R2, formato del fichero, cron) en Spec pipeline viento.

R2

  • Convención de claves: {site}/{mnemo}/{YYYY}/{MM}/{DD}/{site}_{mnemo}_{YYYYMMDD_HHMMSS}.tif — pero el viewer no construye claves: usa rasters.r2_key literal.
  • Desde 2026-11-05 el pipeline admite un prefijo opcional de instancia (R2_KEY_PREFIX, vacío en producción) que antepone {prefijo}/ a toda clave R2 — rasters, mosaico, viento y rayos — para que varias instancias compartan bucket (LAMULA Jobs: una por job). Nada que cambiar aquí, justamente porque el viewer usa r2_key literal y arma la URL como ${base}/${r2_key}; el prefijo viaja dentro del valor. El único efecto visible es que r2_key puede no empezar por el site_id ni, en el mosaico, por mosaic/ — no asumir ninguna de las dos cosas.
  • COG: niveles crudos uint8, scale/offset también embebidos en el TIFF, CRS proj4 AEQD, overviews internos. Peor caso 3680×3680 (N0B) — bajo el cap de textura WebGL de 4096.
  • Acceso: lectura pública (dominio custom o r2.dev) con CORS habilitado para el origen del viewer y soporte de range requests (nativo en R2). El viewer descarga solo tiles/overviews del viewport.

Requisitos de setup fuera del código (una vez)

  1. CORS en el bucket R2 para el origen público del viewer (https://webviewer.ladetec.com) y localhost:3000 para desarrollo. Verificar tras la salida de Cloudflare Pages: la allowlist se escribió con los orígenes *.pages.dev; si el dominio del Swarm no entra en ella, todo cog_url falla por CORS en producción y el mapa queda sin raster.
  2. Acceso público de lectura al bucket (dominio custom recomendado sobre r2.dev).
  3. Credenciales Postgres (NUXT_PG_HOST/NUXT_PG_PORT/NUXT_PG_DATABASE/NUXT_PG_USER/NUXT_PG_PASSWORD) del contenedor del viewer apuntando a la misma base que usa el pipeline — mismo Swarm, red interna, sin binding.
  4. DNS del dominio del viewer (webviewer.ladetec.com) apuntado (orange-cloud) al reverse proxy del Swarm.
  5. NUXT_PUBLIC_I18N_BASE_URL=https://webviewer.ladetec.com en el entorno del contenedor del viewer. Es variable de arranque, no de build (decisión 52): sin ella el SSR avisa en cada petición y las páginas salen sin canonical ni hreflang, que es lo que hace útil el prefijo /en/.

Puntos de coordinación abiertos con el pipeline

  1. ~~Extensión de attrs de NST~~ — cerrado jul-2026: tracks (packets 23/24), dbz_max y el tabular NMD completo fluyen; VIL/top/granizo quedan fuera de alcance (el feed no distribuye SS/HI — barrido verificado por el pipeline).
  2. radars.icao llega null en todo el feed del demo — si el pipeline puede mapear ICAO desde su config, el display mejora sin tocar el viewer.
  3. ~~Documentar claves de attrs en el pipeline~~ — hecho: tabla canónica en db/README.md de aquel repo.
  4. ~~Acceso público de lectura + CORS del bucket R2 y NUXT_PUBLIC_R2_BASE_URL~~ — hecho: bucket con CORS (GET/HEAD + Range, dominio custom + localhost:3000), cog_url resuelve.
  5. Confirmar con el experto la semántica de past/forecast/movement_deg (orden y convención "desde") — parte de la puerta M4.
  6. ~~Viento GFS 10 m~~ — cerrado jul-2026: el pipeline mergeó 0003_wind_grids.sql e ingesta activa; CORS (localhost:3000, pages.dev) y gzip del edge verificados contra el dominio custom el 2026-07-18. Queda del lado del viewer: re-grabar fixtures (incl. wind.json real + bajar los JSON u/v golden) en la próxima re-grabación completa.

mosaic_domains / mosaic_domain_sites / mosaic_rasters — mosaico multi-radar

Compuesto multi-radar producido por el pipeline (0002_mosaic.sql, decisión 43). El viewer lo lee como un producto más, con tres diferencias que importan.

Rejilla temporal propia. slot_time son slots UTC fijos de 300 s, no los vol_time de ningún radar. Las consultas del viewer son las mismas que para rasters (día, más-cercano/siguiente/anterior) pero sobre esa columna.

Geometría por fila. mosaic_rasters trae proj4/width/height/cell_m además de mosaic_domains: añadir un radar a un dominio recalcula la malla, y un COG anterior tiene que seguir georreferenciándose con la suya. mosaic_domains describe la malla actual (para montar la vista); cada fila describe la suya (para pintar ese COG).

contributing es parte del contrato. JSON [{"site","vol_time","lag_s"}] con los radares que entraron en ese slot y su desfase contra el centro (negativo = volumen anterior al centro). Un slot se compone con los radares que haya dentro de tolerancia — un radar caído no apaga el mosaico — así que la cobertura varía entre frames. mosaic_domain_sites da los miembros del dominio; la resta son los ausentes, y eso es lo que pinta el overlay de cobertura. Sin ello, un hueco en el mapa es indistinguible de "no hay ecos".

Columna Uso en el viewer
domain_id identidad del dominio; no es un site_id (ni 3 chars ni vive en radars)
product_code código NEXRAD reusado — shared/products/<code> y su paleta valen sin cambios
slot_time, slot_s timeline del mosaico
r2_key → URL del COG (prefijo mosaic/)
value_scale, value_offset, max_level calibración canónica del producto, fija entre slots
proj4, width, height, cell_m geometría de ese COG
method weighted | lowest_beam
contributing procedencia del slot

N0G nunca aparece: la velocidad radial es relativa al radar y no es componible. Detalle del cálculo en docs/mosaico.md del pipeline.