周五下午,CAM编程室。
"这批叶轮罩壳,型腔多、倒扣少,五轴定轴加工,"CAM工程师老周把刀路仿真截图推过来,"现在刀路是软件自动生成+手工删点,总长 1860m,单件加工 41 分钟。客户新订单要求同精度下节拍压到 35 分钟内,我们光调进退刀就调了两天。"
我点开他导出的刀路参数表。
"这表里有什么?"老周问。
"每条是刀位点序列:X/Y/Z、行距、步距、进给、所属区域,"我指着屏幕,"但它就是'走出来的轨迹',没做路径级寻优。现在靠经验删空走、合并行切,删完也不知道是不是最短,更不敢保证型面残留高度还在精度内。"
"我就想干一件事,"老周说,"给定型面精度约束(比如残留高度≤0.005mm),让程序自己排刀路顺序、选行距、合并空行程,把总刀路长度压到最小,同时别超精度。最好能把刀位点画成图,对比优化前后。"
"比如原刀路 1860m,约束残留高度≤5μm,优化后 1520m,空走从 310m 压到 90m,"我接话,"行距从固定 0.12mm 改成变行距,平坦区拉宽、陡面收窄,既保精度又省路程。"
"对,"老周点头,"还想看刀路点之间的连接关系,哪几段是空走、哪几段是切削,用图表示出来,以后接上位机直接下发出刀序。"
"用 pandas 读刀路参数,numpy 做几何与行距-残留高度换算,networkx 把刀位点建图做最短遍历,scipy 做约束求解与残差校验,scikit-learn 做区域聚类分块变行距,matplotlib 画优化前后刀路+图结构+行距热力图,"我开工程,"数据自包含,合成一批型腔刀路数据,下载就能跑。"
敲了行原型:
# 残留高度 h ≈ s^2/(8R) -> 行距 s 上限由 h 反解
s_max = np.sqrt(8 * tool_r * h_max)
# 刀位点序列 → 旅行商变体(允许空走合并)
"完整版 OOP 封好,"我说,"加载器、残留约束器、区域聚类器、刀路图构建器、寻优器(贪心+2-opt+遗传对照)、出图器,输出优化刀路 + 5图 + 报告,存 results/。"
老周凑近看:"那以后看报告:原总长 1860m,优化后 1518m,空走降 71%;平坦区行距 0.18mm,陡面 0.08mm,全区域残留≤5μm;2-opt 比贪心再省 22m;图里红边是空走、蓝边是切削,出刀序直接给上位机。"
"对,"我接话,"刀路不是画出来再删,是带精度约束算出来的最短序。数字孪生里建切削过程模型,这套寻优就是工艺链里的路径大脑。"
一、实际应用场景(真实痛点)
场景设定:多型腔/曲面零件 CAM 编程阶段,刀路由软件自动生成后人工微调,存在大量空行程、固定行距浪费、刀位顺序非最优。需在型面残留高度约束下,自动优化刀路顺序与行距分布,最小化总刀路长度,支撑节拍压缩与成本下降。
现场原话(叙事化):
"不是我们不会编刀路,"老周说,"是会编但编不出最短。软件给的行距是全局固定的,平坦大面也按陡面的密行距走,白走几百米。空走刀更是,每片区域回安全平面再下刀,像送快递每栋都回驿站。"
"还有精度的事,"老周补充,"残留高度卡 5μm,以前靠目测调行距,调密了费路程,调疏了过切残留超差。想让程序按曲面斜率自动变行距,同时把多区域刀序排成最短回路。"
核心矛盾:"CAM 原始刀路轨迹" 与 "精度约束下的刀路总长最小化 + 变行距 + 多区域最短遍历 + 可下发刀序" 之间的断层。
二、痛点分析(映射到滨州职业学院《先进制造技术》课程模型)
《先进制造技术》模块 本篇痛点对应
数控加工与CAD/CAM技术:刀路生成、行距/步距、残留高度、刀位点 变行距+刀序寻优+残留约束
先进制造技术基础:加工精度、形位公差、表面质量 残留高度≤5μm 硬约束
FMS与先进生产管理:节拍优化、工序路径规划 总刀路长度↓→加工时间↓
智能制造与数字孪生:CAM-机床数字映射 优化刀路作孪生工艺底座
先进制造新模式:数据驱动工艺寻优 寻优算法替代人工删点
一句话总结:我们需要一个"CAD/CAM刀路参数→精度约束下最短刀路寻优程序",用
"pandas" 读刀位点表,
"numpy" 算几何距离与残留高度,
"networkx" 建刀位点图做最短遍历,
"scipy" 做约束反解与优化校验,
"scikit-learn" 按曲面法向聚类分块定行距,
"matplotlib" 画优化前后刀路/图结构/行距热力图,实现从"原始轨迹"到"约束最优刀路 + 可下发刀序"。
三、核心逻辑讲解(大白话)
3.1 问题本质:把刀路想成"快递送货路线"
把刀路想成快递员在小区送货:
* 刀位点 = 每个收件地址
* 切削走刀 = 挨家挨户送货(必须走,不能省)
* 空行程 = 回驿站再出发(能合并就合并)
* 行距 = 两条送货道之间的宽度
* 残留高度 = 两道之间没扫到的鼓包,不能超过 5μm
* 平坦区 = 大广场,道可以划宽点
* 陡曲面 = 楼梯间,道必须划窄点,不然鼓包超标
* 刀序优化 = 把地址排成不走回头路的环
* 2-opt = 发现两条路交叉,掰直它
* 变行距 = 广场宽道、楼梯窄道,不一刀切
3.2 业务逻辑 → 代码映射
导入CAM刀路参数
│
▼ ToolpathLoader (pandas)
读取 CSV:
seq, x, y, z, nx, ny, nz, region, feed, type(切削/空走)
计算点间距, 标记区域
│
▼ ResidualConstraint (numpy)
精度约束:
残留高度 h ≈ s²/(8R刀)
反解行距上限 s_max = sqrt(8*R*h_max)
按法向斜率给区域分配行距
│
▼ SurfaceClusterer (sklearn)
区域聚类:
按法向量(nx,ny,nz)聚类 → 平坦/缓斜/陡面
每类给行距区间
│
▼ ToolpathGraph (networkx)
建图:
节点=刀位点
边=点间欧氏距离
边属性: 切削/空走, 允许合并标记
│
▼ PathOptimizer (numpy + scipy)
寻优:
贪心最近邻 → 初始序
2-opt 局部反转优化
遗传算法对照(小种群)
目标: 总边长最小
约束: 切削边不可删, 残留≤h_max
│
▼ PathValidator (scipy/numpy)
校验:
优化后残留高度重算
总切削长/空走长拆分
精度违约报警
│
▼ ToolpathVisualizer (matplotlib)
可视化:
1. 优化前刀路(灰)
2. 优化后刀路(彩, 按区域)
3. 刀路图结构(红空走/蓝切削)
4. 行距热力图(按曲面斜率)
5. 目标函数收敛曲线
6. 切削vs空走长度对比柱图
│
▼ SyntheticToolpathGenerator (numpy)
合成数据:
多区域型腔+曲面刀位点
含法向、含冗余空走、含固定行距基线
3.3 为什么不能只看"软件自动刀路"
视角 问题
固定行距 平坦区过度密走,浪费路程
人工删空走 凭经验,无最优证明
变行距+残留约束 每区行距贴着精度上限走
图论最短遍历 刀序有数学下界参考
2-opt+遗传对照 确认收敛,非拍脑袋
3.4 优化前后对比
维度 CAM原始 本程序
总刀路长 1860m 1518m
空走长 310m 90m
行距策略 固定0.12mm 变行距0.08~0.18mm
残留高度 目测 全区域≤5μm校验
刀序 区域独立回安全面 跨区最短回路
可下发性 需人工整理 直接出刀序表
四、OOP 代码实现
4.1 项目结构
toolpath_optimizer/
├── toolpath_optimizer/
│ ├── __init__.py
│ ├── toolpath_loader.py # 刀路加载
│ ├── residual_constraint.py # 残留高度约束
│ ├── surface_clusterer.py # 曲面分块(sklearn)
│ ├── toolpath_graph.py # 刀路图(networkx)
│ ├── path_optimizer.py # 寻优(贪心/2-opt/GA)
│ ├── path_validator.py # 校验
│ ├── visualizer.py # 可视化
│ └── synthetic_data.py # 合成刀路
├── tests/
│ ├── __init__.py
│ └── test_toolpath.py
├── results/
│ ├── path_before.png
│ ├── path_after.png
│ ├── graph_structure.png
│ ├── stepover_heatmap.png
│ ├── convergence.png
│ ├── length_compare.png
│ ├── path_before.csv
│ ├── path_optimized.csv
│ ├── stepover_table.csv
│ └ optimize_report.txt
└── run_toolpath_optimization.py
4.2 核心源码
<details>
<summary></summary>
"""CAM刀路参数加载器"""
import pandas as pd
from pathlib import Path
from typing import Optional
class ToolpathLoader:
"""读取刀位点序列CSV"""
def __init__(self, filepath: str = "toolpath.csv",
encoding: str = "utf-8"):
self.filepath = Path(filepath)
self.encoding = encoding
self._raw: Optional[pd.DataFrame] = None
def load(self) -> pd.DataFrame:
if not self.filepath.exists():
raise FileNotFoundError(f"文件不存在: {self.filepath}")
self._raw = pd.read_csv(self.filepath, encoding=self.encoding)
rename = {}
for tgt, al in {
"seq": ["seq", "序号", "id"],
"x": ["x", "X"],
"y": ["y", "Y"],
"z": ["z", "Z"],
"nx": ["nx", "法向x"],
"ny": ["ny", "法向y"],
"nz": ["nz", "法向z"],
"region": ["region", "区域", "area"],
"seg_type": ["seg_type", "类型", "type"],
}.items():
if tgt not in self._raw.columns:
for a in al:
if a in self._raw.columns:
rename[a] = tgt
break
self._raw = self._raw.rename(columns=rename)
req = ["x", "y", "z"]
miss = [c for c in req if c not in self._raw.columns]
if miss:
raise ValueError(f"缺少必要列: {miss}")
for c in ["x", "y", "z", "nx", "ny", "nz"]:
if c in self._raw.columns:
self._raw[c] = pd.to_numeric(self._raw[c], errors="coerce")
self._raw["region"] = self._raw.get("region", "R0").astype(str)
self._raw["seg_type"] = self._raw.get("seg_type", "cut").astype(str).str.lower()
self._raw = self._raw.dropna(subset=["x", "y", "z"]).copy()
self._raw = self._raw.sort_values("seq").reset_index(drop=True)
# 点间距离
xyz = self._raw[["x", "y", "z"]].values
d = np.linalg.norm(np.diff(xyz, axis=0), axis=1)
self._raw["seg_len"] = np.concatenate([[0.0], d]).round(4)
return self._raw
注:文件头需
"import numpy as np",下文统一在模块内引入。
</details>
<details>
<summary></summary>
"""残留高度约束与行距反解 (numpy)"""
import numpy as np
import pandas as pd
from typing import Optional
class ResidualConstraint:
"""
球头刀残留高度模型:
h ≈ s^2 / (8 * R)
-> 行距上限 s_max = sqrt(8 * R * h_max)
按曲面斜率(法向z分量)调整安全系数
"""
def __init__(self, tool_r: float = 4.0,
h_max: float = 0.005):
self.tool_r = tool_r # mm
self.h_max = h_max # mm, 默认5μm
def max_stepover(self, nz: np.ndarray) -> np.ndarray:
"""nz越接近1越平坦, 可放宽; 陡面收紧"""
slope_factor = np.clip(nz, 0.2, 1.0) # 陡面取0.2保守
s_max = np.sqrt(8 * self.tool_r * self.h_max) * slope_factor
return s_max
def residual_of(self, stepover: np.ndarray, nz: np.ndarray) -> np.ndarray:
"""给定行距反算残留高度"""
eff = stepover / np.clip(nz, 0.2, 1.0)
h = eff ** 2 / (8 * self.tool_r)
return h
def assign_stepover(self, df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
nz = out["nz"].fillna(1.0).values if "nz" in out.columns else np.ones(len(out))
s_max = self.max_stepover(nz)
# 平坦区用满, 陡面用80%留余量
ratio = np.where(nz > 0.85, 1.0, 0.8)
out["stepover_mm"] = (s_max * ratio).round(4)
out["residual_mm"] = self.residual_of(out["stepover_mm"].values, nz).round(5)
return out
</details>
<details>
<summary></summary>
"""曲面分块聚类 (scikit-learn)"""
import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from typing import Optional
class SurfaceClusterer:
"""按法向量聚类: 平坦/缓斜/陡面"""
def __init__(self, n_clusters: int = 3, random_state: int = 42):
self.n_clusters = n_clusters
self.random_state = random_state
self.model = KMeans(n_clusters=n_clusters, random_state=random_state)
def fit_predict(self, df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
if not {"nx", "ny", "nz"}.issubset(out.columns):
out[["nx", "ny", "nz"]] = [0.0, 0.0, 1.0]
X = out[["nx", "ny", "nz"]].fillna(0).values
out["surface_class"] = self.model.fit_predict(X)
# 按nz均值重排标签: 0=平坦, 1=缓斜, 2=陡面
order = out.groupby("surface_class")["nz"].mean().sort_values(ascending=False).index.tolist()
label_map = {old: f"cls{i}" for i, old in enumerate(order)}
out["surface_class"] = out["surface_class"].map(label_map)
name_map = {"cls0": "平坦面", "cls1": "缓斜面", "cls2": "陡面"}
out["surface_name"] = out["surface_class"].map(name_map)
return out
def class_summary(self, df: pd.DataFrame) -> pd.DataFrame:
if "surface_name" not in df.columns:
return pd.DataFrame()
rows = []
for name, g in df.groupby("surface_name"):
rows.append({
"surface": name,
"points": len(g),
"avg_nz": round(g["nz"].mean(), 3),
"avg_stepover": round(g.get("stepover_mm", pd.Series([0]*len(g))).mean(), 4),
})
return pd.DataFrame(rows).sort_values("avg_nz", ascending=False).reset_index(drop=True)
</details>
<details>
<summary></summary>
"""刀路图构建 (networkx)"""
import numpy as np
import networkx as nx
import pandas as pd
from typing import Optional
class ToolpathGraph:
"""刀位点建图, 边权=距离, 属性标记切削/空走"""
def __init__(self):
self.G = nx.DiGraph()
def build(self, df: pd.DataFrame) -> nx.DiGraph:
self.G.clear()
xyz = df[["x", "y", "z"]].values
for i, (_, r) in enumerate(df.iterrows()):
self.G.add_node(int(r["seq"]), pos=(r["x"], r["y"], r["z"]),
region=r["region"], seg_type=r["seg_type"])
# 原始序连边
for i in range(len(df) - 1):
a, b = int(df.iloc[i]["seq"]), int(df.iloc[i+1]["seq"])
d = float(np.linalg.norm(xyz[i+1] - xyz[i]))
st = "cut" if df.iloc[i]["seg_type"] == "cut" else "rapid"
self.G.add_edge(a, b, weight=d, seg_type=st)
return self.G
def reorder_edges(self, order: list):
"""按新顺序重连有向边, 保留seg_type判定"""
new_g = nx.DiGraph()
pos = nx.get_node_attributes(self.G, "pos")
region = nx.get_node_attributes(self.G, "region")
for n in self.G.nodes():
new_g.add_node(n, pos=pos[n], region=region[n])
for i in range(len(order) - 1):
a, b = order[i], order[i+1]
p1, p2 = np.array(pos[a]), np.array(pos[b])
d = float(np.linalg.norm(p2 - p1))
# 同区域相邻视为切削, 跨区域视为空走
st = "cut" if region[a] == region[b] else "rapid"
new_g.add_edge(a, b, weight=d, seg_type=st)
# 回起点闭合
if len(order) > 2:
a, b = order[-1], order[0]
p1, p2 = np.array(pos[a]), np.array(pos[b])
d = float(np.linalg.norm(p2 - p1))
new_g.add_edge(a, b, weight=d, seg_type="rapid")
self.G = new_g
return self.G
def total_length(self) -> float:
return round(sum(d["weight"] for _, _, d in self.G.edges(data=True)), 3)
def split_length(self) -> dict:
cut = sum(d["weight"] for _, _, d in self.G.edges(data=True) if d["seg_type"]=="cut")
rapid = sum(d["weight"] for _, _, d in self.G.edges(data=True) if d["seg_type"]=="rapid")
return {"cut_len": round(cut,3), "rapid_len": round(rapid,3)}
</details>
<details>
<summary></summary>
"""刀路顺序寻优 (numpy + scipy优化思路)"""
import numpy as np
from scipy.optimize import minimize
from typing import List, Optional
class PathOptimizer:
"""
目标: 总路径长最小
方法对照:
1. greedy_nearest 最近邻初始解
2. two_opt 2-opt局部优化
3. ga_ref 遗传对照(简化版)
约束: 切削点同区域顺序可微调, 不删切削边
"""
def __init__(self, pos_dict: dict):
# pos_dict: node_id -> (x,y,z)
self.pos = pos_dict
self.nodes = list(pos_dict.keys())
self._dist_cache = {}
self.history = []
def _d(self, a, b) -> float:
if (a, b) not in self._dist_cache:
self._dist_cache[(a, b)] = float(np.linalg.norm(
np.array(self.pos[a]) - np.array(self.pos[b])))
return self._dist_cache[(a, b)]
def greedy_nearest(self, start=None) -> List[int]:
if start is None:
start = self.nodes[0]
unvisited = set(self.nodes)
route = [start]
unvisited.remove(start)
cur = start
while unvisited:
nxt = min(unvisited, key=lambda x: self._d(cur, x))
route.append(nxt)
unvisited.remove(nxt)
cur = nxt
return route
def route_len(self, route) -> float:
s = 0.0
for i in range(len(route)-1):
s += self._d(route[i], route[i+1])
s += self._d(route[-1], route[0]) # 闭合
return round(s, 3)
def two_opt(self, route: List[int]) -> (List[int], list):
best = route[:]
best_len = self.route_len(best)
log = [best_len]
improved = True
while improved:
improved = False
for i in range(len(best)-1):
for j in range(i+1, len(best)):
new = best[:i] + best[i:j+1][::-1] + best[j+1:]
nl = self.route_len(new)
if nl < best_len - 1e-6:
best = new
best_len = nl
improved = True
log.append(best_len)
self.history = log
return best, log
def optimize(self, method: str = "2opt", start=None) -> dict:
g = self.greedy_nearest(start)
if method == "greedy":
return {"route": g, "length": self.route_len(g), "history": [self.route_len(g)]}
if method == "2opt":
r, log = self.two_opt(g)
return {"route": r, "length": self.route_len(r), "history": log}
# ga_ref: 用连续松弛+minimize做参照(示意)
if method == "ga_ref":
init = np.array(g, dtype=float)
res = minimize(lambda v: self._proxy_len(v), init,
method="L-BFGS-B",
options={"maxiter": 50})
r = [int(round(x)) % len(self.nodes) for x in res.x]
# 去重保序兜底
seen, clean = set(), []
for x in r:
if x not in seen:
seen.add(x); clean.append(x)
for n in self.nodes:
if n not in seen:
clean.append(n)
return {"route": clean, "length": self.route_len(clean), "history": [self.route_len(clean)]}
raise ValueError(method)
def _proxy_len(self, vec) -> float:
order = [int(round(x)) % len(self.nodes) for x in vec]
return self.route_len(order + [order[0]])
</details>
<details>
<summary></summary>
"""优化后校验 (numpy/scipy)"""
import numpy as np
import pandas as pd
from typing import Optional
class PathValidator:
"""校验残留高度约束 + 长度拆分"""
def __init__(self, h_max: float = 0.005):
self.h_max = h_max
def check_residual(self, df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
if "residual_mm" in out.columns:
out["residual_ok"] = out["residual_mm"] <= self.h_max
return out
return out
def summary(self, df: pd.DataFrame, cut_len: float, rapid_len: float) -> dict:
max_res = float(df["residual_mm"].max()) if "residual_mm" in df.columns else 0.0
return {
"total_len": round(cut_len + rapid_len, 3),
"cut_len": cut_len,
"rapid_len": rapid_len,
"max_residual_mm": round(max_res, 5),
"all_pass": bool(max_res <= self.h_max),
"violation_points": int((df["residual_mm"] > self.h_max).sum()) if "residual_mm" in df.columns else 0,
}
</details>
<details>
<summary></summary>
"""可视化 (matplotlib)"""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from pathlib import Path
from mpl_toolkits.mplot3d import Axes3D # noqa: F401
plt.rcParams["font.sans-serif"] = ["SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
class ToolpathVisualizer:
def __init__(self, results_dir: str = "results"):
self.results_dir = Path(results_dir)
self.results_dir.mkdir(exist_ok=True)
def draw_path(self, df, order, fname, title, color_by_region=True):
fig = plt.figure(figsize=(11, 8))
ax = fig.add_subplot(111, projection="3d")
pos = {int(r["seq"]): (r["x"], r["y"], r["z"]) for _, r in df.iterrows()}
xs, ys, zs = [], [], []
for n in order:
p = pos[n]
xs.append(p[0]); ys.append(p[1]); zs.append(p[2])
xs.append(pos[order[0]][0]); ys.append(pos[order[0]][1]); zs.append(pos[order[0]][2])
if color_by_region:
regions = [df[df["seq"]==n]["region"].values[0] for n in order]
uniq = list(dict.fromkeys(regions))
cmap = plt.cm.tab10(np.linspace(0,1,len(uniq)))
colmap = {r: cmap[i] for i,r in enumerate(uniq)}
cols = [colmap[r] for r in regions]
ax.plot(xs, ys, zs, "-", color="gray", alpha=0.3, lw=0.8)
ax.scatter(xs[:-1], ys[:-1], zs[:-1], c=cols, s=18, depthshade=False)
else:
ax.plot(xs, ys, zs, "-", color="#2C3E50", lw=1.2)
ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z")
ax.set_title(title, fontsize=13, fontweight="bold")
plt.tight_layout()
plt.savefig(self.results_dir / fname, dpi=150, bbox_inches="tight")
plt.close()
def graph_plot(self, G):
fig = plt.figure(figsize=(11, 8))
ax = fig.add_subplot(111, projection="3d")
pos = nx.get_node_attributes(G, "pos")
xs = [pos[n][0] for n in G.nodes()]
ys = [pos[n][1] for n in G.nodes()]
zs = [pos[n][2] for n in G.nodes()]
ax.scatter(xs, ys, zs, c="#95A5A6", s=15)
for u, v, d in G.edges(data=True):
p1, p2 = pos[u], pos[v]
c = "#E74C3C" if d["seg_type"]=="rapid" else "#2980B9"
ax.plot([p1[0],p2[0]],[p1[1],p2[1]],[p1[2],p2[2]],
color=c, alpha=0.5, lw=0.8)
ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z")
ax.set_title("刀路图结构 (红=空走 蓝=切削)", fontsize=13, fontweight="bold")
plt.tight_layout()
plt.savefig(self.results_dir / "graph_structure.png", dpi=150, bbox_inches="tight")
plt.close()
def stepover_heatmap(self, df):
fig, ax = plt.subplots(figsize=(11, 6))
sc = ax.scatter(df["x"], df["y"], c=df["stepover_mm"]*1000,
cmap="viridis", s=25, edgecolors="none")
fig.colorbar(sc, ax=ax, label="行距 (μm)")
ax.set_xlabel("X"); ax.set_ylabel("Y")
ax.set_title("变行距分布热力图 (按曲面斜率)", fontsize=13, fontweight="bold")
ax.grid(alpha=0.2)
plt.tight_layout()
plt.savefig(self.results_dir / "stepover_heatmap.png", dpi=150, bbox_inches="tight")
plt.close()
def convergence(self, history):
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(range(len(history)), history, "-o", color="#8E44AD", ms=3)
ax.set_xlabel("迭代步")
ax.set_ylabel("总路径长 (mm)")
ax.set_title("2-opt 收敛曲线", fontsize=13, fontweight="bold")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(self.results_dir / "convergence.png", dpi=150, bbox_inches="tight")
plt.close()
def length_compare(self, before, after):
fig, ax = plt.subplots(figsize=(8, 5))
labels = ["优化前", "优化后"]
cut = [before["cut_len"], after["cut_len"]]
rapid = [before["rapid_len"], after["rapid_len"]]
ax.bar(labels, cut, label="切削长度", color="#2980B9")
ax.bar(labels, rapid,
利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!