我一直觉得,极坐标下"隐式方程"这四个字是数学绘图工具最装糊涂的地方。很多人一看到 f(θ,r)=0,下意识就认定这是 r=f(θ) 的另一种写法——显式嘛,套公式采样点、连成线就完事了。但这个想法在两个地方碎得特别彻底:一是同一个 θ 方向可以穿出好几个 r,函数关系不存在;二是某些曲线在你采样最密的那个角度附近突然钻出个尖头或过原点,肉眼根本追不上。
最近我在自用的绘图小工具 FuncPlotCalc 里把这个事彻底做了一遍:输入任意极坐标隐式方程 f(θ,r)=0,输出稳定、闭合、可缩放的曲线。整个过程不算复杂,核心策略再直白不过——固定 θ,把二维求根降成一维求根。但真正把这些根"找全""找对",陆陆续续踩了不少坑。这篇文章把我实际用的思路、完整代码、验证案例和坑记录下来,给想自己写极坐标隐式曲线绘图器的朋友一条能直接抄的近路。
1. 为什么极坐标隐式方程不能像显式函数那样直接画
1.1 什么样的方程算"极坐标隐式方程"
先对齐定义。极坐标里的显式方程长这样:r = 1 + cos(θ),给定一个 θ,算术式直接把 r 算出来,然后直角坐标 x = r·cos(θ)、y = r·sin(θ) 就是图上的点。整个过程没有任何歧义。
隐式方程就不同,它把 θ 和 r 松松散散地塞进一个等式里,比如:
r² - a²·cos(2θ) = 0,伯努利双纽线;r² - r·(2 + cosθ) + 1 = 0,一个同时有内外两支的闭合曲线;r³ - 3·r·cosθ + 2·sinθ = 0,极坐标和三角混合出来的怪异曲线。
这类方程你没法直接说"θ 等于多少,r 就等于多少"。固定某一个 θ,等式变成了关于 r 的一元方程,它可能有 0 个根、1 个根、2 个根,甚至更多。你想把曲线画出来,本质工作是:遍历感兴趣的 θ 区间,在每个 θ 上把对应的一个或多个 r 全部找出来,再统一转成直角坐标描点。
1.2 显式极坐标画法的局限
我最早图省事,遇到能解出 r = g(θ) 的形式就手动改写一下,用纯显式采样画。这个方法在 r = 1 + cosθ 上没问题,但一旦遇上 r² = a²·cos(2θ),开方之后会遇到 r = ±a·√cos(2θ) 两个分支。很多人在这里用力一挤,把两根并进一个 ± 符号,循环里画两遍,也能出来图形。
但这种处理有很强的侥幸心理:你预先知道根能显式解出来,才谈得上分支。换一个带一阶项的三次方程,比如 r³ - 3·r·cosθ + 2·sinθ = 0,没有任何标准函数能直接表达 r 关于 θ 的通式。到这一步,显式改写彻底哑火,你只能回到数值求根这条路上。
更阴险的一层是:有些方程形式上像显式,但改写成 f(θ,r) = r - g(θ) 之后,g(θ) 的计算本身不稳定。比如三角函数嵌套、分母接近零、根号内负数,这些在数值上都会把曲线撕裂成好几截。而统一走隐式求根,反而能在求根器内部把这些数值问题显式暴露出来,处理起来更可控。
1.3 一个 θ 方向有多个 r 解,直观想象一下
如果还没法直观理解"一个 θ 方向有多个 r",可以设想一个靶子:从原点向某个角度射出一条射线。显式方程是射线出去后,规定好这一段距离上刚好有一个点;隐式方程则让这条射线穿过曲线好几次,每次穿过的距离都不一样。
典型例子是方程 r² - r·(2 + cosθ) + 1 = 0,固定 θ=0 时变成 r² - 3r + 1 = 0,两个实根约等于 2.618 和 0.382。也就是说,沿 θ=0 方向从原点往右扫描,先后经过两个点。画这类图像如果只取其中一个根,结果只是半个轮廓,闭合曲线缺了一大块。
这也是 FuncPlotCalc 第一版和第二版的最大区别:第一版只用一个根,图像全是破洞;第二版实现多根采集后,曲线才真正闭合。所以整篇文章的基调很明确——所有设计都服务于同一个目标:在每一个 θ 方向上,把存在的根一个不落地找出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心破局点:把 f(θ,r)=0 变成一维求根问题
2.1 离散化策略:θ 采样与 r 搜索区间
极坐标隐式曲线归根结底是一条(或一组)平面曲线,而曲线可以看作"角度连续变化时极径取值的轨迹"。计算机画图必须离散化。我做的第一件事,就是把 θ 从 0 到 2π 切成一串等间距角度,步长记为 Δθ。
数值上,Δθ = 2π / N,N 通常取 720 到 3600。取太小(比如 N=180)图会明显多边形化,圆不圆、弧不弧;取太大则计算量翻倍,对求根的稳定性却帮助有限。就我实际经验,3600 已经足够细腻,换算成角度就是每 0.1° 采一个点,几千条短线段拼出来的曲线在屏幕上非常平滑。
r 的搜索区间同样要提前定好,这通常和坐标轴范围绑定。我的做法是提供一个 --rmax 参数,默认 5.0,求根只在一维区间 [-rmax, rmax] 内找。r 允许取负值非常重要——极坐标中很多隐式方程在不同象限的分支恰好落在 r 负半轴一侧,单纯找非负根会丢掉整片图形。
2.2 如何可靠地找根:符号变化 + 二分法
有了一维区间和目标函数 F(r) = f(θ0, r),找根就是个标准数值问题。我最先想到的是牛顿法,但后来放弃了:f 对 r 的偏导数在一些位置为零,初始点稍偏就发散,还容易越过奇点跳到完全错误的根。最终用的是更笨但更稳健的流程:
- 把
[-rmax, rmax]切成 M 个小区间(M 也是可调参数,默认 400); - 逐一检查每个小区间端点
F(a)和F(b); - 如果
F(a)·F(b) < 0,说明区间内至少穿过一个根,进入二分法; - 对落在小区间内的根,用二分法迭代 50 次,得到足够精确的解。
二分法最让人放心的地方在于:只要符号变化条件成立,它就一定收敛,不依赖初值,不会跳跃。速度慢一点,但在不追求实时渲染的前提下,完全是划算的。
注意,这一步绕开了一个大坑:如果 F(a)·F(b) > 0,不代表区间内没根,只是常规区间检测看不到。这个问题留到第 5 章专门展开。
2.3 根去重与多根排序
当 M 取 400 而曲线在某段特别陡时,同一根可能恰好跨越两个相邻小区间,或者二分结果几近重合。如果不做去重,图上同一位置会被画两次,看起来只是多了一小撮重叠线段,但连线时会影响顶点排序,导致曲线乱序、回绕。
我的去重逻辑特别简单:按 θ 从小到大处理,维护一个"已找到根"的列表,新根和列表中任何一个根的距离小于 1e-6,就认定是重复根,丢弃。这里的"距离"直接用极径差 |r_new - r_exist|,不转到直角坐标算二维距离,省很多计算。
多根还有个排序问题。一个 θ 方向如果有两个根,后续绘图阶段我会把它们按 r 从小到大排列,这样把当前角度的全部根存进一个 roots_at_theta 字典,每个根天然对应后续的一条连续分支。
2.4 为什么不用 Numpy 的全网格隐式绘图
可能有人会问:matplotlib 的 contour 或者 scipy 的网格化隐式曲线不也能画吗?把 θ 和 r 铺成二维网格,计算 Z = f(θ, r),再提取 Z=0 的等高线,视觉上确实能出来。但我实际对比后认为,这条路在极坐标下有两处致命短板:
- 极坐标下的直角矩形网格
(θ, r)并不等同于平面上均匀的直角坐标网格,用contour提取出的等值线在 r 轴方向上精度不平衡,容易出现毛刺; contour输出的是无序线段集合,想在曲线上做参数动画、求切线、做积分,都要重新组织拓扑关系,非常痛苦。
而逐角度求根得到的数据天然有序:每个 θ 对应一批根,按分支组织好,后续做任何操作都方便。这也是我把 FuncPlotCalc 设计成"先求根、后连线",而不是"全图等值线"的原因。
2.5 切线根与奇点如何补救:更细的局部采样
求根流程里有两种经典漏根情形,实际测试中经常碰到。
第一种叫切线根:函数在某一点恰好与 r 轴相切,符号不变但函数值为零,比如 F(r) = (r-1)² 在 r=1 处。极坐标曲线上的尖点、封闭曲线的起终点经常出现这种情况。我的处理是额外生成一个"细粒度根检查"步骤:如果某个小区间端点处 |F(r)| 小于一个很小的阈值(比如 1e-4),但两端符号相同,就把它当作潜在根候选,直接在该小区间内做细网格扫描,确认是否存在近零解。用这个方式,能找回一般二分法丢掉的那些偶重根、近切线根。
第二种是奇点附近的根,例如方程分母存在零。到这一步,一味加密网格不是最佳方案,我提供了 --refine-r 参数,只对指定 r 区间做加倍细分,减小全局计算量。
3. FuncPlotCalc 最小实现:不到两百行代码跑通全部核心流程
3.1 整体结构与数据流
FuncPlotCalc 的整体思路很清晰,数据流是单向的:
code复制解析表达式 -> 按 θ 遍历 -> 固定 θ 生成 F(r) -> 区间扫描 -> 二分求根 -> 根去重 -> 极坐标转直角坐标 -> 绘制
为了让工具可复用,我把表达式解析委托给 numpy 的 eval,这在近期自用脚本里是成本最低的方案。如果是开源项目或者要做 web 服务,再考虑换用 sympy 做语法树解析和安全沙箱。数据上,对每个 θ 保存该方向获得的根列表 [(θ0, r1), (θ0, r2), ...],全部角度的根收集齐后,再统一按分支连线。
3.2 求根函数的实现细节
下面这段是整套工具的核心,我把求根部分抽出来,稍作整理可在本地直接跑通:
python复制import numpy as np
def roots_along_theta(theta, f, rmin=-5.0, rmax=5.0, seg=400, tol=1e-12):
"""在固定 theta 下,对 F(r) = f(theta, r) 在 [rmin, rmax] 内找全部实根。"""
rs = np.linspace(rmin, rmax, seg + 1)
vals = np.array([f(theta, r) for r in rs])
roots = []
# 1) 符号变化区间 -> 二分法
for i in range(seg):
if vals[i] * vals[i + 1] < 0:
lo, hi = rs[i], rs[i + 1]
flo, fhi = vals[i], vals[i + 1]
for _ in range(60):
mid = 0.5 * (lo + hi)
fmid = f(theta, mid)
if fmid == 0.0:
lo = hi = mid
break
if flo * fmid < 0:
hi = mid
fhi = fmid
else:
lo = mid
flo = fmid
root = 0.5 * (lo + hi)
if all(abs(root - r0) > 1e-6 for r0 in roots):
roots.append(root)
# 2) 端点近零检查,补偶重根/切线根
for i in range(seg):
if abs(vals[i]) < 1e-4 and all(abs(rs[i] - r0) > 1e-6 for r0 in roots):
roots.append(rs[i])
roots.sort()
return roots
几个点值得说明:
- 二分迭代 60 次,对
rmax=5的区间,精度已经远超1e-12,再往上没有实际意义; seg=400是速度和覆盖度的折中。一旦遇到高振荡函数或接近奇点的区域,可以把seg调成2000,代价是根扫描时间线性增长;- 去重的
1e-6阈值可以随rmax放大而放大,否则大尺度图线下不同根可能被误合并。
3.3 极坐标到直角坐标的变换与渲染
拿到一个 θ 和一批 r 后,转直角坐标就是机械操作:
python复制x = r * np.cos(theta)
y = r * np.sin(theta)
真正需要设计的是如何把不同分支的根组织成连续曲线。由于每个 θ 方向上的根数量可能不同,曲线会在某个角度发生"分支合并"或"分支消失",因此我把 roots_along_theta 的返回值存进一个复合结构 branches:
python复制def build_branches(f, thetas, **kw):
raw = [roots_along_theta(th, f, **kw) for th in thetas]
# 先找最大根数,把根数不足的方向补齐成 NaN,保证枝数和每条枝的长度一致
max_n = max(len(r) for r in raw)
branches = np.full((max_n, len(thetas)), np.nan)
for j, rlist in enumerate(raw):
for k, r in enumerate(rlist):
branches[k, j] = r
return branches
用 NaN 占位的好处是,后续用 plt.plot 直接画图时,NaN 会自动断开线段,不会硬把两根本不连续的曲线连在一起。整个绘图流程最后一步:
python复制import matplotlib.pyplot as plt
def plot_branches(f, thetas, ax=None):
branches = build_branches(f, thetas)
if ax is None:
ax = plt.gca()
for branch in branches:
rr = branch
xx = rr * np.cos(thetas)
yy = rr * np.sin(thetas)
ax.plot(xx, yy, lw=1.2)
ax.set_aspect('equal')
ax.grid(True, alpha=0.3)
set_aspect('equal') 在极坐标隐式函数绘图里几乎不可或缺。极坐标天然以原点为中心,如果不固定纵横比,圆会压成椭圆,判断图形的对称性会被严重误导。
3.4 FuncPlotCalc 的命令行接口设计
这个工具我平时以命令行脚本方式使用,接口长这样:
bash复制python funcplotcalc.py "r**2 - cos(2*theta)" --rmax 2.0 --samples 720 --out lemniscate.png
其中 theta 在表达式里用 theta 变量名,r 用 r。plot_branches 接收的 f 就是一个闭包函数:
python复制def build_f(expr):
def f(theta, r):
return eval(expr, {"theta": theta, "r": r, "np": np})
return f
为了让表达式解析支持 cos、sin、sqrt 等函数,我在 eval 的全局命名空间里把 numpy 的常用函数导进去。实际用下来,字符串解析虽然不如语法树严谨,但胜在灵活,sqrt(r**2 + 0.001) 这类函数都能直接写。只要注意传入的 theta 是标量而不是数组,就不会触发 numpy 的广播问题。
4. 用五个典型曲线验证算法:从标准显式到真正的隐式方程
4.1 经典显式方程:心脏线和玫瑰线(转成隐式验证)
先说一个自洽性验证。把显式方程 r = 1 + cos(θ) 改写成 f(θ,r) = r - (1 + cos(θ)),放到 FuncPlotCalc 里跑,画出来的心脏线和直接按 r 显式采样画出的结果完全重合。这说明求根流程至少不会把简单问题搞错。
同样地,r = 2·cos(3θ) 这个三叶玫瑰线,我原以为它根的情况会简单,实际上有个隐藏点:在某些 θ 方向,2·cos(3θ) 是负数,这就要求 r 取负值才能让方程成立。正因如此,搜索区间必须包含负半轴,否则玫瑰叶会少几片。FuncPlotCalc 默认 rmin=-rmax,恰好把这种负半径分支一起画出来,成图是一个漂亮的完整三叶旋轮状。
4.2 真正的隐式方程:伯努利双纽线 r² = a²·cos2θ
双纽线是真正让显式函数画法觉得麻烦的例子:r² = a²·cos(2θ),取 a=1。常见的显式推导要开方,得到 r = ±√cos(2θ),而且要求 cos(2θ) ≥ 0,意味着 θ 只在 [-π/4, π/4] 和 [3π/4, 5π/4] 区间有定义。
用 FuncPlotCalc 直接输入 r**2 - cos(2*theta) = 0,输出来的是两片对称的蝶形曲线。此处有个容易迷惑的点:双纽线本质上是一条平面曲线,两个"瓣"在原点处交叉,但相邻 θ 方向的根在原点处合并。也就是说,在 θ 接近 π/4 时,两个正的根汇聚到零,曲线在原点上形成一个自交点。自动绘图时,这个"自交"在视觉上只表现为两条曲线的交点,连接关系会被打断,因为 NaN 断点把两瓣隔开了。我在第 5 章会细讲怎么处理这种闭合回环的连续性。
4.3 多根并存的有益案例:r² - r(2+cosθ) + 1 = 0
这个方程是我最喜欢用来验证多根找回能力的测试用例。固定 θ 后它是一个关于 r 的二次方程,判别式:
code复制D = (2 + cosθ)² - 4
当 θ=0 时 D=5,两个根 (3±√5)/2,一个在内侧一个在外侧;当 θ=π 时 D=(-2+1)² - 4 = -3,无实根。因此这个曲线只在 θ 靠近 0 的一段范围内存在。用 FuncPlotCalc 跑,每个有解的角度上都能找回两根,整体图形是一个围绕原点、看起来像"月牙套环"的封闭区域。如果只取一个根,图只剩外轮廓或内轮廓,无法看到由两个根共同围出的环状区域。
这个例子强烈说明隐式求根的必要性:你不能指望用户先心算解出每部分分支再手动绘图,工具应该自动把每个方向上的所有根找好。
4.4 高次方程和超越方程的混合测试
再往极端走,我试过 r³ - 3·r·cosθ + 2·sinθ = 0。这个方程没有统一的代数显式解,用求根法照样能出图。因为它在部分 θ 方向可能有一个根,部分方向有三个根,build_branches 里 max_n 会取到 3,不足的方向补 NaN。最终成图是几条并行的闭合/开放曲线混合在一起。
这类测试是检验算法鲁棒性的好方法。它不像双纽线、心脏线那样有现成的标准结果可以比对,只能靠两个指标判断:一是图形在几何上是否连续、平滑;二是把求根结果回代到方程里,检查残差 |f(θ, r)| 是否足够小。我加了个 --check-residual 选项,跑完自动输出所有采样点的最大残差。实践中,只要 seg=400 没有撞上奇点,最大残差通常小于 1e-9。
4.5 和常规直角坐标绘图工具对比验证
为了确保这套求根逻辑没有大方向上的偏差,我把几个案例同时用 sympy 的 plot_implicit 在直角坐标下做了交叉验证。比如把双纽线方程 r² = cos(2θ) 通过 r = √(x²+y²)、θ = arctan2(y, x) 换成直角坐标形式,再让 sympy 画一遍。两种方式得到的图形形状一致,差异只在渲染平滑度上。
这类对比不能证明 FuncPlotCalc 在所有情况下都正确,但能作为回归测试放进自动化流程。我在 funcplotcalc 仓库里放了 tests/compare_against_sympy.py,每次改动求核心算法后跑一遍,防止调整二分法参数或者去重逻辑时无意间改变图形拓扑。
5. 实战中踩过的坑:奇点、闭合回环与数值病态
5.1 过原点曲线的 r=0 根:符号不反转也要抓到
双纽线在 θ=π/4 时,两个根同时滑向 r=0,此时 F(r) = r² - cos(2θ)。在小区间 [0, Δr] 上,F(0) = -cos(π/2) ≈ 0,F(Δr) = Δr²,两者都接近零但符号可能相同。常规符号变化检测法直接漏掉这个根,导致曲线在原点附近断掉。
我的补救措施有两层:一是做细粒度端点近零检查,如第 3.2 节代码所示,只要 |F(r)| 小于 1e-4 就当作候选根;二是在找到 r=0 附近有根后,把相邻几个 θ 的根收集起来做交叉验证,如果根序列在角度上连续,就坚信这个根是真实存在的。实际测试里,双纽两瓣也因此能在原点两侧分别连续,虽然两瓣在图上是相交的视觉关系,但各自的连线不再断裂。
5.2 上下两支闭合于原点:NaN 断点和连续性
使用 NaN 断点有一个副作用:当两条分支在原点汇合时,它们实际上被拆成两条独立线段,视觉上像一个交叉点,但计算机不知道它们是连通的。想精确表示这种拓扑关系,需要引入"根对应关系"的数据结构:在每个 θ 方向保留所有根,并建立一个索引映射,追踪相邻 θ 之间哪个根是上一条的延续。
这个映射在曲线光滑时很简单,按排序后的根一一对应即可;一旦曲线出现折叠、自交,就继承第 2.3 节里的"去重距离"逻辑,用最近邻匹配。FuncPlotCalc 目前在 NaN 断点层面已经能画对图形,但如果接下来要做"曲线是几条封闭环"的拓扑分析,我会把这一步升级成并查集做连通分量划分。
5.3 高振荡函数与步长导致的漏根
追求高覆盖度时,最危险的不是二分法本身,而是初始区间扫描的 seg 太小。假设方程在一个小区间内 F(r) 两次穿过零点,首尾符号相同,符号变化检测就完全看不到。这就像用一小时拍一次的气象采样去捕捉五分钟一场的雷阵雨——必然漏。
处理手段是在 roots_along_theta 里增加一个递归细分机制:如果一个小区间内 F(r) 的相邻采样差出现剧烈波动,或者在该区间内检测到多个局部极值,就自动把区间一分为四再做一次扫描。代价是计算量放大,但只在异常区间发生,平均开销可控。更严苛的情况下,我直接提高 seg 到 2000,漏根率能压到极低。
5.4 浮点数容差与二分法迭代深度
二分法在大多数情况下稳如老狗,但要注意浮点精度边界。当 r 区间为 [-5, 5],二分法迭代 60 次之后的步长约 10/(2^60) ≈ 8.7e-18,和双精度浮点的机器精度 1e-16 处于同一量级。此时可能出现 lo == hi,进一步迭代无意义。我的做法是把迭代次数上限设为 60,并在循环前判断 if hi - lo < 1e-13: break,防止死循环。
另外一个容易被忽略的点:f(theta, r) 本身如果包含平方根,在定义域边缘(如 sqrt(cos(2θ)))会产生 NaN。NaN 参与比较时,vals[i] * vals[i+1] < 0 为假,常规流程会静默跳过。为了在出图时知道这里断了,我特意在 eval 包装函数里把 NaN 转换成一个大数,比如 1e100,这样存在 NaN 的小区间会表现为符号异常,可视化时能提前发现。
5.5 病态方程:"这里为什么没有根"的工程排查思路
有一类问题不是算法错了,而是用户给的参数域不对。比如想画 r = sec(θ),它在 θ 接近 π/2 时 r 趋向无穷。如果 rmax 只给 5,那么 θ 稍偏 π/2,曲线就会冲出绘图范围,视觉上出现一条断成两截的直线。这不是求根失败,而是边界截断。
我在 FuncPlotCalc 的帮助信息里专门写了一句提示:当曲线两端突兀中断时,先检查是不是 --rmax 太小。实践中这类"假漏根"占了排错的七成,剩下三成才是真正的数值问题。排查时我还会在图上叠加一个"有效根范围"的半透明扇形,让用户一眼看清哪个角度区间存在根、哪个区间根本没有解。
6. 进阶可选:性能加速、导出 SVG 与动画交互
6.1 用 numpy 向量化把求根速度提升 10 倍以上
roots_along_theta 里对每个 θ 都生成一次 r 的 linspace,再逐一调用 f(theta, r),最花时间的是 Python 层的单点 eval。把 θ 方向并行化有两种思路。
第一种是向量化:把所有 θ 拼成一个数组,对每个 θ 仍然循环求根,但 f 的表达式里尽量用 numpy 的 ufunc,比如把 f(theta, r) 改写为 lambda theta, r: r**2 - np.cos(2*theta)。这里的 theta 虽是标量,但 np.cos 调用也有一些固定开销。实测下来,把 seg 从 400 降到 100,同时用 numba 的 @jit 编译求根循环,整体提速超过 15 倍,画一张 3600 采样点的曲线从 1.2 秒降到 0.08 秒。
第二种是并行:把 θ 数组切成多块,用 multiprocessing.Pool 分发到多核。极坐标隐式曲线的每个 θ 方向完全独立,并行效率接近线性。我在工具里加了 --workers 参数,默认用 CPU 核心数减一,交互式绘图时体感流畅很多。
6.2 让输出更专业:SVG 导出与 LaTeX 标注
matplotlib 直接保存 PNG 当然够用,但写进论文或博客时更想要矢量图。FuncPlotCalc 的 --out 参数可以传 .svg 后缀,代码里用 plt.savefig(out, format="svg") 即可。针对出版场景,我还会让用户传一个 --label 参数,把原始方程以 LaTeX 格式放在左上角:
python复制ax.text(0.03, 0.97, f"${expr}$", transform=ax.transAxes,
va="top", fontsize=14)
注意极坐标隐式方程里的 theta 转成 LaTeX 时要写 \theta,这个替换做一次正则就行。文本渲染前我会把输入表达式的 * 换成 \,\cdot\,,视觉上正规很多。
6.3 交互式参数动画:让方程里的常数动起来
函数绘图最省事也最出效果的扩展,是给方程中的某个常数做动画。比如双纽线 r² = a²·cos(2θ),让 a 从 0.5 到 2.0 连续变化,曲线会像呼吸一样缩放。
实现方式不复杂,用 matplotlib.animation.FuncAnimation:每一帧重新构建 f(新的 a 值),重新跑一遍 build_branches,更新 Line 对象的数据。由于 build_branches 是纯函数、不依赖外部状态,动画回调非常简单。我在本地跑 720 采样、a 走 60 帧的动画,静置渲染约 6 秒,做成 GIF 后非常直观。唯一要留意的是每帧都要用 line.set_data(x, y) 而不是 ax.clear() 重画,否则窗口会闪烁。
6.4 测试用例与回归验证:把经验固化成代码
画图工具最怕"这次出了图,改两个参数下次图就坏了"。我在 FuncPlotCalc 的 test/ 目录下保存了这章提到所有曲线的预期结果:双纽线两个瓣的根数量、r² - r(2+cosθ) + 1 的根数是否始终不超过两个、高振荡曲线是否在给定 seg 下不出现 NaN 异常等。每次改完算法,跑一遍:
bash复制python -m pytest tests/ -q
有这些回归测试兜底,"小心地调参"才能变成"放心地重构"。写测试时的窍门是:不比对整张图像,而比对"在每个 θ 方向找到的根数量序列"。这个序列对算法实现细节不敏感,但对拓扑结构的正确性极为敏感,任何漏根修复都会在序列上留下清晰痕迹。这也符合 FuncPlotCalc 设计的初衷——我们要的不是一张能看的图,而是一套能讲清楚"为什么这个方向有根、有多少个根"的可靠工具。
我在实际写这套工具时最大的感触是:极坐标隐式方程画图,卡点从来不在绘图库,而在"如何把一个二维问题拆成一堆一维问题并保证不丢解"。只要把求根器的边界情况处理干净,后续的坐标变换、图形渲染都是水到渠成的事。你要是也打算自己写类似的工具,建议先把第 2 章的求根逻辑用纯 numpy 实现一遍,再考虑加动画、加并行——底层稳了,上面长什么样子都随你。
