
简介这份文档面向气象雷达数据处理、算法研发与二次开发人员系统梳理了CINRAD SA/SB与CA/CB两类雷达基数据的完整格式规范。内容从雷达信息头的28字节结构开始逐一讲解径向数据收集时间、儒略日、不模糊距离、方位角、仰角、体扫模式、径向数据状态等关键字段的字节偏移与编码方式并详细说明反射率、速度、谱宽三类数据的距离库长、库数、数据指针及数值换算公式如反射率dBZ、速度m/s读者可据此编写解析程序、完成基数据可视化或排查数据异常。资源为单份Word文档共1个doc文件压缩包体积仅92KB轻量实用。目前已有2799人学习下载。文档末尾还对比了SA/SB与CA/CB两种雷达在反射率距离库数460/800、速度和谱宽库数920/1600等参数上的差异并总结数据存储顺序与径向长度适合作为雷达基数据格式的查阅手册或教学参考资料。1. 雷达基数据为什么同一批文件换个雷达就解析失败雷达基数据这四个字做气象数据处理的人都绕不过去。它是雷达扫描后落盘的第一手二进制结果存的是反射率、径向速度和谱宽按仰角、方位、距离一层层堆在文件里。但也是这份文件让不少人在数据接入上栽跟头同一套解析代码换一个雷达型号或者换一个格式版本读出来的就是乱码或错位数值连回波位置都对不上。这篇笔记把基数据的存储结构、解析套路和常见坑拆开讲清楚适合要写解析器、做基数据质量检查或者对接业务系统的开发者做参考。2. 读懂基数据的三层二进制结构文件头、仰角头与径向数据2.1 为什么必须按字节对齐读基数据的存储约定雷达基数据不是什么文本或 JSON它是雷达信号处理器把内存里的结构体直接写盘的结果。这意味着文件里没有分隔符也没有自描述的标签所有字段的位置都靠固定偏移来约定。我经手过的基数据文件绝大多数在头部放了 4 字节魔数紧接着是站点编号、雷达型号、格式版本、体扫模式、距离库数、库长等等再往后才是按仰角排列的径向数据。内存对齐这点最容易被忽略。同一份数据结构在不同位数的编译器下生成的布局长度可能不同写入端和读取端的字节序不一致读出来所有字段都会错位。所以解析器的第一个动作不是去算径向而是先读头部校验魔数和版本确认这份文件是哪一种格式约定。我一般会把头部做成一个常量表字段偏移写清楚方便不同雷达型号切换解析分支。2.2 从文件头到仰角头先定位再解析数据在文件里的路线图是三层嵌套文件头给出仰角层数和体扫模式每个仰角层有一个仰角头给出该层的径向数和仰角值每个径向有径向头给出方位角和有效距离库数后面紧跟着反射率、速度、谱宽三个等长的 uint8 数组。我以一套教学简化模型为例文件头常见字段如下偏移类型字段含义0char[4]magic文件魔数用于识别格式4uint16site_id雷达站点编号6uint16version格式版本号8uint16elev_count体扫仰角层数10uint16vcp_mode体扫模式编号12uint32max_gates最大距离库数16float32gate_length单个距离库长度单位米注意这是教学简化模型不同型号雷达的偏移会不同版本字段尤其要用来选择后续的解析分支。拿到一个未知文件先打印头部前 32 字节的十六进制对比你手上的格式说明比直接写解析循环靠谱得多。仰角头接着文件头顺序排列典型字段是仰角序号、该层径向数、仰角值、波束宽度。解析到这一层时先读固定长度的仰角头再根据径向数循环读径向。径向头里的 gate_count 是这一根径向的有效距离库数后续三个数组的长度都由它决定。2.3 反射率、速度与谱宽的二进制编码从整数到物理量三个物理量在径向里各占 gate_count 个字节每个字节是一个 uint8。存储时做了一次线性编码常见形式是物理量 原始整数 × scale - offset。反射率常用 scale0.5 dBZ、offset-32速度常用 scale0.5 m/s、offset64 左右谱宽常用 scale0.5。无效值和缺测值的判定比换算本身更重要。有的格式用 0 表示缺测有的用 255有的在质量标记字节里说明。在解析阶段就把无效值转成 NaN后续统计才不会把无回波当成 0dBZ这是很多初版解析器出问题的根源也是后面避坑部分要展开的重点。3. 写一个最小雷达基数据解析器核心代码与三处参数陷阱3.1 搭建最小读取框架struct 解包与字节偏移我用 Python 写解析器时标准库 struct 加上 numpy 就够了。struct 负责按固定宽度切字节numpy 负责把字节数组转成可计算的物理量数组。先搭一个最小骨架定义头部常量和读取辅助函数。import struct import numpy as np BASE_HEADER_SIZE 1024 ELEV_HEADER_SIZE 16 RADIAL_HEADER_SIZE 16 MAGIC bRADR def read_bytes(fp, n): buf fp.read(n) if len(buf) n: raise EOFError(f文件提前结束: 需要 {n} 字节, 实际读到 {len(buf)}) return buf class BaseDataFile: def __init__(self, path): self.path path self.header {} self.elevations [] def read_base_header(self): with open(self.path, rb) as f: buf read_bytes(f, BASE_HEADER_SIZE) magic, struct.unpack_from(4s, buf, 0) if magic ! MAGIC: raise ValueError(f魔数校验失败: {magic}) self.header[site_id] struct.unpack_from(H, buf, 4)[0] self.header[version] struct.unpack_from(H, buf, 6)[0] self.header[elev_count] struct.unpack_from(H, buf, 8)[0] self.header[vcp_mode] struct.unpack_from(H, buf, 10)[0] self.header[max_gates] struct.unpack_from(I, buf, 12)[0] self.header[gate_length] struct.unpack_from(f, buf, 16)[0]代码里4s表示小端序 4 字节字符串H是无符号短整型I是无符号整型f是单精度浮点。unpack_from在读取时指定偏移不会把整个头部切错。魔数校验必须放在第一步它能在解析错位的早期直接把问题暴露出来而不是让你对着乱码猜字段。版本字段在头部偏移 6代码里先读出来但没做分支。实际项目中这个字段决定了后续仰角头和径向头的长度建议维护一张版本到头部布局的映射表不要写死在常量里。3.2 按仰角循环解析径向头与三个数组文件头读完后文件指针挪到仰角数据区。每个仰角层先读仰角头拿到径向数再逐根径向读径向头最后按 gate_count 读三个物理量数组。def _read_elevation(self, fp): buf read_bytes(fp, ELEV_HEADER_SIZE) elev_idx, radial_count, elev_angle, beam_width struct.unpack_from( H H f f, buf, 0 ) layer {elev_angle: elev_angle, radials: []} for _ in range(radial_count): rh read_bytes(fp, RADIAL_HEADER_SIZE) az_idx, az_angle, cur_elev, gate_count, data_flag struct.unpack_from( H f f H B, rh, 0 ) refl np.frombuffer(read_bytes(fp, gate_count), dtypenp.uint8) velo np.frombuffer(read_bytes(fp, gate_count), dtypenp.uint8) swid np.frombuffer(read_bytes(fp, gate_count), dtypenp.uint8) layer[radials].append({ azimuth: az_angle, elevation: cur_elev, gate_count: gate_count, data_flag: data_flag, reflectivity: refl, velocity: velo, spectrum_width: swid, }) return layer def parse(self): self.read_base_header() with open(self.path, rb) as f: f.seek(BASE_HEADER_SIZE) for _ in range(self.header[elev_count]): self.elevations.append(self._read_elevation(f))径向头里最关键的字段是 gate_count。很多格式的头部写了 max_gates但每根径向的实际长度可能因雷达扫描模式或仰角不同而变化代码里必须用每根径向自己的 gate_count 去读数组否则径向数据会对不齐。data_flag 是质量控制标记通常区分实测、插补、地物杂波等解析阶段保留它后面做质量控制要用。3.3 物理量换算、无效值处理与参数表从 uint8 数组到物理量数组需要统一的换算函数。我把 scale、offset、invalid 做成参数字典方便不同雷达型号替换。REFL {scale: 0.5, offset: -32.0, invalid: 0} VELO {scale: 0.5, offset: 63.5, invalid: 1} SWID {scale: 0.5, offset: 0.0, invalid: 0} def decode(raw, scale, offset, invalid): data raw.astype(np.float32) * scale - offset data[raw invalid] np.nan return data注意 invalid 的处理方式。速度的缺测值在很多格式里是 1而不是 0因为 0 可能是合法速度值。反射率的缺测有的用 0有的用 255。换算之前先做无效值掩码否则统计均值时会把缺测算成有效回波。物理量scaleoffset常见无效值反射率0.5 dBZ-32.00 或 255径向速度0.5 m/s63.51谱宽0.5 m/s0.00 或 255这组参数只是常见取值不做硬编码更好。我会把它们放进配置文件每个雷达站点或者每个格式版本对应一份解析器启动时按版本字段加载。4. 解析雷达基数据避坑指南5 个常见问题与排查思路4.1 字节序与结构体对齐解析结果全是乱码现象是文件头魔数读出来不是 RADR而是 RDAR 或者 RARD后面的字段数值大得离谱比如站点编号变成了几十万。原因基本是写入端和读取端字节序不一致。雷达主机常见的是大端序而 PC 是 x86 小端序。还有一些老系统用 4 字节对齐写结构体字段之间有空隙按偏移读会错位。解决方法是先做魔数校验再统一字节序。代码里把改成重新读一遍头部如果魔数对了说明是整文件大端序把所有 struct 解包都换成大端前缀。结构体对齐导致的错位没有通用解法只能对照格式手册里字段的实际偏移逐个修正所以头部字段尽量不用位置参数直接用偏移量解包。4.2 距离库数与扫描模式不匹配径向长度忽长忽短现象是解析到某一个仰角层时径向数组越界或者报 EOF打印出来的 gate_count 和头部 max_gates 对不上。原因是部分基数据格式的径向头里存的是最大库数而实际写入的是按当前扫描模式截断后的有效距离。比如体扫模式不同最大探测距离不同距离库数就不同。头部 max_gates 是上限不能当作每根径向的统一长度。解决方法是每根径向单独读 gate_count读完数组后用文件指针位置做一次断言如果和径向头预期不符直接跳过这根径向并记录异常不要让一条坏数据打断整个文件的解析。4.3 仰角层数与体扫模式对应关系漏层、多层判定错误现象是解析出的仰角层数和体扫模式手册不一致有时候少一层有时候多一层。原因是一些基数据文件把模式切换时的过渡层也写进去了比如 VCP21 模式指定的仰角和文件里实际扫描的层数不完全相同文件里多了一个仰角为 0 的占位层。如果程序直接按文件头 elev_count 循环不做仰角过滤就会多解析或者漏解析。解决方法是读取仰角头里的实际仰角值按 VCP 模式对应的仰角集合过滤把不匹配的层剔除。解析完再对每一层的仰角做排序后续体扫插值使用排序后的层不用文件中的原始顺序。4.4 速度模糊与谱宽缺失没做质量控制直接出图现象是速度场在正负最大值之间跳变出现明显的条纹而谱宽层全是 0出图后一片白或者一片黑。原因是超过最大不模糊速度的径向速度被折叠基数据没有做退模糊处理解析出来的原始值直接换算就会出现跳变。谱宽在某些体扫模式下没有观测数据写入的是全 0。解决方法是解析阶段保留原始速度和谱宽不要直接做显示。速度场用相邻径向连续性做退模糊或者把模糊区域标记出来交给算法处理谱宽全 0 的层在显示时跳过不让无效值干扰色标。data_flag 在这里派上用场它记录了哪些库是插补值哪些是实测值。4.5 时间基准不统一拼图时两站数据错位现象是两站基数据拼图时回波位置出现几十公里的位移单站看没问题拼在一起就错位。原因是一个站文件头存的是 UTC 时间另一个站存的是本地时间有的文件头是体扫开始时间有的径向还有独立的观测时间戳。直接拿文件修改时间或者文件名里的时间做拼图对齐就会对不上。解决方法是解析时把时间统一换算成 UTC以体扫开始时间加径向序号乘以扫描周期来重建每根径向的时间。跨站拼图只认统一时间戳不直接比文件名或文件修改时间。5. 从单文件到全目录格式自检与批量解析的落地管线5.1 写一个格式自检脚本魔数、字段范围与径向连续性解析器写完第一步不是直接做业务而是写格式自检脚本。经纬度、数据量这些不管先确认每个文件能被解析且解析结果在合理范围内。def quick_check(path): try: bd BaseDataFile(path) bd.parse() except Exception as exc: return False, str(exc) hdr bd.header if not (0 hdr[elev_count] 30): return False, f仰角层数异常: {hdr[elev_count]} if not (0 hdr[max_gates] 2000): return False, f距离库数异常: {hdr[max_gates]} for layer in bd.elevations: if len(layer[radials]) 0: return False, f仰角 {layer[elev_angle]} 无径向数据 return True, 自检的检查项不用多魔数、仰角层数范围、距离库数范围、每层径向数是否为 0这四样就能拦住绝大多数损坏文件。基数据在业务链路里通常凌晨批量落盘定时用自检脚本刷一遍目录比等到白天人工发现异常要早好几个小时。5.2 生成模拟基数据验证解析器正确性解析器必须有测试数据但没有官方样本时可以按格式约定生成一份模拟基数据然后解析回来对比。这样能验证字节偏移、数组长度和物理量换算三件事。def generate_fake_base(path, elev_count2, radials360, gates460): with open(path, wb) as f: head bytearray(BASE_HEADER_SIZE) struct.pack_into(4s, head, 0, MAGIC) struct.pack_into(H, head, 4, 9001) struct.pack_into(H, head, 6, 8) struct.pack_into(H, head, 8, elev_count) struct.pack_into(H, head, 10, 21) struct.pack_into(I, head, 12, gates) struct.pack_into(f, head, 16, 1000.0) f.write(head) for layer in range(elev_count): f.write(struct.pack(H H f f, layer, radials, 0.5 layer * 4.5, 1.0)) for r in range(radials): az r * 360.0 / radials f.write(struct.pack(H f f H B, r, az, 0.5 layer * 4.5, gates, 1)) refl np.random.randint(0, 255, gates).astype(np.uint8) velo np.random.randint(0, 255, gates).astype(np.uint8) swid np.random.randint(0, 255, gates).astype(np.uint8) f.write(refl.tobytes()) f.write(velo.tobytes()) f.write(swid.tobytes())生成时用随机 uint8解析后用 decode 还原成物理量再和原始随机值对算一遍确认换算函数没写反。固定模式则用全 0 或全 1 填充专门验证 invalid 值处理是否生效。5.3 批量解析与内存预算业务场景很少只解析一个文件更多是扫全目录。基数据解析是 CPU 密集操作用进程池比线程池靠谱绕开解释器全局锁。import concurrent.futures from pathlib import Path def process_one(path): bd BaseDataFile(str(path)) bd.parse() return path.name, bd.header, len(bd.elevations) files list(Path(/data/radar).glob(*.bin)) with concurrent.futures.ProcessPoolExecutor(max_workers4) as ex: for name, hdr, elev_count in ex.map(process_one, files): print(name, hdr[site_id], elev_count)内存要单独算。一个径向三个数组各几百字节360 根径向乘以 9 层单文件读进内存轻松上百兆。批量处理时按文件逐个解析、逐条落盘不要把整目录文件一次性全读进来。落盘格式我用过 numpy 的 npz 和 Zarr 两种前者适合小规模单机后者适合后续做拼图算法共享数据。6. 进阶技巧用径向覆盖率和极坐标渲染做回归验证6.1 径向覆盖率定位扫描异常解析器稳定之后我习惯把径向覆盖率作为雷达健康度的第一指标。覆盖率等于每个仰角层实际读到的径向数除以期望径向数。期望值由体扫模式和扫描速率决定通常固定。def radial_coverage(bd): coverage [] for layer in bd.elevations: expected int(360 / layer[az_step]) if az_step in layer else 360 actual len(layer[radials]) coverage.append(actual / expected) return coverage覆盖率低于 0.9 说明这个仰角层扫描不完整可能是天线在扫描过程中丢了脉冲也可能是文件写入中断。我在某个跨平台系统上排查过类似问题最后发现是数据采集端把偶发丢径向的数据也写进了文件覆盖率脚本直接定位到了丢数据的仰角层。6.2 极坐标渲染对比新旧解析结果解析器改动字节偏移或换算参数后怎么确认没改坏我的做法是把新旧解析结果渲染成极坐标图逐仰角对比回波分布。极坐标渲染用 matplotlib 的 pcolormesh 加 projection 选项就能做关键是渲染时使用方位角数组和距离库数组而不是直接当直角坐标画。import matplotlib.pyplot as plt def plot_layer(ax, layer, gate_length): az np.deg2rad([r[azimuth] for r in layer[radials]]) rng np.arange(layer[radials][0][gate_count]) * gate_length / 1000.0 grid np.vstack([r[reflectivity] for r in layer[radials]]) ax.pcolormesh(az, rng, grid, shadingauto)渲染结果和新版本算法输出的图对比回波范围和强度分布应该一致。我留过一个教训某次只改了速度的 offset所有速度场出图都偏了半个量程就是因为没跑图对比直接拿数值表看小数差别很难被发现。之后我改任何格式解析相关的代码都先出图再做逻辑判断。解析器写出来只是开始格式自检、模拟数据验证、批量管线这三步能帮你把解析器从能跑到敢用的差距补上。数据格式是个黑匣子但黑匣子也得有打开它的钥匙。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。