1. 为什么近场测量代码的第一行几乎总是 numpy.linspace
做电磁近场测量的人,日常工作基本围着三样东西转:一台能稳定输出连续波的信号源、一套能带着探头在二维或三维空间里挪动的扫描架,以及一套事后把近场幅相数据变换成远场方向图的处理脚本。前两样是硬件,第三样是软件。而软件里真正决定结果对不对的,往往不是那个看起来很唬人的近远场变换算法,而是最前面那几十行"把坐标轴定义出来"的代码。
原因很直白:近场测量本质上是一次空间采样。探头在每一个采样点上记录幅值和相位,这些点连起来构成一个离散的场分布矩阵。后面所有的操作——沿某一维做 FFT 得到平面波谱、把谱在传播方向上加相位因子、再逆变换回远场——都默认一件事:采样点在空间上是等间隔排列的,而且这个间隔的值是被算法精确知道的。只要坐标轴和实际物理位置对不上,哪怕只差半个点,最后出来的方向图主瓣宽度、副瓣位置、零点深度统统会偏。你不会看到报错,只会看到一张"看起来挺像但就是和仿真对不上"的图,然后开始怀疑探头、怀疑暗室、怀疑人生。
np.linspace的价值就在这里。它的语义极其干净:给定起点、终点和点数,返回一个端点严格可控、步进全程一致的一维数组。相比np.arange,它不会因为浮点累加而在末尾悄悄少一个点或者多一个点;相比手写np.array([i*dx for i in range(N)]),它不用你自己去纠结最后一个点到底该不该落在终点上。在近场测量这种"点数、步进、口径尺寸三者必须严格自洽"的场景里,这种确定性比什么都值钱。
这篇东西不打算泛泛地讲 API 文档。我想按实际工作流的顺序,把np.linspace在近场测量里最常出现的几种用法、参数背后那些文档里一笔带过但现场会咬人的细节、以及我自己踩过的坑,一条条摊开讲。适合写过一点 Python、正在或者准备搭近场测量数据处理链的人看,也适合把 FFT 用熟了但没想清楚坐标轴含义的人对照检查。
1.1 采样栅格决定了后面所有变换的成败
先把整条链路的逻辑捋一遍,这样后面讲参数的时候你才知道每个数字为什么重要。
假设做一个平面近场扫描,被测件是一个工作在 10 GHz 附近的口径天线,物理口径尺寸大约 300 mm。10 GHz 对应的自由空间波长 λ = c/f ≈ 30 mm。近场测量的常规采样判据是空间步进不大于半个波长,也就是 Δ ≤ 15 mm。于是扫描面在 x 方向上至少要覆盖 300 mm 加边缘余量(实际工程上一般要往外扩 10λ 左右,压制截断带来的方向图波纹),粗略取 400 mm。
到这里,np.linspace要回答的问题就变成了:起点 -200 mm,终点 +200 mm,步进 15 mm,那一共几个点?
很多人第一反应是int(400/15) = 26。错。26 个点排出来是 26 段间隔,实际跨度是 25 × 15 = 375 mm,末端只剩 187.5 mm,比你想要的 200 mm 差了一截。正确做法是先把点数定下来,再反过来算步进,这也是我后面要重点讲的两步法。np.linspace天生就是为这个逻辑设计的:它的参数里没有"步进"这一项,只有"点数",逼着你从点数的角度去思考问题。
一旦点数定了,这个 N 就会一路往下传:它决定 FFT 的长度,决定空间频率轴fftfreq(N, d=dx)的取值,决定平面波谱里 kx 的取值范围,决定远场角度轴上哪些角度是可用的(超出 |kx| ≤ k 的部分是瞬逝波,不能直接映射到远场)。N 错一个数,后面全歪。
1.2 用 arange 生成 15 mm 步进,结果整条轴短了一个点
说一个我自己早期真实遇到的事故。
那会儿写第一版平面近场处理脚本,坐标轴是这么生成的:
import numpy as np dx_target = 0.015 # 15 mm L = 0.400 # 扫描面总长度 400 mm x = np.arange(-L/2, L/2, dx_target) print(len(x), x[-1])输出是26和0.175。终点本该是 0.2,实际停在 0.175;点数本该是 27,实际拿到 26。脚本没报错,FFT 照样跑,方向图照样出图。问题是后面在算平面波谱的时候,我用的是fftfreq(len(x), d=dx_target),长度对上了,但真实的物理跨度是 26 个点对不上 400 mm 的口径假设。等到把远场方向图和仿真结果叠在一起看,主瓣宽度差了差不多 4%,副瓣电平整体抬高了几个 dB,而且左右不对称——因为截断实际上发生在一侧。
排查花了大半天。一开始怀疑暗室的吸波材料、怀疑探头定位精度、怀疑参考通道的相位漂移,最后是打印坐标轴数组一眼看出来末端不对。改成np.linspace之后:
x = np.linspace(-L/2, L/2, 27) # array([-0.2 , -0.18462, ..., 0.18462, 0.2])端点严丝合缝落在 ±0.2,步进 0.01538 mm,和 400/26 完全一致。
这个坑的根源是np.arange的语义:它按"起点 + 步进 × 序号"来生成,然后用一个容差去判断是否越过终点。步进是十进制小数,二进制浮点表示不精确,累加几次之后就可能在边界上判断失误。点少了是小事,点多了更麻烦——有些情况下它会多吐一个点出来,那个点已经超出你规划的扫描范围,对应的物理位置根本没测过数据,你在做插值或者补零的时候就会莫名其妙多出一列。
提示:凡是坐标轴生成,尤其是步进是小数的情况,一律用
np.linspace。np.arange留给"整数序号"这类天然精确的场景。
2. linspace 的参数语义与三个被忽略的返回细节
np.linspace的签名看着简单,实际参数不多但每一个都有讲究,尤其是新手最容易忽略的那几个。
基础形式是np.linspace(start, stop, num=50, endpoint=True, retstep=False, dtype=None, axis=0)。近场测量里,前三个参数基本每次都要显式写全,endpoint和retstep在特定场景下会变成关键。
2.1 start、stop、num 三者的端点约定
先说一个很多人搞错的地方:stop是包含还是排除,完全由endpoint决定,跟stop这个参数名没关系。
endpoint=True(默认):数组的最后一个元素恰好等于stop,总点数就是num,间隔是(stop - start)/(num - 1)。endpoint=False:数组的最后一个元素是stop - 间隔,相当于把stop当作"下一个点的位置但不要它",总点数还是num,间隔是(stop - start)/num。
这个区别在近场测量里不是学术问题,是实打实会改变结果的。
举一个场景:你要在角度域上生成一圈采样点,用来和实测转台的角度列表对齐。转台从 -90° 转到 +90°,如果软件配置里是"每步 1°,含首含尾",那你得到的是 181 个点,np.linspace(-90, 90, 181)。如果转台配置是"从 -90° 开始,走 180 步",那你实际停在 +89°(或者 +90°,取决于控制器实现),这就是另一种点数。角度轴和实测转台对不上,方向图的零点位置会整体平移,看起来很像是相位中心偏移,但其实是坐标轴问题。
更典型的是 FFT 场景。假设某个维度上你打算用周期边界做离散傅里叶变换,那么采样点应该覆盖一个完整周期但不重复端点,这时必须用endpoint=False。而如果你是想让某个物理量在两端都有明确取值(比如口径场在边缘为零的切比雪夫分布),那就必须endpoint=True,否则边缘那个点的权重就丢了。
num这个参数还有个细节:新版本 numpy 要求它必须是整数,早年间传浮点数会隐式转换,现在已经明确报错。写脚本时如果用int(L/dx)算点数,别忘了那个int(),而且要想清楚是int()、round()还是ceil()——这三个在边界情况下结果可能差一个点。
2.2 retstep=True:让实际步进自己报出来
这个参数是我强烈建议在近场测量脚本里默认打开的。
retstep=True会让np.linspace返回一个元组:数组本身加上实际使用的间隔。为什么要开?因为实际间隔几乎永远不等于你心里想的那个目标间隔。
L = 0.400 dx_target = 0.015 N = int(np.ceil(L / dx_target)) + 1 # 27 x, dx = np.linspace(-L/2, L/2, N, retstep=True) print(N, dx) # 27 0.015384615384615385你想的是 15 mm,实际得到的是 15.38 mm。这不是误差,这是必然——因为点数必须是整数,400 mm 除以 15 mm 等于 26.67,取整之后步进必然被拉伸或压缩。
关键在于:后面所有用到步进的地方,必须用dx这个实际值,而不是dx_target。最典型的就是np.fft.fftfreq(N, d=dx)。如果你用目标值 0.015 去算空间频率轴,而真实采样间隔是 0.01538,空间频率轴整体会缩放 2.5% 左右。反映到远场方向图上,角度轴同样缩放 2.5%,10 GHz 下这个偏差在 ±60° 的位置大概能到一两度——够让方向图比对直接失败。
我现在的习惯是,凡是空间坐标轴,一律这么写:
x, dx_actual = np.linspace(-Lx/2, Lx/2, Nx, retstep=True) y, dy_actual = np.linspace(-Ly/2, Ly/2, Ny, retstep=True) assert np.isclose(dx_actual, dy_actual, rtol=1e-9), "两个方向步进不一致,检查点数"那个assert也是踩出来的。平面近场里 x 和 y 两个方向的步进理论上应该完全相同(探头是按网格走的),但有一次因为两个方向的口径余量取了不同值、点数又各自取整,结果两个方向的步进差了 0.3%,做二维 FFT 之后方向图在斜切面上出现了一个不明显的梯形畸变,肉眼几乎看不出来,只有和仿真叠图时才露馅。
2.3 endpoint=False 与 FFT 周期性的天然契合
再展开说一下endpoint=False,因为它在近场数据处理里出现的频率比想象中高。
离散傅里叶变换在数学上隐含一个假设:输入的有限长序列是某个周期序列的一个周期。也就是说,第 N 个采样点和第 0 个采样点在物理上是同一个点。如果你的数组同时包含了起点和终点,实际等于把这个周期点算了两遍——在频域上表现为一种很轻微的、和采样长度相关的起伏。
在近场测量里,什么时候会在意这个?主要是在做频谱分析类的处理,比如你从近场数据里截取一段做空间谱估计、或者对某一维做加窗之前的预处理。这时候如果端点重复,窗函数在边界处的行为会和理论不符。标准做法是用endpoint=False生成轴,同时用np.fft.fftfreq(N, d=dx)生成对应的频率轴,两者长度一致、周期自洽。
不过在近场扫描网格的定义上,我个人的做法是相反:用endpoint=True。理由是物理口径本身是有限尺寸的,被测件不会真的在边界处首尾相连,我们希望扫描面严格覆盖从 -L/2 到 +L/2 的整个区域,边缘那个点必须测。这一点和信号处理里"周期序列"的语境不同,不能照搬。
这两种用法并存,是很容易把自己绕进去的地方。我的经验是:定义物理扫描面时用 endpoint=True,处理截取出来的频谱段时用 endpoint=False,并且在代码注释里写清楚为什么。过几个月回头看,注释能救你。
3. 从口径尺寸和波长反推采样点数的公式链
这一节把前面零散提到的东西串成一条可复用的公式链。这条链子我建议直接写成一个工具函数,每个近场处理脚本开头调用它,省得每次都重新推。
3.1 半波长判据、采样密度与瞬逝波的关系
为什么是半个波长?很多人背下来了但没想清楚。
空间采样和时域采样是一回事。时域采样定理说采样率要大于信号最高频率的两倍,否则高频分量会折叠到低频。空间域同理:以间隔 Δ 采样一个空间分布,能够无混叠表示的最大空间频率是k_max = π/Δ。
近场里,场的空间频率由k = 2π/λ给出。传播波对应的空间频率范围是 |k| ≤ 2π/λ。要让这个范围完整落在无混叠区间内,需要:
π/Δ ≥ 2π/λ → Δ ≤ λ/2所以 λ/2 不是一个工程经验常数,而是"刚好能无混叠地表示全部传播波谱"的临界值。取等号的时候,k 恰好落在边界上,实际会有一点风险,所以工程上习惯再取小一点,比如 0.45λ 或者 0.4λ。
更关键的一点:近场数据的价值恰恰在于它包含瞬逝波(|k| > 2π/λ 的那部分,衰减很快,只在距离口径几个波长内存在)。瞬逝波携带的是超分辨信息,如果采样间隔刚好是 λ/2,这部分信息已经被混叠污染,拿不回来。想保留更多瞬逝波,就得把 Δ 压到 λ/3、λ/4 甚至更小。当然代价是采样时间成倍增长、数据量成倍增长。
实用上我的一般策略是:
| 测量目标 | 建议步进 | 理由 |
|---|---|---|
| 只看远场方向图主瓣和近副瓣 | 0.5λ | 满足传播波无混叠的临界条件,采样最快 |
| 需要精确副瓣、深零点 | 0.4λ~0.45λ | 留出混叠余量,副瓣区域误差更小 |
| 关注瞬逝波、超分辨诊断 | 0.2λ~0.3λ | 保留部分瞬逝分量,数据量显著上升 |
| 大尺寸阵列诊断(找单元失效) | 0.3λ 以下 | 需要足够空间分辨率定位单元级异常 |
这张表不是标准,是我自己几轮项目下来总结的经验区间,具体取值还要看你的被测件尺寸、扫描架行程和时间预算。
3.2 先定 N 再反推 dx:两步法的完整推导
把 3.1 的结论和np.linspace结合起来,标准流程是这样的。
第一步,确定扫描面尺寸 L。它由被测口径尺寸 D 加上边缘外扩量 ΔL 决定:
L = D + 2 × ΔL外扩量 ΔL 通常取 5λ~10λ。外扩不足会导致截断效应,方向图旁瓣区域出现周期性波纹;外扩太多则是纯粹浪费时间。对于 300 mm 口径、30 mm 波长,取 ΔL = 8λ = 240 mm,则 L = 300 + 480 = 780 mm。第一次做的时候我看到这个数字有点惊讶——扫描面比口径大了两倍多。但这是近场测量的常态,平面近场变换要求一个足够大的等效口面,否则谱域里会引入不真实的边缘散射。
第二步,确定点数:
N = int(np.ceil(L / dx_target)) + 1为什么是ceil而不是round?因为我们要保证实际步进不大于目标步进。用ceil得到更大的 N,反推的 dx 就更小,采样更密,一定满足无混叠条件;用round有可能让实际步进略大于 λ/2,那就踩线了。
那个+1也必须有。ceil(L/dx)得到的是"间隔段数",点数比段数多一。
第三步,反推实际步进:
dx = L / (N - 1)第四步,生成坐标轴:
x, dx_check = np.linspace(-L/2, L/2, N, retstep=True) assert abs(dx_check - dx) < 1e-12把这段封装一下:
def make_axis(aperture, wavelength, edge_extra_wl=8.0, sample_ratio=0.45): """ 生成一维扫描轴。 aperture : 被测口径尺寸 (m) wavelength : 工作波长 (m) edge_extra_wl : 单侧外扩量,单位波长 sample_ratio : 采样步进与波长的比值,建议 0.4~0.5 返回 (坐标数组, 实际步进, 实际扫描面长度) """ dx_target = sample_ratio * wavelength L = aperture + 2 * edge_extra_wl * wavelength N = int(np.ceil(L / dx_target)) + 1 axis, dx_actual = np.linspace(-L / 2, L / 2, N, retstep=True) assert dx_actual <= dx_target + 1e-15, "实际步进超过了目标步进" return axis, dx_actual, L用 10 GHz、300 mm 口径带进去算一下:dx_target = 0.45 × 0.03 = 0.0135 m;L = 0.3 + 2×8×0.03 = 0.78 m;N = ceil(0.78/0.0135) + 1 = ceil(57.78) + 1 = 58 + 1 = 59;dx_actual = 0.78/58 = 0.013448 m。比目标略小,符合预期。
3.3 点数取奇数还是偶数:一个容易被忽略的一致性要求
还有一个细节值得单独说:两个方向的点数最好保持奇偶性一致。
这不是 FFT 的硬性要求,二维 FFT 对任意形状的数组都能算。问题出在坐标原点的位置上。如果 Nx 是奇数,linspace(-L/2, L/2, Nx)的中间那个元素恰好是 0,原点落在采样点上;如果 Nx 是偶数,原点落在两个采样点正中间,任何"以原点为中心"的对称操作都要做半格偏移。
这在处理对称结构(比如对称阵、对称口径分布)的时候会体现出来。我在一个项目里做近场数据的对称性检查——理论上被测件左右对称,实测幅相应关于中心对称——结果发现差值有一个稳定的半个采样点的相位梯度。查了半天,最后发现是 x 方向取了 60 点(偶)、y 方向取了 59 点(奇),两个方向的原点约定不一致,我用的中心化代码只对了一种情况。
所以现在的习惯是:两个方向要么都是奇数,要么都是偶数,并且和中心化代码的约定对齐。如果make_axis返回偶数点数,我就在调用处统一加一个判断,把 N 调成奇数(相应 dx 会略微变化,用retstep=True拿到实际值就行)。
4. 波数域里的 linspace:从 k 到 fftfreq 的完整换算
近场变换的第二步,是把空间域的场分布变换到波数域(平面波谱域)。这一步里np.linspace表面上看不见了,但它生成的那条坐标轴在这里扮演了决定性角色——因为波数域的一切都从它派生。
4.1 波数 k 与相位项的生成
自由空间波数:
k0 = 2 * np.pi / wavelength平面波谱方法里,我们需要的是一组方向余弦或者一组横向波数 (kx, ky)。它们和空间坐标的关系由二维傅里叶变换建立:
kx = 2 * np.pi * np.fft.fftfreq(Nx, d=dx_actual) ky = 2 * np.pi * np.fft.fftfreq(Ny, d=dy_actual)这里d必须用retstep给出的真实步进。fftfreq返回的是"每单位长度的周期数",单位是 1/m,乘 2π 才是波数,单位 rad/m。
拿到 kx、ky 之后,纵向波数由色散关系给出:
KX, KY = np.meshgrid(kx, ky, indexing='ij') KZ2 = k0**2 - KX**2 - KY**2 KZ = np.sqrt(KZ2.astype(complex))这里KZ2.astype(complex)是必需的。如果 KZ2 里有负值(对应瞬逝波),直接开方会得到nan并伴随一个警告,而这些位置的信息恰恰是有用的——瞬逝波对应的是纯虚数的 KZ,物理意义是指数衰减。用复数开方就能自然得到-1j*sqrt(-KZ2)这样的形式。
这一步非常容易出错的地方在于符号约定。傅里叶变换的相位因子用exp(-jkr)还是exp(+jkr),直接决定 KZ 该取正根还是负根、以及远场变换时该乘还是该除。这个不是 linspace 的问题,但和它紧密相关,因为一旦坐标轴方向反了(比如 linspace 的起点终点写反),整个相位符号体系就乱了,最终表现为远场方向图左右翻转或者上下翻转。我见过有人因为这个把探头旋转了 90° 重测,白干一天。
验证符号约定最快的方法:构造一个明显偏右的波束,看远场主瓣是不是出现在正的 theta 方向。如果翻了,先检查 linspace 的start和stop是不是写反了。
4.2 fftfreq 的频率轴顺序与 fftshift 的使用时机
np.fft.fftfreq返回的频率轴顺序是"先正后负":[0, 1, 2, ..., N/2-1, -N/2, ..., -1],不是单调递增的。这是 FFT 的标准输出顺序,不是 bug。
直接用它去画图会得到一张中间裂开的图。要么用np.fft.fftshift把数组和轴一起搬到中心对齐的顺序:
kx_shifted = np.fft.fftshift(kx) spectrum_shifted = np.fft.fftshift(spectrum, axes=(0, 1))要么就在计算阶段保持原顺序,只在最后出图的时候 shift 一次。
我的习惯是在变换完成后立刻 shift 一次,之后所有处理都在中心对齐的顺序下做。理由是后面要在谱域里乘以传播因子exp(-j*KZ*z)、要做滤波、要截取有效谱区域,这些操作在中心对齐的顺序下直觉更清楚:原点在数组中心,往两边是正负方向。
要注意的是:shift 必须对空间频率轴和谱数据同步做,做过一次之后不要再做第二次。我踩过的坑是对复数二维谱的axes参数写错了——只 shift 了第一个维度,第二个维度忘掉,结果是谱在 y 方向裂开,一度以为是扫描架 y 轴的丝杠误差。所以现在写代码一律写全axes=(0, 1),哪怕默认值是全部轴也写清楚。
4.3 从 k 空间回到角度空间的映射
从波数映射到远场角度,用的是方向余弦:
u = kx / k0 = sin(theta) * cos(phi) v = ky / k0 = sin(theta) * sin(phi)用linspace生成的角度扫描轴通常是 theta,而 u、v 是从 kx、ky 直接来的,两者不能混。想做 theta 切面图,需要从 (kx, ky) 网格里取出一条曲线上的值——这就是为什么很多人最后会转成用scipy.interpolate在 u-v 平面上插值到规则的 theta 网格上。
这里有个隐含前提:u-v 平面上的采样是规则的矩形网格(因为 kx、ky 就是从规则等间隔的空间轴 FFT 来的),所以在 u-v 上做双线性插值精度很好。如果前面的空间采样不是等间隔(比如用极坐标扫描架采的数据直接拿来用),这个前提就崩了,必须先重采样。这也是为什么前面反复强调 linspace 的等间隔性——它的价值会一直传递到最后一公里的角度插值。
5. meshgrid 的 indexing 参数:一张镜像了的方向图
这一节单独拿出来讲,因为它是近场代码里最隐蔽、最容易被当成硬件问题的 bug 源头。
5.1 xy 与 ij 的区别,以及场分布图为什么镜像了
np.meshgrid有两个 indexing 模式,默认是'xy',另一个是'ij'。
x = np.linspace(-0.2, 0.2, 5) y = np.linspace(-0.1, 0.1, 3) Xa, Ya = np.meshgrid(x, y, indexing='xy') # shape (3, 5) Xb, Yb = np.meshgrid(x, y, indexing='ij') # shape (5, 3)'xy'模式是笛卡尔惯例,第一个输出对应横轴、形状是(len(y), len(x));'ij'模式是矩阵惯例,形状是(len(x), len(y))。
'xy'的默认值是从绘图库借来的习惯,做二维图像显示很顺手。但近场测量处理链基本上都是矩阵运算思路:x 是第一维,y 是第二维,数据矩阵是field[i, j]对应(x[i], y[j])。这时候必须用'ij'。
我曾经因为混用这两个模式,出了一张"上下镜像"的方向图。当时的现象是:主瓣位置对,副瓣结构也对,但整个方向图在 v 方向上翻了。第一反应是 y 轴扫描方向定义反了,或者探头安装角度有了 180° 的偏差,差点去拆架子。后来把数据矩阵直接imshow出来,发现近场幅值图本身就有轻微的不对称——不对,是完全对称的图被镜像之后看起来对称。追到根因就是有两处meshgrid一个用了默认'xy'、一个显式写了'ij',中间经过一次矩阵乘法把维度的语义搞混了。
提示:在近场数据处理里,我建议全项目统一
indexing='ij',禁用默认值。哪怕要多打几个字符,也比事后追镜像 bug 划算。
5.2 用广播替代 meshgrid:省内存也省心
网格本身还有个实际问题:内存。一个 601 × 601 的扫描面,用meshgrid生成两个 float64 的二维数组,每个约 2.9 MB,看着不多。但如果网格再细一点,比如 1201 × 1201,单个数组就接近 11.5 MB,加上后面的复数场数组、复数谱数组、KZ 数组、传播因子数组,内存占用会迅速爬到几百 MB 甚至 GB 级别。做到三维扫描或者批量处理多频点时,很容易撞上内存墙。
替代方案是广播:
xcol = x[:, None] # shape (Nx, 1) yrow = y[None, :] # shape (1, Ny) phase = np.exp(-1j * k0 * np.sqrt(xcol**2 + yrow**2)) # shape (Nx, Ny)结果和meshgrid完全一样,但中间过程不会真的物化两个 (Nx, Ny) 数组,只有最后的结果数组。np 的广播机制会自动处理维度对齐,写成x[:, None]和y[None, :]之后,任何基于它们的表达式都会自动广播到二维。
这个写法我第一次见到的时候觉得有点"魔法",用熟之后发现它还有个好处:维度语义显式可读。x[:, None]明明白白告诉你"这个量在第一个维度变化",比meshgrid那种要记住 indexing 参数的方式清晰得多。
顺带说一个真实的性能对比。有一次处理一个 2001 × 2001 的近场数据集,需要生成球面波相位因子。用meshgrid的版本峰值内存大概 1.8 GB,跑一次要 40 多秒;改成广播写法之后峰值内存降到 600 MB 左右,时间降到 20 秒出头。差别主要是避免了大数组的中间拷贝,在内存带宽受限的机器上效果尤其明显。
6. 什么时候不该用 linspace:扫频场景下的 geomspace
前面讲的都是空间轴的场景。近场测量还有个常见的数组生成需求:频率轴。宽带测量、多频点近场变换、频域方向图汇总,都要生成扫频点。这时候linspace不一定是最优选择。
6.1 线性扫频与对数扫频的分辨率差异
假设要覆盖 1 GHz 到 18 GHz 这个很常见的宽带范围,取 101 个频点。
linspace(1e9, 18e9, 101)的间隔是 170 MHz。在低频端,1 GHz 处 170 MHz 相当于 17% 的相对带宽——这个分辨率太粗了,完全看不出低频段的谐振细节;而在 18 GHz 处,170 MHz 只有 0.94%,又显得过密。
np.geomspace(1e9, 18e9, 101)生成的是等比数列,相邻两点比值恒定。算下来每步约 1.0295 倍频,也就是 2.95%。这样在 1 GHz 处步进约 29.5 MHz,在 18 GHz 处步进约 530 MHz,低频细、高频粗,相对分辨率处处一致。
对数扫频的另一个好处是:显示在波特图式的对数频率轴上,点分布均匀,视觉上更自然,做频域平均或者趋势拟合时权重也更合理。
什么时候还是得用linspace?当你关注的是某个窄带内的细节,比如被测件的某个谐振腔模式附近需要密集采样,那就用linspace在局部密集、其他地方稀疏。我的做法是分段:低频段用 geomspace 打底做整体趋势,谐振区域用 linspace 局部加密,最后np.concatenate拼起来再np.unique去重。
f_lo = np.geomspace(1e9, 4e9, 61) f_res = np.linspace(3.8e9, 4.2e9, 81) # 谐振区局部加密 f_hi = np.geomspace(4.3e9, 18e9, 61) freq = np.unique(np.concatenate([f_lo, f_res, f_hi]))拼接之后点数不是整数没关系,测量系统按这个列表逐点扫就行。要注意的是拼接处的点不要太近,太近的两个频点在某些网络分析仪上会触发内部的中频带宽切换,带来轻微的不一致。一般保证最小间隔大于中频带宽的 3~5 倍比较稳妥。
6.2 实测非均匀数据重采样到均匀栅格
还有一种场景,数据和理想栅格不一致:扫描架的定位精度有限,实测位置和理论位置有零点几毫米的偏差;或者数据是从别的系统导过来的,栅格定义本身就和你现在的处理链不同。要做 FFT,就必须先重采样到严格的均匀栅格。
流程是:用linspace定义目标均匀栅格,然后把实测数据插值上去。
x_target, dx_target = np.linspace(x_meas.min(), x_meas.max(), N_target, retstep=True) # 复数数据要实部虚部分开插值 re = np.interp(x_target, x_meas, field.real) im = np.interp(x_target, x_meas, field.imag) field_uniform = re + 1j * im两个注意点。
第一,一定要实虚分开插值。直接在复数数组上调用np.interp会报错或者给出意外结果,因为复数没有全序关系,插值算法无法比较大小。分开之后各自线性插值再合成,这是正确做法。
第二,x_meas必须是严格单调递增的。实测的定位数据偶尔会出现相邻两点位置相同或者回退的情况(机械回程差、编码器抖动),这时候np.interp会静默给出错误结果。我现在的习惯是在插值前加一道检查:
assert np.all(np.diff(x_meas) > 0), "定位数据非单调,需要先做清洗"处理办法是把重复点或者回退点合并(幅相取平均或者取后一个),再继续。这道检查加进去之后,因为定位数据问题导致的诡异结果基本绝迹了。
如果数据质量比较差、需要更平滑的插值,可以上scipy.interpolate里的样条或者线性插值器,但要注意样条在数据边界处会有过冲,边界附近的外推结果不可信。近场数据的边界区域恰恰是对方向图副瓣贡献最大的区域,所以我一般只用线性插值,宁可稍微损失一点平滑度,也不要引入虚假的过冲。
7. 几个不起眼但反复救命的实操习惯
前面按功能模块讲完了,最后把一些零散但真实管用的经验凑在一起。这些东西看着琐碎,但每一条都对应一次具体的翻车。
7.1 把坐标轴和数据一起存下来
近场测量的原始数据文件,如果只存幅相矩阵不存坐标轴,后面任何一次重新处理都要靠"猜"当初的栅格定义。猜错了就是前面说的那些症状。
我的做法是每个数据文件存成一个npz,里面至少包含:数据矩阵、x 轴、y 轴、频率列表、实际步进、扫描面尺寸、处理代码的版本号。
np.savez_compressed( "scan_20240101_run03.npz", field=field_complex, x=x, y=y, freq=freq, dx=dx_actual, dy=dy_actual, wavelength=wavelength, code_version="nfproc-1.4.2", )code_version这一项是被一次深夜事故逼出来的。当时改了一版相位参考面的定义,但旧数据没有版本标记,第二天拿旧数据重跑,结果是新算法配旧假设,方向图完全不对,排查了好几个小时才发现是数据和处理代码不匹配。加了一行版本号之后,这类问题基本不会发生了。
压缩存储的代价可以忽略:复数场数据本身冗余度不高,但savez_compressed对重复结构的数据效果不错,我实测过一个 800 × 800 的复数矩阵,未压缩约 10 MB,压缩后大概 6 MB 出头。
7.2 每次变换前做形状断言
数据处理的 bug 里,维度不匹配占了相当大的比例,而且往往不会报错——numpy 的广播机制有时候会"善意"地帮你把错误的形状对齐,然后产生一个形状合法但语义完全错误的结果。
所以现在每个关键步骤之前我都加断言:
assert field.shape == (len(x), len(y)), "场数据与坐标轴长度不匹配" assert np.isclose(dx, dy, rtol=1e-9), "两方向步进不一致" assert field.dtype == np.complex128, "场数据必须是复数双精度"那个 dtype 检查也是有来历的。有一次为了省内存把中间结果转成了complex64,单精度下相位精度只有大约 1e-7 弧度,看起来够用。但近场变换涉及到跨波长距离的相位累加,一个 780 mm 的扫描面在 10 GHz 下累积的相位超过 160 弧度,单精度的相对误差被放大之后,远场方向图在深零点附近出现了明显的数值噪声,零点深度从 -40 dB 抬到 -28 dB。改回双精度之后恢复正常。
代价是内存翻倍,但在近场测量这个数据量级下(几百 MB),现代机器完全承受得起。空间轴的 dtype 也用 float64,不要用 float32 存坐标——坐标的精度直接决定 FFT 频率轴的精度。
7.3 记录采样参数时的单位约定
最后一个习惯,关于单位。近场代码里长度单位混用是重灾区:硬件手册用毫米,暗室标注用厘米,物理公式用米,波长计算用米,扫描架接口有的用微米。
我的规定是:所有进到 numpy 数组里的长度,一律用米,在读取和写出的时候做一次显式转换。转换函数写成这样:
MM = 1e-3 def mm(val): return val * MM用的时候写dx_target = mm(0.45 * 30),一眼能看出输入的 30 是毫米。这个习惯看着有点啰嗦,但它把单位错误从"运行时静默出错"变成了"代码里一眼可见"。我见过最惨的一次是有人把毫米当米传进波长参数,算出来的 k0 比真实值小 1000 倍,相位因子近似为常数,方向图出来是一个几乎全向的圆——因为所有相位都被抹平了。这种错误不报错,但结果荒谬,而荒谬的结果有时候反而让人先怀疑硬件。
np.linspace本身当然管不了单位,但它生成的数组是整条链路的输入,输入错了后面全错。所以把单位纪律和数组生成写在一起,是我目前的实践方式。
回到最开始那个观点:近场测量里真正决定成败的,往往不是那个复杂的两维傅里叶变换,而是最前面那几行定义坐标轴的代码。np.linspace短小、朴素、没什么技术含量,但它把"口径尺寸、采样步进、点数"这三个互相牵制的量用一个精确的接口固定下来了。把它的参数语义、端点约定、步进反馈这几件事吃透,后面那些看起来高深的变换才有意义。