6
0
1
开源:功率放大器学术图像绘制-Python
本文分享了绘制功率放大器(PA)小信号与大信号结果的学术风格曲线的Python源代码。
包括:
小信号(S参数)仿真结果,绘制S11、S21、S22,单位均为dB;
单独频点的大信号功率扫描结果,横坐标为输入功率(Pin,单位dBm),绘制 Gain(dB), Pout(dBm), PAE(%);
大信号的频率扫描结果,横坐标为频率,绘制 PAEmax(%), OP1dB(dBm), Psat(dBm)。
输入文件格式:
CSV文件
输出文件格式:
PNG文件(可在代码中自行修改为其他格式)
代码文件:
plot_S参数仿真结果.py
plot_大信号功率扫描结果.py
plot_大信号仿真结果频率变化.py
getTime.py
下面是各代码文件的源代码。
1. plot_S参数仿真结果.py
该代码用于绘制 S11(dB), S21(dB), S22(dB)随频率变化的学术图像,输入为CSV文件,输出为PNG文件。
CSV文件的默认序列为:
←点击折叠/展开代码
# -*- coding: utf-8 -*-
'''
=====================================================================
文件用途: 读取 S 参数仿真结果 CSV(列顺序: 频率(GHz), S11(dB), S21(dB),
S22(dB)), 绘制 S11/S21/S22 幅度曲线(横坐标为频率, 单位 GHz),
并标注 S21 最大值及其对应频率、S21 的 3dB 带宽(以 S21 最大值
下降 3dB 为带边判据), 采用学术论文风格(衬线字体、内向刻度、
点状网格)。
运行方式: python plot_S参数仿真结果.py [CSV路径]
不带参数时默认读取 "S参数仿真结果.csv"
输出文件: 图片结果/S参数仿真结果_学术风格.png
=====================================================================
'''
import sys
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg") # 无界面后端, 仅输出图片文件
import matplotlib.pyplot as plt
import getTime
# ---------------- 全局学术风格设置 ----------------
plt.rcParams.update({
"font.family": "serif",
"font.serif": ["Times New Roman", "SimSun"], # 英文用 Times, 中文用宋体
"mathtext.fontset": "stix", # 数学符号风格与 Times 一致
"font.size": 11,
"axes.linewidth": 1.0,
"axes.edgecolor": "black",
"axes.labelsize": 13,
"axes.titlesize": 13,
"xtick.direction": "in",
"ytick.direction": "in",
"xtick.major.width": 1.0,
"ytick.major.width": 1.0,
"xtick.major.size": 5,
"ytick.major.size": 5,
"legend.fontsize": 10,
"legend.frameon": True,
"legend.edgecolor": "black",
"figure.dpi": 150,
"savefig.dpi": 600,
})
CSV_PATH = sys.argv[1] if len(sys.argv) > 1 else\
f"仿真数据{getTime.get_current_day()}/S参数仿真结果.csv"
OUT_PATH = f"图片/S参数仿真结果_{getTime.get_current_time()}.png"
# 三条曲线的颜色: S21 深红(主曲线), S11 深蓝, S22 深绿, 保持严肃学术配色
COLOR_S21 = "#B2182B"
COLOR_S11 = "#2166AC"
COLOR_S22 = "#1B7837"
def load_data(csv_path):
"""
读取 S 参数仿真结果 CSV 并整理为 numpy 数组。
:param csv_path: CSV 文件路径, 列顺序为 频率(GHz), S11(dB), S21(dB),
S22(dB)
:return: 元组 (freq, s11, s21, s22), 单位分别为 GHz 与 dB
"""
df = pd.read_csv(csv_path)
# 不同导出工具的表头写法不一, 统一按列位置读取, 避免表头歧义
cols = df.columns.tolist()
freq = df[cols[0]].to_numpy(dtype=float)*1e-9 # 转换为 GHz
s11 = df[cols[1]].to_numpy(dtype=float)
s21 = df[cols[2]].to_numpy(dtype=float)
s22 = df[cols[3]].to_numpy(dtype=float)
return freq, s11, s21, s22
def find_s21_max(freq, s21):
"""
确定 S21 最大值及其对应频率。
:param freq: 频率数组 (GHz)
:param s21: S21 幅度数组 (dB)
:return: 元组 (f_max, s21_max)
"""
i = int(np.argmax(s21))
return float(freq[i]), float(s21[i])
def find_3db_edges(freq, s21, f_max, s21_max):
"""
确定 S21 的 3dB 带宽带边: S21 由最大值下降 3dB 的两个交越频率。
在峰值左侧找上升穿越点 f1, 右侧找下降穿越点 f2, 均做线性插值。
:param freq: 频率数组 (GHz), 需单调递增
:param s21: S21 幅度数组 (dB)
:param f_max: S21 最大值对应频率 (GHz)
:param s21_max: S21 最大值 (dB)
:return: 元组 (f1, f2), 分别为下带边与上带边频率 (GHz)
"""
target = s21_max - 3.0
i_max = int(np.argmax(s21))
f1 = f2 = None
# 峰值左侧: 找 S21 首次上升到 target 的相邻两点
for i in range(1, i_max + 1):
if s21[i - 1] < target <= s21[i]:
f1 = float(np.interp(target, [s21[i - 1], s21[i]],
[freq[i - 1], freq[i]]))
break
# 峰值右侧: 找 S21 首次下降到 target 的相邻两点
for i in range(i_max + 1, len(freq)):
if s21[i - 1] >= target > s21[i]:
f2 = float(np.interp(target, [s21[i], s21[i - 1]],
[freq[i], freq[i - 1]]))
break
if f1 is None:
raise ValueError("未在峰值左侧找到 S21 下降 3dB 的交越点")
if f2 is None:
raise ValueError("未在峰值右侧找到 S21 下降 3dB 的交越点")
return f1, f2
def main():
"""
主流程: 读取数据、计算 S21 最大值与 3dB 带宽、绘图并保存。
:return: 无
"""
freq, s11, s21, s22 = load_data(CSV_PATH)
# ---- 计算 S21 最大值与 3dB 带宽 ----
f_max, s21_max = find_s21_max(freq, s21)
f1, f2 = find_3db_edges(freq, s21, f_max, s21_max)
bw3 = f2 - f1
target = s21_max - 3.0
bw3d = bw3 / ((f1 + f2) / 2) * 100.0
print(f"S21 最大值 = {s21_max:.2f} dB @ f = {f_max:.3f} GHz")
print(f"3dB 带边: f1 = {f1:.3f} GHz, f2 = {f2:.3f} GHz, "
f"BW3dB = {bw3:.3f} GHz")
print(f"3dB 带宽占比: {bw3d:.3f} %")
# ---- 绘图: 单幅 S 参数幅度图 ----
fig, ax = plt.subplots(figsize=(6.0, 5.0))
ax.plot(freq, s21, color=COLOR_S21, ls="-", lw=1.5, label="$S_{21}$")
ax.plot(freq, s11, color=COLOR_S11, ls="--", lw=1.2, label="$S_{11}$")
ax.plot(freq, s22, color=COLOR_S22, ls="-.", lw=1.2, label="$S_{22}$")
# ---- 标注 S21 最大值点: 竖直参考线 + 空心圆 + 文字 ----
ax.axvline(f_max, ls="--", lw=0.8, color=COLOR_S21, alpha=0.55, zorder=1)
ax.plot([f_max], [s21_max], marker="o", ms=7, mfc="white", mec=COLOR_S21,
mew=1.4, ls="none", zorder=5)
ax.annotate("max = {:.2f} dB @ {:.2f} GHz".format(s21_max, f_max),
xy=(f_max, s21_max), xytext=(8, 8), textcoords="offset points",
fontsize=9.5, color=COLOR_S21, ha="left", va="center",
zorder=6)
# ---- 标注 3dB 带宽: -3dB 水平参考线 + 带边竖直参考线 + 双向箭头 ----
ax.axhline(target, ls=":", lw=0.9, color="0.35", zorder=1)
ax.annotate("max $-$ 3 dB", xy=(freq[0], target), xytext=(4, 6),
textcoords="offset points", fontsize=9, color="0.25",
ha="left", va="center", zorder=6)
for f_edge in (f1, f2):
ax.axvline(f_edge, ls="-.", lw=0.8, color=COLOR_S21, alpha=0.55,
zorder=1)
ax.plot([f_edge], [target], marker="o", ms=6, mfc="white",
mec=COLOR_S21, mew=1.2, ls="none", zorder=5)
y_arrow = target - 0.12 * (ax.get_ylim()[1] - ax.get_ylim()[0])
ax.annotate("", xy=(f1, y_arrow), xytext=(f2, y_arrow),
arrowprops=dict(arrowstyle="<->", lw=1.0, color=COLOR_S21),
zorder=6)
ax.annotate("BW$_{\\mathrm{3dB}}$ = %.2f GHz" % bw3,
xy=((f1 + f2) / 2, y_arrow), xytext=(0, -6),
textcoords="offset points", fontsize=9.5, color=COLOR_S21,
ha="center", va="top", zorder=6)
ax.set_xlabel("Frequency (GHz)")
ax.set_ylabel("Magnitude (dB)")
ax.grid(True, ls=":", lw=0.5, color="gray", alpha=0.6)
ax.legend(loc="upper right")
# ---- 图底说明行: 汇总最大值与带宽数值 ----
caption = ("$S_{21,\\mathrm{max}}$ = %.2f dB @ f = %.2f GHz "
"$f_1$ = %.2f GHz, $f_2$ = %.2f GHz, "
"BW$_{\\mathrm{3dB}}$ = %.2f GHz(%3.1f%%)" % (s21_max, f_max, f1, f2,
bw3, bw3d))
fig.text(0.5, -0.02, caption, ha="center", va="top", fontsize=10.5)
fig.tight_layout()
fig.savefig(OUT_PATH, bbox_inches="tight")
print(f"图片已保存: {OUT_PATH}")
if __name__ == "__main__":
main()
2. plot_大信号功率扫描结果.py
该代码用于绘制 Gain(dB), Pout(dBm), PAE(%)随频率变化的学术图像,输入为CSV文件,输出为PNG文件。
CSV文件的默认序列为:
←点击折叠/展开代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
=====================================================================
文件用途: 读取单一频率下 PA 大信号功率扫描结果 CSV,
列顺序: Pin(dBm), Gain(dB), Pout(dBm), PAE(%),
绘制 Gain/Pout/PAE 随输入功率变化曲线(横坐标 Pin, 单位 dBm),
计算并标注 OP1dB 与 6dB 回退(back-off)工作点处的
Pout 与 PAE 数值, 采用学术论文风格(衬线字体、内向刻度、
点状网格)。
定义约定:
1. 小信号增益 G_ss 取低功率平台区增益的中位数;
2. OP1dB 为增益相对 G_ss 下降 1dB 处的输出功率;
3. 6dB 回退点定义为相对 OP1dB, 即 Pout = OP1dB - 6dB 处;
4. 各工作点处的 PAE 由 PAE-Pin 曲线线性插值得到。
运行方式: python plot_大信号功率扫描结果.py [CSV路径]
不带参数时默认读取 "大信号功率扫描结果.csv"
输出文件: 图片结果/大信号功率扫描结果_学术风格.png
说明:
1. 左轴同时表示 Pout(dBm) 与 Gain(dB), 右轴表示 PAE(%);
2. 工作点标注文字在布局完成后进行包围盒重叠检测与自动避让,
避让距离过大时补绘细引导线。
=====================================================================
"""
import os
import sys
import numpy as np
import pandas as pd
import matplotlib
import getTime
matplotlib.use("Agg") # 无界面后端, 仅输出图片文件
import matplotlib.pyplot as plt
# ---------------- 全局学术风格设置 ----------------
plt.rcParams.update({
"font.family": "serif",
"font.serif": ["Times New Roman", "SimSun"],
"mathtext.fontset": "stix",
"font.size": 11,
"axes.linewidth": 1.0,
"axes.edgecolor": "black",
"axes.labelsize": 13,
"axes.titlesize": 13,
"xtick.direction": "in",
"ytick.direction": "in",
"xtick.major.width": 1.0,
"ytick.major.width": 1.0,
"xtick.major.size": 5,
"ytick.major.size": 5,
"legend.fontsize": 10,
"legend.frameon": True,
"legend.edgecolor": "black",
"figure.dpi": 150,
"savefig.dpi": 600,
})
CSV_PATH = sys.argv[1] if len(sys.argv) > 1 else\
f"仿真数据{getTime.get_current_day()}/大信号功率扫描结果.csv"
OUT_PATH = os.path.join("图片", f"大信号功率扫描结果_{getTime.get_current_time()}.png")
# 曲线颜色: Pout 深红(主曲线), Gain 深蓝, PAE 深绿
COLOR_PAE = "#B2182B"
COLOR_GAIN = "#2166AC"
COLOR_POUT = "#1B7837"
# 工作点标记颜色: OP1dB 黑色, 6dB 回退深灰, 与曲线色区分
COLOR_OP1DB = "#000000"
COLOR_BO6 = "#4D4D4D"
# 回退量定义 (dB), 参考量为 OP1dB
BACKOFF_DB = 6.0
def read_csv_flexible(csv_path):
"""
尝试使用常见编码读取 CSV 文件。
:param csv_path: CSV 文件路径
:return: pandas.DataFrame
"""
encodings = ("utf-8-sig", "gbk", "latin1")
last_error = None
for enc in encodings:
try:
return pd.read_csv(csv_path, encoding=enc)
except UnicodeDecodeError as exc:
last_error = exc
continue
raise ValueError(f"无法识别 CSV 文件编码: {csv_path}") from last_error
def load_data(csv_path):
"""
读取功率扫描结果 CSV 并整理为 numpy 数组。
:param csv_path: CSV 文件路径, 列顺序为
Pin(dBm), Gain(dB), Pout(dBm), PAE(%)
:return: (pin, gain, pout, pae)
"""
df = read_csv_flexible(csv_path)
cols = df.columns.tolist()
if len(cols) < 4:
raise ValueError("CSV 至少需要 4 列: Pin, Gain, Pout, PAE")
pin = df[cols[0]].to_numpy(dtype=float)
gain = df[cols[1]].to_numpy(dtype=float)
pout = df[cols[2]].to_numpy(dtype=float)
pae = df[cols[3]].to_numpy(dtype=float)
data = np.column_stack((pin, gain, pout, pae))
data = data[np.all(np.isfinite(data), axis=1)]
if data.shape[0] < 2:
raise ValueError("CSV 中没有足够的有效数值行")
# 按输入功率升序排列, 保证交越点查找逻辑成立
order = np.argsort(data[:, 0])
data = data[order]
return data[:, 0], data[:, 1], data[:, 2], data[:, 3]
def find_small_signal_gain(gain):
"""
估计小信号增益: 取低功率平台区增益的中位数。
该定义对存在轻微增益扩展(gain expansion)的曲线仍然稳健。
:param gain: Gain 幅度数组 (dB), 对应 Pin 升序
:return: 小信号增益 G_ss (dB)
"""
n = len(gain)
n_plat = max(3, int(0.1 * n))
return float(np.median(gain[:n_plat]))
def find_op1db(pin, gain, pout, pae, g_ss):
"""
确定 1dB 压缩点: Gain 由小信号值下降 1dB 的交越位置, 线性插值。
:param pin: 输入功率数组 (dBm)
:param gain: Gain 幅度数组 (dB)
:param pout: Pout 数组 (dBm)
:param pae: PAE 数组 (%)
:param g_ss: 小信号增益 (dB)
:return: (pin_1db, pout_1db, pae_1db)
"""
target = g_ss - 1.0
idx = None
for i in range(1, len(pin)):
if gain[i - 1] >= target > gain[i]:
idx = i
break
if idx is None:
raise ValueError("未找到增益下降 1dB 的交越点, 请检查功率扫描范围")
pin_1db = float(np.interp(target, [gain[idx], gain[idx - 1]],
[pin[idx], pin[idx - 1]]))
pout_1db = float(np.interp(pin_1db, pin, pout))
pae_1db = float(np.interp(pin_1db, pin, pae))
return pin_1db, pout_1db, pae_1db
def find_backoff_point(pin, pout, pae, p_ref, backoff_db=6.0):
"""
确定回退工作点: Pout 由低向高上升穿越 p_ref - backoff_db 的位置。
本脚本中回退参考量 p_ref 为 OP1dB, 即回退定义相对 OP1dB。
:param pin: 输入功率数组 (dBm), 需单调递增
:param pout: Pout 数组 (dBm)
:param pae: PAE 数组 (%)
:param p_ref: 回退参考输出功率 (dBm), 此处为 OP1dB
:param backoff_db: 回退量 (dB), 默认 6dB
:return: (pin_bo, pout_bo, pae_bo)
"""
pin = np.asarray(pin, dtype=float)
pout = np.asarray(pout, dtype=float)
pae = np.asarray(pae, dtype=float)
target = p_ref - backoff_db
idx = None
for i in range(1, len(pin)):
if pout[i - 1] < target <= pout[i]:
idx = i
break
if idx is None:
raise ValueError("未找到 Pout 上升穿越回退门限的交越点, "
"请向低功率方向扩展扫描范围")
pin_bo = float(np.interp(target, [pout[idx - 1], pout[idx]],
[pin[idx - 1], pin[idx]]))
pae_bo = float(np.interp(pin_bo, pin, pae))
return pin_bo, target, pae_bo
def create_point_annotation(ax, x, y, color, text):
"""
创建工作点文字标注(初始偏移), 后续由避让函数统一调整。
:param ax: Matplotlib Axes 对象
:param x: 标注指向点横坐标(数据坐标)
:param y: 标注指向点纵坐标(数据坐标)
:param color: 文字颜色
:param text: 标注文字
:return: Annotation 对象
"""
x_min, x_max = ax.get_xlim()
x_span = x_max - x_min
if x_span <= 0.0:
x_span = 1.0
if x > x_min + 0.65 * x_span:
x_offset = -8
ha = "right"
else:
x_offset = 8
ha = "left"
y_min, y_top = ax.get_ylim()
y_span = y_top - y_min
if y_span <= 0.0:
y_span = 1.0
if y > y_min + 0.72 * y_span:
y_offset = -12
va = "top"
else:
y_offset = 10
va = "bottom"
return ax.annotate(
text,
xy=(x, y),
xytext=(x_offset, y_offset),
textcoords="offset points",
fontsize=9.5,
color=color,
ha=ha,
va=va,
zorder=6,
)
def _bbox_inside(inner, outer):
"""
判断包围盒 inner 是否完全位于 outer 内部。
:param inner: 内层 Bbox
:param outer: 外层 Bbox
:return: 布尔值
"""
return (inner.x0 >= outer.x0 and inner.x1 <= outer.x1
and inner.y0 >= outer.y0 and inner.y1 <= outer.y1)
def _try_move_annotation(ann, renderer, ax_bbox, obstacles, base, dx, dy):
"""
尝试将标注文字平移 (dx, dy), 并检查平移后是否越界或压到障碍物。
:return: 平移成功返回新包围盒, 失败返回 None
"""
ox, oy = base
ann.xyann = (ox + dx, oy + dy)
ext = ann.get_window_extent(renderer)
if not _bbox_inside(ext, ax_bbox):
return None
for ob in obstacles:
if ext.expanded(1.02, 1.10).overlaps(ob):
return None
return ext
def resolve_annotation_overlaps(fig, ax, annotations, obstacles=(),
step=12.0, max_rounds=60):
"""
消除标注文字之间、以及文字与障碍物(如图例)之间的重叠。
每轮查找第一处重叠, 将对应标注按 下、上、左、右 的顺序尝试平移
一个步长; 若四个方向均不可行, 则强制下移, 直至无重叠或达到
迭代上限。所有判断基于渲染后的显示坐标包围盒。
:param fig: Figure 对象
:param ax: 用于确定文字允许范围的 Axes 对象
:param annotations: Annotation 对象列表
:param obstacles: 固定障碍物包围盒列表(显示坐标)
:param step: 单次平移步长, 单位为 point
:param max_rounds: 最大迭代轮数
:return: 是否完全消除重叠
"""
renderer = fig.canvas.get_renderer()
ax_bbox = ax.get_window_extent()
obstacles = list(obstacles)
for _ in range(max_rounds):
extents = [ann.get_window_extent(renderer) for ann in annotations]
target = None
for i, ext in enumerate(extents):
padded = ext.expanded(1.03, 1.15)
for ob in obstacles:
if padded.overlaps(ob):
target = i
break
if target is not None:
break
for j in range(i + 1, len(extents)):
if padded.overlaps(extents[j]):
target = j
break
if target is not None:
break
if target is None:
return True
ann = annotations[target]
base = ann.xyann
moved = False
for dx, dy in ((0.0, -step), (0.0, step),
(-step, 0.0), (step, 0.0),
(0.0, -2.0 * step), (0.0, 2.0 * step)):
if _try_move_annotation(ann, renderer, ax_bbox, obstacles,
base, dx, dy) is not None:
moved = True
break
if not moved:
ann.xyann = (base[0], base[1] - step)
return False
def add_leader_lines(fig, items, threshold_points=24.0):
"""
对因避让而远离数据点的标注补绘细引导线, 保持指向关系明确。
:param fig: Figure 对象
:param items: 列表, 元素为 (ann, x, y, color, ax)
:param threshold_points: 触发引导线的偏移距离阈值, 单位为 point
:return: 无
"""
scale = fig.dpi / 72.0
for ann, x, y, color, ax in items:
ox, oy = ann.xyann
if np.hypot(ox, oy) <= threshold_points:
continue
disp = ax.transData.transform((x, y))
text_disp = (disp[0] + ox * scale, disp[1] + oy * scale)
tx, ty = ax.transData.inverted().transform(text_disp)
ax.plot([x, tx], [y, ty], ls="-", lw=0.6, color=color,
alpha=0.55, zorder=4)
def main():
"""
主流程: 读取数据、计算 OP1dB 与相对 OP1dB 的 6dB 回退工作点、
绘图并保存。
:return: 无
"""
pin, gain, pout, pae = load_data(CSV_PATH)
# ---- 计算小信号增益、OP1dB 与 6dB 回退点(参考 OP1dB) ----
g_ss = find_small_signal_gain(gain)
pin_1db, pout_1db, pae_1db = find_op1db(pin, gain, pout, pae, g_ss)
pin_bo, pout_bo, pae_bo = find_backoff_point(
pin, pout, pae, p_ref=pout_1db, backoff_db=BACKOFF_DB)
bo_level = pout_1db - BACKOFF_DB
print(f"小信号增益 G_ss = {g_ss:.2f} dB")
print(f"OP1dB = {pout_1db:.2f} dBm @ Pin = {pin_1db:.2f} dBm, "
f"PAE = {pae_1db:.2f} %")
print(f"6dB 回退点(参考 OP1dB): Pout = {pout_bo:.2f} dBm "
f"@ Pin = {pin_bo:.2f} dBm, PAE = {pae_bo:.2f} %")
fig, ax1 = plt.subplots(figsize=(6.0, 5.0))
ax2 = ax1.twinx()
# ---------------- 绘制曲线 ----------------
line_pout, = ax1.plot(pin, pout, color=COLOR_POUT, ls="-", marker="o",
ms = 3.0, lw=1.5, label=r"P$_{\mathrm{out}}$")
line_gain, = ax1.plot(pin, gain, color=COLOR_GAIN, ls="--", marker="s",
ms = 3.0, lw=1.2, label="Gain")
line_pae, = ax2.plot(pin, pae, color=COLOR_PAE, ls="-.", marker="p",
ms = 3.0, lw=1.2, label="PAE")
# ---------------- 坐标轴与网格设置 ----------------
ax1.set_xlabel(r"P$_{\mathrm{in}}$ (dBm)")
ax1.set_ylabel(r"P$_{\mathrm{out}}$ (dBm) / Gain (dB)")
ax2.set_ylabel(r"PAE (%)")
ax1.margins(x=0.02, y=0.18)
ax2.margins(y=0.18)
ax1.grid(True, ls=":", lw=0.5, color="gray", alpha=0.6)
ax1.set_axisbelow(True)
ax2.grid(False)
ax1.tick_params(axis="both", direction="in")
ax2.tick_params(axis="both", direction="in")
ax1.spines["right"].set_visible(False)
ax2.spines["right"].set_visible(True)
ax2.spines["left"].set_visible(False)
ax2.yaxis.set_ticks_position("right")
ax2.yaxis.set_label_position("right")
# ---------------- 回退门限水平参考线 (OP1dB - 6dB) ----
ax1.axhline(bo_level, ls=":", lw=0.9, color="0.35", zorder=1)
ax1.annotate(r"OP$_{\mathrm{1dB}}$ $-$ %.0f dB" % BACKOFF_DB,
xy=(pin[0], bo_level), xytext=(4, -4),
textcoords="offset points", fontsize=9, color="0.25",
ha="left", va="top", zorder=6)
# ---------------- 工作点竖直参考线 ----------------
ax1.axvline(pin_1db, ls="--", lw=0.8, color=COLOR_OP1DB, alpha=0.55,
zorder=1)
ax1.axvline(pin_bo, ls="-.", lw=0.8, color=COLOR_BO6, alpha=0.55,
zorder=1)
# ---------------- 工作点空心圆: 覆盖 Gain/Pout/PAE 三条曲线 ----
markers = [
(pin_1db, g_ss - 1.0, ax1, COLOR_OP1DB),
(pin_1db, pout_1db, ax1, COLOR_OP1DB),
(pin_1db, pae_1db, ax2, COLOR_OP1DB),
(pin_bo, pout_bo, ax1, COLOR_BO6),
(pin_bo, pae_bo, ax2, COLOR_BO6),
]
for xx, yy, ax_cur, cc in markers:
ax_cur.plot([xx], [yy], marker="o", ms=7, mfc="white", mec=cc,
mew=1.4, ls="none", zorder=5)
# ---------------- 工作点文字标注 ----------------
text_1db = (r"OP$_{\mathrm{1dB}}$ = %.2f dBm, PAE = %.2f %% "
r"@ P$_{\mathrm{in}}$ = %.2f dBm"
% (pout_1db, pae_1db, pin_1db))
ann_1db = create_point_annotation(ax1, pin_1db, pout_1db,
COLOR_OP1DB, text_1db)
text_bo = (r"%.0f dB back-off: P$_{\mathrm{out}}$ = %.2f dBm, "
r"PAE = %.2f %% @ P$_{\mathrm{in}}$ = %.2f dBm"
% (BACKOFF_DB, pout_bo, pae_bo, pin_bo))
ann_bo = create_point_annotation(ax1, pin_bo, pout_bo,
COLOR_BO6, text_bo)
items = [
(ann_1db, pin_1db, pout_1db, COLOR_OP1DB, ax1),
(ann_bo, pin_bo, pout_bo, COLOR_BO6, ax1),
]
# ---------------- 合并左右轴图例 ----------------
lines = [line_pout, line_gain, line_pae]
labels = [line.get_label() for line in lines]
leg = ax1.legend(lines, labels, loc="upper left")
# ---------------- 图底说明行 ----------------
caption_template = (
r"G$_{\mathrm{ss}}$ = %.2f dB, OP$_{\mathrm{1dB}}$ = %.2f dBm "
r"@ P$_{\mathrm{in}}$ = %.2f dBm, PAE = %.2f %%"
"\n"
r"%.0f dB back-off (ref. OP$_{\mathrm{1dB}}$): "
r"P$_{\mathrm{out}}$ = %.2f dBm, PAE = %.2f %% "
r"@ P$_{\mathrm{in}}$ = %.2f dBm"
)
caption = caption_template % (
g_ss, pout_1db, pin_1db, pae_1db,
BACKOFF_DB, pout_bo, pae_bo, pin_bo,
)
fig.tight_layout(rect=[0, 0.11, 1, 1])
fig.text(0.5, 0.02, caption, ha="center", va="bottom", fontsize=10.0)
# ---------------- 布局确定后进行标注重叠避让 ----------------
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
obstacles = [leg.get_window_extent(renderer)]
resolved = resolve_annotation_overlaps(
fig, ax1, [ann_1db, ann_bo], obstacles)
if not resolved:
print("提示: 标注重叠未能在迭代上限内完全消除, 请检查数据范围")
add_leader_lines(fig, items)
# ---------------- 保存图片 ----------------
out_dir = os.path.dirname(OUT_PATH)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
fig.savefig(OUT_PATH, bbox_inches="tight")
print(f"图片已保存: {OUT_PATH}")
if __name__ == "__main__":
main()3. plot_大信号仿真结果频率变化.py
该代码用于绘制PAEmax(%), OP1dB(dBm), Psat(dBm)随频率变化的学术图像,输入为CSV文件,输出为PNG文件。
CSV文件的默认序列为:
←点击折叠/展开代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
=====================================================================
文件用途: 读取 PA 大信号仿真结果 CSV, 绘制 PAEmax、OP1dB、Psat 随频率变化
的学术风格曲线, 并标注每条曲线的最大值及其对应频率。
默认输入: PA大信号仿真结果.csv
默认列序: 频率, PAEmax(%), OP1dB(dBm), Psat(dBm)
运行方式: python plot_PA大信号仿真结果.py [CSV路径]
输出文件: 图片结果/PA大信号仿真结果_学术风格.png
说明:
1. 频率单位优先根据表头识别, 支持 GHz、MHz、kHz、Hz。
如果表头中没有明确单位, 则根据数值范围自动判断。
2. PAEmax 默认按百分比读取。
如果仿真导出结果中的 PAEmax 是 0 到 1 之间的小数,
可在 load_data 函数中打开比例转换注释。
3. OP1dB 与 Psat 默认按 dBm 读取。
4. 最大值标注文字在布局完成后进行包围盒重叠检测与自动避让,
避让距离过大时补绘细引导线, 以避免文字相互覆盖。
=====================================================================
"""
import os
import sys
import numpy as np
import pandas as pd
import matplotlib
import getTime
matplotlib.use("Agg") # 无界面后端, 仅输出图片文件
import matplotlib.pyplot as plt
# ---------------- 全局学术风格设置 ----------------
plt.rcParams.update({
"font.family": "serif",
"font.serif": ["Times New Roman", "SimSun"],
"mathtext.fontset": "stix",
"font.size": 11,
"axes.linewidth": 1.0,
"axes.edgecolor": "black",
"axes.labelsize": 13,
"axes.titlesize": 13,
"xtick.direction": "in",
"ytick.direction": "in",
"xtick.major.width": 1.0,
"ytick.major.width": 1.0,
"xtick.major.size": 5,
"ytick.major.size": 5,
"legend.fontsize": 10,
"legend.frameon": True,
"legend.edgecolor": "black",
"figure.dpi": 150,
"savefig.dpi": 600,
})
CSV_PATH = sys.argv[1] if len(sys.argv) > 1 else\
f"仿真数据{getTime.get_current_day()}/PA大信号仿真结果.csv"
OUT_PATH = os.path.join("图片", f"PA大信号仿真结果_{getTime.get_current_time()}.png")
# 三条曲线的颜色: PAE 深红, OP1dB 深蓝, Psat 深绿, 保持严肃学术配色
COLOR_PAE = "#B2182B"
COLOR_OP1DB = "#2166AC"
COLOR_PSAT = "#1B7837"
def read_csv_flexible(csv_path):
"""
尝试使用常见编码读取 CSV 文件。
:param csv_path: CSV 文件路径
:return: pandas.DataFrame
"""
encodings = ("utf-8-sig", "gbk", "latin1")
last_error = None
for enc in encodings:
try:
return pd.read_csv(csv_path, encoding=enc)
except UnicodeDecodeError as exc:
last_error = exc
continue
raise ValueError(f"无法识别 CSV 文件编码: {csv_path}") from last_error
def normalize_frequency_to_ghz(freq_raw, column_name=""):
"""
将频率数据统一转换为 GHz。
优先根据列名中的单位信息进行判断:
- 包含 GHz: 不转换
- 包含 MHz: MHz 转 GHz
- 包含 kHz: kHz 转 GHz
- 包含 Hz: Hz 转 GHz
如果列名中没有单位信息, 则按数值范围自动判断:
- 最大值大于 1e7: 按 Hz 处理
- 最大值大于 1e3: 按 MHz 处理
- 其他情况: 按 GHz 处理
:param freq_raw: 原始频率数组
:param column_name: 频率列名称, 用于识别单位
:return: (freq_ghz, unit_text)
"""
freq_raw = np.asarray(freq_raw, dtype=float)
if freq_raw.size == 0 or not np.any(np.isfinite(freq_raw)):
raise ValueError("频率列不包含有效数值")
name = str(column_name).lower()
if "ghz" in name:
factor = 1.0
unit_text = "GHz"
elif "mhz" in name:
factor = 1.0e-3
unit_text = "MHz"
elif "khz" in name:
factor = 1.0e-6
unit_text = "kHz"
elif "hz" in name:
factor = 1.0e-9
unit_text = "Hz"
else:
f_max_raw = float(np.nanmax(freq_raw))
if f_max_raw > 1.0e7:
factor = 1.0e-9
unit_text = "Hz, 自动识别"
elif f_max_raw > 1.0e3:
factor = 1.0e-3
unit_text = "MHz, 自动识别"
else:
factor = 1.0
unit_text = "GHz, 自动识别"
return freq_raw * factor, unit_text
def load_data(csv_path):
"""
读取 PA 大信号仿真结果 CSV 并整理为 numpy 数组。
:param csv_path: CSV 文件路径, 默认列顺序为
频率, PAEmax(%), OP1dB(dBm), Psat(dBm)
:return: (freq, pae_max, op1db, psat, freq_unit)
频率单位为 GHz
"""
df = read_csv_flexible(csv_path)
cols = df.columns.tolist()
if len(cols) < 4:
raise ValueError("CSV 至少需要 4 列: 频率, PAEmax, OP1dB, Psat")
freq_raw = df[cols[0]].to_numpy(dtype=float)
pae_max = df[cols[1]].to_numpy(dtype=float)
op1db = df[cols[2]].to_numpy(dtype=float)
psat = df[cols[3]].to_numpy(dtype=float)
freq, freq_unit = normalize_frequency_to_ghz(freq_raw, cols[0])
# 如果 PAEmax 以 0 到 1 的小数形式给出, 可取消下面注释:
# if np.nanmax(pae_max) <= 1.5:
# pae_max = pae_max * 100.0
data = np.column_stack((freq, pae_max, op1db, psat))
# 删除包含非有限数值的行
data = data[np.all(np.isfinite(data), axis=1)]
if data.shape[0] < 1:
raise ValueError("CSV 中没有有效的数值行")
# 按频率升序排列, 避免曲线因数据乱序而交叉
order = np.argsort(data[:, 0])
data = data[order]
return data[:, 0], data[:, 1], data[:, 2], data[:, 3], freq_unit
def find_curve_max(freq, y):
"""
确定曲线最大值及其对应频率。
:param freq: 频率数组 (GHz)
:param y: 曲线数值数组
:return: (f_max, y_max)
"""
valid = np.isfinite(freq) & np.isfinite(y)
if not np.any(valid):
raise ValueError("曲线中不存在有效数据点")
f_valid = freq[valid]
y_valid = y[valid]
idx = int(np.argmax(y_valid))
return float(f_valid[idx]), float(y_valid[idx])
def create_max_annotation(ax, freq, y, color, label, unit,
value_fmt="{:.2f}"):
"""
在指定坐标轴上绘制最大值参考线与空心圆, 并创建文字标注。
文字初始偏移根据最大值点在坐标区中的位置自动选择,
后续由 resolve_annotation_overlaps 统一进行避让调整。
:param ax: Matplotlib Axes 对象
:param freq: 频率数组 (GHz)
:param y: 曲线数值数组
:param color: 曲线颜色
:param label: 曲线标签, 支持 Mathtext
:param unit: 数值单位字符串
:param value_fmt: 数值格式化字符串
:return: (ann, f_max, y_max)
"""
f_max, y_max = find_curve_max(freq, y)
# 最大值位置竖直参考线
ax.axvline(f_max, ls="--", lw=0.8, color=color, alpha=0.35, zorder=1)
# 最大值空心圆点
ax.plot(
[f_max],
[y_max],
marker="o",
ms=7,
mfc="white",
mec=color,
mew=1.4,
ls="none",
zorder=5,
)
# 水平方向: 最大值点偏右时文字向左排, 否则向右排
f_min = float(np.nanmin(freq))
f_span = float(np.nanmax(freq)) - f_min
if f_span <= 0.0:
f_span = 1.0
if f_max > f_min + 0.65 * f_span:
x_offset = -8
ha = "right"
else:
x_offset = 8
ha = "left"
# 垂直方向: 最大值点偏上时文字置于点下方, 否则置于点上方
y_min, y_top = ax.get_ylim()
y_span = y_top - y_min
if y_span <= 0.0:
y_span = 1.0
if y_max > y_min + 0.72 * y_span:
y_offset = -12
va = "top"
else:
y_offset = 10
va = "bottom"
text = f"max {label} = {value_fmt.format(y_max)} {unit} @ {f_max:.2f} GHz"
ann = ax.annotate(
text,
xy=(f_max, y_max),
xytext=(x_offset, y_offset),
textcoords="offset points",
fontsize=9.5,
color=color,
ha=ha,
va=va,
zorder=6,
)
return ann, f_max, y_max
def _bbox_inside(inner, outer):
"""
判断包围盒 inner 是否完全位于 outer 内部。
:param inner: 内层 Bbox
:param outer: 外层 Bbox
:return: 布尔值
"""
return (inner.x0 >= outer.x0 and inner.x1 <= outer.x1
and inner.y0 >= outer.y0 and inner.y1 <= outer.y1)
def _try_move_annotation(ann, renderer, ax_bbox, obstacles, base, dx, dy):
"""
尝试将标注文字平移 (dx, dy), 并检查平移后是否越界或压到障碍物。
:return: 平移成功返回新包围盒, 失败返回 None
"""
ox, oy = base
ann.xyann = (ox + dx, oy + dy)
ext = ann.get_window_extent(renderer)
if not _bbox_inside(ext, ax_bbox):
return None
for ob in obstacles:
if ext.expanded(1.02, 1.10).overlaps(ob):
return None
return ext
def resolve_annotation_overlaps(fig, ax, annotations, obstacles=(),
step=12.0, max_rounds=60):
"""
消除最大值标注文字之间、以及文字与障碍物(如图例)之间的重叠。
每轮查找第一处重叠, 将对应标注按 下、上、左、右 的顺序尝试平移
一个步长; 若四个方向均不可行, 则强制下移, 直至无重叠或达到
迭代上限。所有判断基于渲染后的显示坐标包围盒。
:param fig: Figure 对象
:param ax: 用于确定文字允许范围的 Axes 对象
:param annotations: Annotation 对象列表
:param obstacles: 固定障碍物包围盒列表(显示坐标)
:param step: 单次平移步长, 单位为 point
:param max_rounds: 最大迭代轮数
:return: 是否完全消除重叠
"""
renderer = fig.canvas.get_renderer()
ax_bbox = ax.get_window_extent()
obstacles = list(obstacles)
for _ in range(max_rounds):
extents = [ann.get_window_extent(renderer) for ann in annotations]
target = None
for i, ext in enumerate(extents):
padded = ext.expanded(1.03, 1.15)
# 与固定障碍物(图例等)的重叠优先处理
for ob in obstacles:
if padded.overlaps(ob):
target = i
break
if target is not None:
break
# 标注文字之间的两两重叠
for j in range(i + 1, len(extents)):
if padded.overlaps(extents[j]):
target = j
break
if target is not None:
break
if target is None:
return True
ann = annotations[target]
base = ann.xyann
moved = False
for dx, dy in ((0.0, -step), (0.0, step),
(-step, 0.0), (step, 0.0),
(0.0, -2.0 * step), (0.0, 2.0 * step)):
if _try_move_annotation(ann, renderer, ax_bbox, obstacles,
base, dx, dy) is not None:
moved = True
break
if not moved:
ann.xyann = (base[0], base[1] - step)
return False
def add_leader_lines(fig, items, threshold_points=24.0):
"""
对因避让而远离数据点的标注补绘细引导线, 保持指向关系明确。
:param fig: Figure 对象
:param items: 列表, 元素为 (ann, f_max, y_max, color, ax)
:param threshold_points: 触发引导线的偏移距离阈值, 单位为 point
:return: 无
"""
scale = fig.dpi / 72.0
for ann, f_max, y_max, color, ax in items:
ox, oy = ann.xyann
if np.hypot(ox, oy) <= threshold_points:
continue
disp = ax.transData.transform((f_max, y_max))
text_disp = (disp[0] + ox * scale, disp[1] + oy * scale)
tx, ty = ax.transData.inverted().transform(text_disp)
ax.plot([f_max, tx], [y_max, ty], ls="-", lw=0.6, color=color,
alpha=0.55, zorder=4)
def main():
"""
主流程: 读取数据、绘制 PAEmax、OP1dB、Psat 曲线,
标注每条曲线最大值及其对应频率, 自动避让重叠后保存图片。
:return: 无
"""
freq, pae_max, op1db, psat, freq_unit = load_data(CSV_PATH)
print(f"频率单位识别结果: {freq_unit}; 绘图统一使用 GHz")
fig, ax1 = plt.subplots(figsize=(6.0, 5.0))
ax2 = ax1.twinx()
# ---------------- 绘制曲线 ----------------
# 左轴: PAEmax
line_pae, = ax1.plot(
freq,
pae_max,
color=COLOR_PAE,
ls="-",
lw=1.5,
label=r"$PAE_{\mathrm{max}}$",
)
# 右轴: OP1dB 与 Psat
line_op1db, = ax2.plot(
freq,
op1db,
color=COLOR_OP1DB,
ls="--",
lw=1.2,
label=r"$OP_{\mathrm{1dB}}$",
)
line_psat, = ax2.plot(
freq,
psat,
color=COLOR_PSAT,
ls="-.",
lw=1.2,
label=r"$P_{\mathrm{sat}}$",
)
# ---------------- 坐标轴与网格设置 ----------------
ax1.set_xlabel("Frequency (GHz)")
ax1.set_ylabel(r"$PAE_{\mathrm{max}}$ (%)")
ax2.set_ylabel(r"$OP_{\mathrm{1dB}}$ / $P_{\mathrm{sat}}$ (dBm)")
# 留出纵向边距, 为标注避让提供空间
ax1.margins(x=0.02, y=0.18)
ax2.margins(y=0.18)
ax1.grid(True, ls=":", lw=0.5, color="gray", alpha=0.6)
ax1.set_axisbelow(True)
ax2.grid(False)
# 双坐标轴刻度均向内, 与学术论文风格一致
ax1.tick_params(axis="both", direction="in")
ax2.tick_params(axis="both", direction="in")
ax1.spines["right"].set_visible(False)
ax2.spines["right"].set_visible(True)
ax2.spines["left"].set_visible(False)
ax2.yaxis.set_ticks_position("right")
ax2.yaxis.set_label_position("right")
# ---------------- 创建每条曲线的最大值标注 ----------------
items = []
ann_pae, f_pae_max, pae_peak = create_max_annotation(
ax1, freq, pae_max, COLOR_PAE, r"$PAE_{\mathrm{max}}$", "%")
items.append((ann_pae, f_pae_max, pae_peak, COLOR_PAE, ax1))
ann_op1db, f_op1db_max, op1db_peak = create_max_annotation(
ax2, freq, op1db, COLOR_OP1DB, r"$OP_{\mathrm{1dB}}$", "dBm")
items.append((ann_op1db, f_op1db_max, op1db_peak, COLOR_OP1DB, ax2))
ann_psat, f_psat_max, psat_peak = create_max_annotation(
ax2, freq, psat, COLOR_PSAT, r"$P_{\mathrm{sat}}$", "dBm")
items.append((ann_psat, f_psat_max, psat_peak, COLOR_PSAT, ax2))
# ---------------- 合并左右轴图例 ----------------
lines = [line_pae, line_op1db, line_psat]
labels = [line.get_label() for line in lines]
leg = ax1.legend(lines, labels, loc="upper right")
# ---------------- 图底说明行 ----------------
caption_template = (
r"Max $PAE_{\mathrm{max}}$ = %.2f %% @ %.2f GHz"
"\n"
r"Max $OP_{\mathrm{1dB}}$ = %.2f dBm @ %.2f GHz "
r"Max $P_{\mathrm{sat}}$ = %.2f dBm @ %.2f GHz"
)
caption = caption_template % (
pae_peak,
f_pae_max,
op1db_peak,
f_op1db_max,
psat_peak,
f_psat_max,
)
fig.tight_layout(rect=[0, 0.11, 1, 1])
fig.text(0.5, 0.02, caption, ha="center", va="bottom", fontsize=10.0)
# ---------------- 布局确定后进行标注重叠避让 ----------------
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
obstacles = [leg.get_window_extent(renderer)]
annotations = [item[0] for item in items]
resolved = resolve_annotation_overlaps(fig, ax1, annotations, obstacles)
if not resolved:
print("提示: 标注重叠未能在迭代上限内完全消除, 请检查数据范围")
# 对避让后远离数据点的标注补绘引导线
add_leader_lines(fig, items)
# ---------------- 保存图片 ----------------
out_dir = os.path.dirname(OUT_PATH)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
fig.savefig(OUT_PATH, bbox_inches="tight")
print(f"PAEmax 最大值 = {pae_peak:.2f} % @ f = {f_pae_max:.3f} GHz")
print(f"OP1dB 最大值 = {op1db_peak:.2f} dBm @ f = {f_op1db_max:.3f} GHz")
print(f"Psat 最大值 = {psat_peak:.2f} dBm @ f = {f_psat_max:.3f} GHz")
print(f"图片已保存: {OUT_PATH}")
if __name__ == "__main__":
main()4. getTime.py
该代码用于前述三个代码输出文件的命名,无输入输出。
←点击折叠/展开代码
import time
def get_current_time():
"""
获取当前时间的字符串表示,格式为 "YYYY-MM-DD_HH-MM-SS"。
:return: 当前时间的字符串表示
"""
return time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
def get_current_day():
"""
获取当前日期的字符串表示,格式为 "YYYYMMDD"。
:return: 当前日期的字符串表示
"""
return time.strftime("%Y%m%d", time.localtime())
if __name__ == "__main__":
current_time = get_current_time()
print(f"当前时间: {current_time}")开源:功率放大器学术图像绘制-Python
/archives/kai-yuan-gong-lu-fang-da-qi-xue-shu-tu-xiang-hui-zhi-python