6
0
1

开源:功率放大器学术图像绘制-Python

本文分享了绘制功率放大器(PA)小信号与大信号结果的学术风格曲线的Python源代码。

包括:

  1. 小信号(S参数)仿真结果,绘制S11、S21、S22,单位均为dB;

  2. 单独频点的大信号功率扫描结果,横坐标为输入功率(Pin,单位dBm),绘制 Gain(dB), Pout(dBm), PAE(%);

  3. 大信号的频率扫描结果,横坐标为频率,绘制 PAEmax(%), OP1dB(dBm), Psat(dBm)。

输入文件格式:

CSV文件

输出文件格式:

PNG文件(可在代码中自行修改为其他格式)

代码文件:

  1. plot_S参数仿真结果.py

  2. plot_大信号功率扫描结果.py

  3. plot_大信号仿真结果频率变化.py

  4. getTime.py

下面是各代码文件的源代码。

1. plot_S参数仿真结果.py

该代码用于绘制 S11(dB), S21(dB), S22(dB)随频率变化的学术图像,输入为CSV文件,输出为PNG文件。

CSV文件的默认序列为:

频率

S11(dB)

S21(dB)

S22(dB)

**

**

**

**

←点击折叠/展开代码
# -*- 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文件的默认序列为:

Pin(dBm)

Gain(dB)

Pout(dBm)

PAE(%)

**

**

**

**

←点击折叠/展开代码
#!/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文件的默认序列为:

频率

PAEmax(%)

OP1dB(dBm)

Psat(dBm)

**

**

**

**

←点击折叠/展开代码
#!/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
作者
YaoHaolin
发布于
2026-10-15
许可协议
CC BY-NC-SA 4.0

评论