Formatos soportados¶
Estructura de test-fixtures/¶
test-fixtures/
├── observations/ # SOLO datos crudos reales (lo que un parser real recibe)
│ ├── insmet/ # .obs (Camagüey)
│ ├── nexrad-l2/ # .gz (KMLB, Archive II)
│ └── rainbow/ # .vol (bandaS = HNS/Tegucigalpa, bandaX = HNX/La Ceiba)
└── reference/ # specs, decoders/simuladores de referencia, no son observaciones
├── insmet/ # Obs_Parser.py original + nuestro port verificado
├── nexrad-l2/ # ICD, simulador RDA 2013, referencia libL2 de NOAA, nuestro decoder verificado
└── rainbow/ # nuestro decoder verificado (rainbow_probe.py)
Gap de código fuente¶
legacy/ no contiene el código real de los traductores/parsers. Unidades
referenciadas por Observation.pas y Shell_Process.pas pero ausentes del
repo clonado: Translator.pas, Translators.pas, Nexrad_Translator.pas,
Nexrad_File.pas, tsqBZip2.pas, Movement.pas, Plane.pas,
Description.pas, Measure.pas, Angle.pas. Confirmado con find +
git log --all (un solo commit squash, nunca estuvieron). No existe repo
hermano en el org LADETEC-CU con estas unidades.
Consecuencia: no hay lógica original que portar para los parsers. Implementación contra especificación pública de cada formato.
Formato 1 — Rainbow 5.0 (Gematronik/Leonardo-Selex)¶
- Extensión
.vol, contenedor XML + blobs binarios (no XML puro). - Confirmado en
Leeme_Process.txtcronología 5.3.4.7, 5.3.13.1, 5.3.16.1, 5.3.17.1-5.3.17.3 ("Rainbow5 Translator", "gematronik"). - Sin código fuente legacy vendido:
Process.dprreferenciaRainbow5_Translator.pas/Rainbow5_File.paspero ninguno de los dos archivos está enlegacy/(mismo gap que Nexrad, ver arriba). Oráculo usado en su lugar: xradar (xradar/io/backends/rainbow.py), cruzado contra bytes reales. - Sensible a locale (separador decimal) — bug documentado en el original, no reintroducirlo.
Material recibido, organizado en:
test-fixtures/observations/rainbow/bandaS/— volumen real S-band, sitioHNS/Tegucigalpa (Honduras), 15 elevaciones (0.0°-30.0°), un archivo por momento (dBZ,dBuZ,V,W).test-fixtures/observations/rainbow/bandaX/— volumen real X-band, sitioHNX/La Ceiba (Honduras), 4 elevaciones (1.3°-5.0°), un archivo por momento (dBZ,dBuZ,V,W,RhoHV,uPhiDP).test-fixtures/reference/rainbow/rainbow_probe.py— nuestro decoder Python 3 stdlib-only, verificado contra los 10 archivos (0 fallos de descompresión/layout, rangos físicos plausibles en cada uno).
Verificado de verdad (bytes reales, no solo la lectura de xradar):
corrí rainbow_probe.py contra los 10 archivos completos.
Layout confirmado:
| Bloque | Notas |
|---|---|
| Header XML | un único <volume> raíz, termina en la línea literal <!-- END XML -->. volume > scan (uno por archivo) y volume > sensorinfo (hermano de scan, no anidado — confirmado por posición de bytes real) con lon/lat/alt/wavelen/beamwidth. |
| Blobs binarios | tras el marcador, secuencia plana: <BLOB blobid="N" size="S" compression="qt">\n<S bytes>\n</BLOB>\n. blobid es un contador único para todo el archivo (no se reinicia por slice/elevación): slice 0 usa 0,1,2; slice 1 usa 3,4,5; etc. Hay un \n literal después del > de apertura, uno antes de </BLOB>, y uno más entre </BLOB> y el siguiente <BLOB> — los tres confirmados byte a byte. |
Compresión qt |
primeros 4 bytes del payload = tamaño sin comprimir (u32 big-endian), el resto es un stream zlib crudo (zlib.decompress). Ningún otro valor de compresión visto en los fixtures. |
Por <slice refid="N"> (una por elevación) |
posangle = ángulo de elevación en grados. slicedata contiene 2× <rayinfo refid="startangle"/"stopangle"> (ángulo por rayo, u16, único depth=16 visto en rayinfo) + 1× <rawdata type="..." rays bins min max depth blobid> (dato del momento). |
Decode rayinfo (ángulo) |
grados = raw * 360.0 / 65536.0 — divide por 2**depth, no por 2**depth-2. Confirmado: la secuencia decodificada avanza ~1.0° entre rayos, igual al anglestep real del volumen. |
Decode rawdata (momento) |
u8 (depth=8: dBZ/dBuZ/V/W/RhoHV) o u16 (depth=16: uPhiDP, único caso visto). raw=0 = sin dato/bajo umbral. Para raw>=1: scale=(vmax-vmin)/(2**depth-2), físico = vmin + (raw-1)*scale — no la fórmula ingenua vmin + raw*scale (corre todo medio paso). Confirmado empíricamente: el raw==1 real más bajo de un sweep mapea EXACTO al vmin declarado del slice, a 3+ decimales — coincide con la fórmula de xradar (scale_factor=(vmax-vmin)/(2**depth-2), add_offset=vmin-scale_factor, raw*scale_factor+add_offset, algebraicamente idéntica). |
Sin resolver: @depth sub-byte (ej. 6-bit) y el bit-unpacking asociado
— ningún fixture lo usa, no verificado con bytes reales. Valores de
compression distintos de "qt" — no vistos. Ambigüedad raw==1 vs
mínimo físico real cuando vmin=0 (ej. RhoHV) — mismo tipo de ambigüedad
que en .obs/Level II, no resuelta aquí tampoco.
Formato 2 — NEXRAD Level II (Archive II)¶
- Ya presente en el original, no es feature nueva:
Shell_Process.pasimportaNexrad_File/tsqBZip2, comentarioNexrad .ar2.bz2 -> vcp 11, instanciaTNexradMessage.Create(Obs, MS), nombres de sitio reales (KMLB,CCMW/Camagüey,CCSB). - Desconocido si el original soporta también Level III/NIDS — sin evidencia ninguna forma.
Material recibido, organizado en:
test-fixtures/observations/nexrad-l2/— 3 archivos reales de Level II del radar KMLB (Melbourne, FL),.gz(envoltorio NCDC, no LDM tiempo-real). Esto es lo único que un parser real recibe como input.test-fixtures/reference/nexrad-l2/RDA_RPG_2620002P.pdf— ICD oficial NOAA/ROC ("Interface Control Document for the RDA/RPG").test-fixtures/reference/nexrad-l2/libl2_reference/— subset del decoder de referencia en C de NOAA ROC (libl2.c,parse_ldm_file.c,testlibl2.c, man page); depende de headers del SDK ORPG no incluidos, no compila standalone, sirve solo de referencia de structs/lógica. Se descartaron los scripts/makefiles internos de ROC del zip original — verNOTE.mdahí mismo.test-fixtures/reference/nexrad-l2/rda_simulator_2013/— módulos Python 2 escritos por el usuario en 2013 (MSG_Header.py,Digital_Radar_Data.py,VCP_Data.py,CODE_messages.py, etc.). No son un decoder, son un simulador RDA que arma mensajes Level II sintéticos para probar el path de ingesta de Vesta, hardcodeado aRDA_Id='CCMW'y lat/long de Camagüey; susstruct.pack('>...')sí son oro porque citan tabla/página exacta del ICD.test-fixtures/reference/nexrad-l2/l2_probe_py3.py— nuestro decoder Python 3 verificado (ver abajo).test-fixtures/observations/nexrad-l2/KBYX20260726_113948_V06— archivo real descargado del bucket públiconoaa-nexrad-level2en AWS (radar KBYX, Key West FL), sin envoltorio.gz, bzip2-comprimido por registro (ver más abajo). A diferencia de los 3 KMLB, este es representativo de lo que cualquier descarga real de ese bucket entrega hoy.test-fixtures/observations/nexrad-l3/— dos productos Level III reales (BYX_N0B_.../BYX_N0G_..., reflectividad/velocidad super-res) del mismo volumen KBYX, del bucket públicounidata-nexrad-level3, usados para validar geometría de gates de forma independiente (vertest-fixtures/reference/nexrad-l3/l3_probe_py3.py).
Verificado de verdad (no solo leído del ICD): escribí+corrí
l2_probe_py3.py (Python 3 stdlib) contra los 3 archivos KMLB reales
completos (43 MB cada uno descomprimidos). 0 frames corruptos en miles de
mensajes recorridos por archivo, valores de reflectividad/velocidad/ZDR/PHI/
RHO decodificados dentro de rango físicamente plausible en las 3.
Layout confirmado (big-endian):
| Bloque | Tamaño | Notas |
|---|---|---|
| Volume Header Record | 24 B | [0:12] ASCII tape id ("AR2V0006.157"), [12:16] fecha juliana (u32 BE, día 1 = 1970-01-01 — literal en Tabla II nota 2 del ICD, no supuesto), [16:20] ms del día (u32 BE), [20:24] ICAO sitio ("KMLB"). No cubierto por el ICD 2620002P (ese documento es la interfaz RDA↔RPG, no el wrapper de archivo Archive II) — layout confirmado empíricamente contra los 3 fixtures. |
| Prefijo de frame | 12 B, siempre cero | ICD §3.2.2.2: "the communications manager... inserts an additional 12 bytes to the ICD format message" — cita textual verificada contra el PDF. Es el placeholder CTM_Header.py ('>3I'), inerte en archivo. |
| Message Header | 16 B | '>H2B2HI2H': message_size (halfwords), RDA_redundant_channel, message_type, id_sequence_number, julian_date, milliseconds_of_day, number_of_message_segments, message_segment_number. RDA_Redundant_Channel=8 confirmado como "ORDA Single Channel" válido en Tabla II (no es basura). |
| Framing | fijo 2432 B para todo tipo ≠ 31 (12+16+hasta 2400 payload, zero-pad); tipo 31 no se rellena, tamaño exacto 12 + message_size*2 — confirmado escaneando el archivo completo (~7600 firmas), no una muestra. |
|
| Msg 31 — Data Header Block | 68 B | '>4sIHHfBBHBBBBfBBH9I': radar_id, collection_time, mjd, azimuth_number/angle, compression_indicator, radial_length, elevation_number/angle, 9 punteros u32 (VOL/ELV/RAD/REF/VEL/SW/ZDR/PHI/RHO). Corrección importante: la posición de cada puntero es fija, pero qué momento hay en cada slot NO es confiable (cortes solo-vigilancia dual-pol usan el slot "VEL" para ZDR) — hay que identificar cada bloque por su propio tag de 4 bytes (DREF/DVEL/DZDR/etc.), nunca por posición de puntero. |
| Msg 31 — Data Moment block | 28 B header + N gates | '>4sIHHHHhBBff': tag, n_gates, range, interval, tover, snr_threshold (firmado — bug latente en el simulador de referencia que lo empaca sin signo), data_word_size (8/16 bit), scale, offset. Valor físico = (raw-offset)/scale para raw>=2; raw=0=bajo umbral, raw=1=range-folded. |
Compresión — corrección importante (2026-07-26): los 3 fixtures KMLB
originales no usan bzip2 (cero firmas BZh, compression_indicator=0
en cada radial) — pero esto resultó ser la excepción, no la regla. Un
archivo real recién bajado del bucket (KBYX20260726_113948_V06, ver
arriba) sí viene comprimido: tras el Volume Header de 24 B, el archivo es
una secuencia de registros [4 B BE longitud][bzip2 "BZh..." de esa
longitud], donde CADA registro es su propio stream bzip2 independiente
(confirmado descomprimiendo con bz2 de Python: 55 registros, 0 errores,
offset final exacto al tamaño del archivo). El parser original no tenía
ningún soporte bzip2 — con un archivo real tiraba no message-31 found o
peor, con la primera librería JS probada (bzip2 de npm, decoder puro-JS de
2014), truncaba silenciosamente cualquier registro que superara 900.000
bytes descomprimidos (varios registros reales llegan a 1.4 MB), corrompiendo
el resto del stream sin lanzar error. Solucionado migrando a bzip2-wasm
(libbzip2 real compilado a WASM, mismo código C que usa el bz2 de Python)
— ver src/lib/parsers/nexrad-l2/archive2Bzip2.ts y su test de regresión
específico para el bug de los 900k.
Sin resolver: una corrida de ~177 KB de frames tipo 0 (no es tipo ICD
válido) entre dos mensajes de metadata cerca del inicio del archivo — no
rompe el parseo (el stride fijo de 2432 B lo atraviesa igual) pero la causa
no está confirmada. Qué significa exactamente "0006" en AR2V0006 — no
está en el ICD 2620002P (ese documento no cubre el wrapper de archivo).
Validación cruzada de geometría de gates contra Level III (2026-07-26):
motivada por duda de que la distancia/gate no se estuviera leyendo bien.
Con el bug de bzip2 arriba corregido, se descargó también Level III
(reflectividad N0B + velocidad N0G, mismo volumen KBYX) para verificar
rangeToFirstGateM/gateLengthM contra un producto RPG independiente, no
solo re-derivar los mismos bytes de Level II. Decoder de referencia en
test-fixtures/reference/nexrad-l3/l3_probe_py3.py (grounded contra
Py-ART, no contra un ICD PDF — no hay uno de Level III en este repo).
Tres coincidencias independientes contra el primer corte REF decodificado
por Level II: cantidad de radiales (720 == 720), azimut del primer radial
(358.25° L3 vs 358.248° L2) y espaciado de gate (range_scale_raw × 0.25 =
249.75m L3 vs 250m L2). Conclusión: la matemática de rango/gate de Level
II (message31.ts) ya era correcta — el bug real era la falta de soporte
bzip2 de arriba, no un error de unidades/off-by-one.
Formato interno — .obs (Vesta)¶
No es un formato de radar de entrada, es el contenedor propio de Vesta
al que el original convierte cualquier observación tras ciertas operaciones
(Observation.pas: "esta es convertida automáticamente a este formato").
Datos crudos reales en test-fixtures/observations/insmet/ (4
observaciones del radar Camagüey/rdCamaguey1). Material de referencia en
test-fixtures/reference/insmet/: Obs_Parser.py (script propio del
usuario, 2013, que lo decodifica) y obs_probe_py3.py (nuestro port
verificado, ver abajo).
Gap resuelto: el script es Python 2 (print statement, xrange,
file()) y usa struct en modo nativo (sin prefijo </>/=), que
inserta padding de alineación distinto según la plataforma. Corrí un port
mínimo a Python 3 sin dependencias (zlib+struct de la stdlib, sin
pylab/numpy) contra los 4 archivos reales: en modo nativo de 64-bit
decodifica basura (channel_count de 220, campos con valores absurdos). En
modo empacado (<, sin alineación automática) + 1 byte de padding real
explícito en 2 structs, decodifica los 4 archivos limpio, zlib incluido,
0 fallos de descompresión. El padding es real (bytes que existen en el
archivo, alineación del record Delphi original a 4 bytes para el double),
no un artefacto de lectura — confirmado porque sin él los offsets de PPIs
subsiguientes se corrompen en cascada.
Layout confirmado (little-endian, empacado, sin padding automático de
struct; x = byte de padding real presente en el archivo):
| Bloque | Tamaño | Formato struct |
Notas |
|---|---|---|---|
| Header parte 1 | 64 B | <20s4H36s |
firma "Vesta Observation\x1a\x00\x00", versión, design (string, ej. VCP_31, VCP_11_Merged) |
| Header parte 2 | 20 B (offset 64) | <B2?Bd2I |
radar (código→dRadar), daylight, variance, dummy, time (OLE date, double), ppi_count, channel_count |
| Tabla de ubicaciones | 4×ppi_count B |
<{n}I |
offset absoluto de cada bloque PPI |
Channel desc (×channel_count) |
32 B c/u | <2Bh3I3fI |
wave_length, pulse, dummy, number_of_cells, cell_length_m, num_of_sectors, beam_width_deg, met_potential, delta_potential, index |
PPI Desc (×ppi_count) |
28 B | <3BxdI2B3hI |
radar, speed, dummy, pad, time, channel, kind (H/V), measure (código→dMeasure), angle/start_az/finish_az (código 16-bit→grados, code*360/4096), sectorCount |
| PPI Header | 12 B (tras el Desc) | <BxH2I |
pack_method (dVestaPackMethod: 0=pmNone/sin comprimir, 1=pmDAS, 2=pmZLib), pad, dummy, packed_size, unpacked_size |
| PPI data | packed_size B |
zlib o crudo | pmZLib descomprime a unpacked_size bytes; pmNone ya está sin comprimir (packed_size == unpacked_size, se usa tal cual) → array (sectors, gates) de uint8; dBZ (unDBZ) = byte-80, m/s (MS/SW) = (byte-128)/2, dBZ (unDB) = byte + met_potential + max(0, 20·log10(rango_km)) |
Confirmado en los 4 fixtures reales (c01y0815.obs, c02y1830.obs,
c27a0815VCP31.obs, c27a2100VCP11.obs): radar único rdCamaguey1,
canal 0 = reflectividad (1800 celdas × 250 m = 450 km, S-band/10cm, pulso
largo — coincide con spec WSR-88D), canal 1 = velocidad/ancho espectral
(pulso corto, menos celdas). design usa nomenclatura VCP de NEXRAD
(VCP_11, VCP_31, y variantes _Merged) — confirma que el radar cubano
opera con convenciones WSR-88D aunque el contenedor sea propio de Vesta.
met_potential trae la constante de calibración real (-36.99 dBZ en el
canal de reflectividad de estos 4 archivos) usada en dB2dBZ() para pasar
de dB crudo a dBZ calibrado.
Qué NO resuelve esto: cómo se llega de Rainbow5/NEXRAD Level II crudo
a este .obs — esa lógica sigue en las unidades Translator* que faltan
en el repo. Pero sí nos da, con datos reales y verificados, el contrato
completo del modelo interno (Observation/Channel/PPI) que tanto el parser
de Rainbow5 como el de Level II deben producir — y fixtures reales para
probarlo end-to-end sin depender de tener los parsers de formato crudo
listos primero.
Parser .obs (src/lib/parsers/insmet/), notas del parseo real:
- El campo
number_of_cellsdel channel desc no es fiable para el canal 1 (velocidad/ancho espectral): en los 4 fixtures reales, el bloque PPI decodificado siempre tiene el mismo ancho de fila que el canal 0 (1800 gates), sin importar lo que diganumber_of_cellsdel canal 1 (450/540 según el archivo). El único valor consistente y verificado esunpacked_size / sector_count(del PPI Header/Desc, no del channel desc) — así es como el parser derivaScan.numGates, ignorando deliberadamentenumber_of_cells. - Un mismo momento (ej.
dBZ) puede venir de dos canales físicos distintos dentro de una misma observación: cortes "batch"/split-cut alternan reflectividad de pulso largo (canal 0) y de pulso corto (canal 1, junto con V/W) en distintas elevaciones — confirmado enc02y1830.obs, donde el canal 1 también trae PPIsunDBZcon su propiomet_potential(-26.99vs-36.99del canal 0). El parser agrupa por el par(índice de canal físico, momento), no solo por momento (a diferencia de NEXRAD L2), para no perder esa calibración distinta. .obsno da ángulos por rayo ni rango al primer gate: solo un parstart_az/finish_azy unsectorCountpor PPI. En los 4 fixtures siempre esstart=0, finish=360, sectorCount=360(1°/sector exacto), así que el parser subdivide uniformemente ese rango — no es un dato inventado, pero tampoco literal por-rayo; revisar si aparece algún fixture con sector parcial.rangeToFirstGateMse asume 0 (no hay campo para esto en el contenedor).- Medidas soportadas:
unDBZ(byte-80),unDB→dBZ(corregida por rango, ver abajo),unMS→VyunSW→W(ambas(byte-128)/2). El resto (ZDR/uPhiDP/RhoHV/KDP/...) lanza error en vez de adivinar una fórmula. El propioObs_Parser.pyinvierte el signo deunMScon un comentario# TODO speed sign correctiondel autor original — no se replica esa inversión aquí por ser una duda del propio autor, no una convención confirmada. unDBes reflectividad sin corregir por rango: a diferencia deunDBZ/unMS/unSW,Obs_Parser.pyno transforma el byte crudo en su bucle de carga — solo lo hace en el método apartedB2dBZ(), nunca invocado por su propio__init__. Portado aquí como parte normal del decode:dBZ = byte + met_potential + max(0, 20·log10(rango_km)), conrango_kmpor gate =cell_length_m/1000 * (gate_index + 1)(1-indexed, replicalinspace(cellLength, ncell*cellLength, ncell)del original).delta_potentialno se usa en esta fórmula (tampoco en el original). Confirmado contratest-fixtures/observations/insmet/p15g1530.obs(radarrdPilon, 2007, formato de contenedor más antiguo — mismo layout de bytes, header versión7.7.0.6vs10.10.0.1de los 4 fixtures VCP, single-channel,design="VOL02_15"en vez de nomenclaturaVCP_*).wave_lengthes un código (dWaveLengthenObs_Parser.py:0=wl3cm, 1=wl10cm, 2=wl5cm), no metros directos — el parser traduce el código, no asume que el byte crudo ya está en metros.pack_method0 (pmNone) también está confirmado, no solo 2 (pmZLib):test-fixtures/observations/insmet/b12g2100.obs(radarrdCasablanca, 2004,design="Huracan") trae sus 25 PPIs sin comprimir,packed_size == unpacked_sizeen los 25. El parser copia el blob tal cual en vez de pasarlo porDecompressionStream.pmDAS(1) sigue sin soporte — ni el propioObs_Parser.pylo implementa (print 'Unhadled compression method').
Fuera de alcance de parsing (por ahora)¶
- NMEA/ATTEX (Rusia) — tracking de avión, es P4.
- NetCDF — solo export, no formato de entrada.
Necesidades de fixtures¶
Datos crudos en test-fixtures/observations/<formato>/, material de
referencia (specs, decoders/simuladores) en test-fixtures/reference/<formato>/.
rainbow5/ — recibido y verificado (2026-07-24): bandaS (15 elevaciones,
dBZ/dBuZ/V/W) y bandaX (4 elevaciones, dBZ/dBuZ/V/W/
RhoHV/uPhiDP), 0 fallos de descompresión/layout en los 10 archivos.
Cada momento va en su propio archivo .vol (no hay un volumen
multi-momento en un solo archivo entre los fixtures recibidos). Pendiente
todavía:
- Caso
anglestep < 1°(bug histórico de sectores, changelog 5.3.17.1-3) — los fixtures actuales usananglestep=1. - Volumen con RHI (corte vertical) — los fixtures actuales son solo PPI.
@depthsub-byte (ej. 6-bit) — ningún fixture actual lo usa.- Volumen parcial/corrupto para probar manejo de error.
nexrad-l2/ — recibido y verificado (2026-07-23): 3 archivos reales KMLB
(.gz, wrapper NCDC), 0 frames corruptos en los 3, momentos REF/VEL/SW/
ZDR/PHI/RHO decodificados en rango físico plausible. Pendiente todavía:
volumen parcial/corrupto para probar manejo de error (los 3 que tenemos son
casos felices completos).