commit 9b4b3072a5262c2f496f4a59b929b702f8572cb4 Author: Hongru Date: Wed Oct 15 20:30:07 2025 +0800 第一版工程文件 diff --git a/app.py b/app.py new file mode 100644 index 0000000..edfde00 --- /dev/null +++ b/app.py @@ -0,0 +1,1006 @@ +import gradio as gr +import numpy as np +import control as ct +import matplotlib.pyplot as plt +import re + +# ==================== API 配置 ==================== +# 将 API 相关配置集中在此处,方便修改 +# +# DeepSeek API 配置说明: +# 1. API_KEY: 您的 DeepSeek API 密钥 +# - 从 https://platform.deepseek.com/api_keys 获取 +# - 或使用环境变量: API_KEY = os.environ.get("DEEPSEEK_API_KEY", "") +# +# 2. API_BASE_URL: API 服务的基础 URL +# - 官方地址: https://api.deepseek.com/v1 +# - DeepSeek API 兼容 OpenAI 格式 +# +# 3. API_MODEL: 使用的 DeepSeek 模型名称 +# - deepseek-chat (推荐,性能强大) +# - deepseek-coder (代码专用) +# - 其他可用模型请参考官方文档 + +API_KEY = "sk-2292af2428d7419897ca1fb6e99ba6bc" # 请在此处填入您的 DeepSeek API 密钥 +API_BASE_URL = "https://api.deepseek.com/v1" # API 基础 URL +API_MODEL = "deepseek-chat" # 使用的模型名称 +API_TYPE = "deepseek" # API 类型: "deepseek" 或 "gemini" +# ================================================== +import json +import os + +# --- [FIXED] 辅助函数:将系数数组转换为LaTeX数学公式字符串 --- +def coeffs_to_latex(coeffs, var='s'): + """ + 一个更健壮的函数,用于将系数数组转换为美观的LaTeX字符串。 + """ + coeffs = np.atleast_1d(coeffs) + # 如果只有一个系数(常数),直接返回 + if coeffs.size == 1: + return f"{coeffs[0]:g}" + + latex_str = "" + degree = len(coeffs) - 1 + for i, coeff in enumerate(coeffs): + # 跳过系数为0的项 + if np.isclose(coeff, 0): + continue + + # 确定符号 + sign = "" + if i > 0 and latex_str: + if coeff > 0: + sign = " + " + else: + sign = " - " + elif coeff < 0: + sign = "-" + + # 处理绝对值 + coeff_abs = abs(coeff) + + # 处理系数的显示 + coeff_str = "" + # 仅当系数不为1或为常数项时显示系数 + if not np.isclose(coeff_abs, 1) or degree - i == 0: + coeff_str = f"{coeff_abs:g}" + + # 处理变量和幂 + power = degree - i + power_str = "" + if power > 0: + power_str = var + if power > 1: + power_str += f"^{{{power}}}" + + latex_str += f"{sign}{coeff_str}{power_str}" + + return latex_str if latex_str else "0" + + +# --- 功能函数1:显示传递函数 --- +def display_transfer_function(num_str, den_str): + try: + num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str) + den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str) + num_coeffs = np.array([float(n) for n in num_str_cleaned.split(',') if n]) + den_coeffs = np.array([float(d) for d in den_str_cleaned.split(',') if d]) + if num_coeffs.size == 0 or den_coeffs.size == 0: + return "分子或分母不能为空" + num_latex = coeffs_to_latex(num_coeffs) + den_latex = coeffs_to_latex(den_coeffs) + tf_latex = f"$$ G(s) = \\frac{{{num_latex}}}{{{den_latex}}} $$" + return tf_latex + except Exception as e: + return f"输入格式错误: {e}" + +# --- 功能函数2:执行时域分析和绘图 --- +def time_domain_analysis(num_str, den_str): + try: + num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str) + den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str) + num = np.array([float(n) for n in num_str_cleaned.split(',') if n]) + den = np.array([float(d) for d in den_str_cleaned.split(',') if d]) + if num.size == 0 or den.size == 0: + return None, "错误:分子或分母系数不能为空。" + system = ct.TransferFunction(num, den) + t = np.linspace(0, 15, 1000) + T_step, yout_step = ct.step_response(system, T=t) + T_impulse, yout_impulse = ct.impulse_response(system, T=t) + + try: + info = ct.step_info(system) + if isinstance(info, dict): + metrics_text = ( + f"Rise Time: {info.get('RiseTime', float('nan')):.2f} s\n" + f"Peak Time: {info.get('PeakTime', float('inf')):.2f} s\n" + f"Peak: {info.get('Peak', float('inf')):.2f}\n" + f"Overshoot: {info.get('Overshoot', float('nan')):.1f} %\n" + f"Settling Time: {info.get('SettlingTime', float('nan')):.2f} s\n" + f"Steady State Value: {info.get('SteadyStateValue', float('nan')):.2f}" + ) + else: + metrics_text = "系统性能指标计算失败。" + except RuntimeError: + metrics_text = "系统可能不稳定,无法计算阶跃响应指标。" + except Exception: + metrics_text = "无法计算所有性能指标。" + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) + + ax1.plot(T_step, yout_step); ax1.set_title("Unit Step Response"); ax1.set_xlabel("Time (s)"); ax1.set_ylabel("Amplitude"); ax1.grid(True) + ax2.plot(T_impulse, yout_impulse); ax2.set_title("Unit Impulse Response"); ax2.set_xlabel("Time (s)"); ax2.set_ylabel("Amplitude"); ax2.grid(True) + + plt.tight_layout() + + return fig, metrics_text + except Exception as e: + return None, f"错误: {e}\n请检查您的输入。" + +# --- 功能函数3:执行频域分析和绘图 --- +def frequency_domain_analysis(num_str, den_str, k): + try: + num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str) + den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str) + num_coeffs = np.array([float(n) for n in num_str_cleaned.split(',') if n]) + den_coeffs = np.array([float(d) for d in den_str_cleaned.split(',') if d]) + + if num_coeffs.size == 0 or den_coeffs.size == 0: + return None, "错误:分子或分母系数不能为空。", "", "" + + system_with_gain = ct.TransferFunction(k * num_coeffs, den_coeffs) + + num_latex = coeffs_to_latex(num_coeffs) + den_latex = coeffs_to_latex(den_coeffs) + + tf_latex_with_gain = f"$$ G_{{open}}(s) = K \\times \\frac{{{num_latex}}}{{{den_latex}}} \\quad \\text{{where }} K = {k:.2f} $$" + + fig = plt.figure(figsize=(12, 6)); gs = fig.add_gridspec(2, 2) + ax_mag = fig.add_subplot(gs[0, 0]); ax_phase = fig.add_subplot(gs[1, 0], sharex=ax_mag) + omega_range = np.logspace(-2, 3, 1000) + mag, phase, omega = ct.frequency_response(system_with_gain, omega_range) + + ax_mag.semilogx(omega, 20 * np.log10(mag)); ax_mag.grid(True, which='both'); ax_mag.set_ylabel("Magnitude (dB)"); ax_mag.set_title("Bode Plot") + ax_phase.semilogx(omega, np.rad2deg(phase)); ax_phase.grid(True, which='both'); ax_phase.set_ylabel("Phase (deg)"); ax_phase.set_xlabel("Frequency (rad/s)"); ax_phase.axhline(y=-180, color='r', linestyle='--', linewidth=0.8) + + ax_nyquist = fig.add_subplot(gs[:, 1]) + ct.nyquist_plot(system_with_gain, ax=ax_nyquist); ax_nyquist.set_title("Nyquist Plot"); ax_nyquist.grid(True) + plt.tight_layout() + + try: + gm, pm, _, _ = ct.margin(system_with_gain) + gm_db = 20 * np.log10(gm) if gm > 0 and np.isfinite(gm) else float('inf') + + is_stable = gm_db > 0 and pm > 0 + stability_text = f"**Evaluation**: **{'System Stable' if is_stable else 'System Unstable'}**" + + metrics_text = "" + if np.isinf(gm_db): metrics_text += f"Gain Margin (GM): inf dB\n(Note: Phase never crosses -180° line)\n" + else: metrics_text += f"Gain Margin (GM): {gm_db:.2f} dB\n" + if np.isinf(pm): metrics_text += f"Phase Margin (PM): not defined" + else: metrics_text += f"Phase Margin (PM): {pm:.2f} deg" + except Exception as e: + metrics_text = f"Unable to compute stability margins."; stability_text = "**Evaluation**: **Cannot determine**" + + return fig, metrics_text, tf_latex_with_gain, stability_text + except Exception as e: + return None, f"错误: {e}", "", "" + +# --- 功能函数4:执行根轨迹分析 --- +def root_locus_analysis(num_str, den_str, log_k): + try: + k = 10**log_k + num_str_cleaned = re.sub(r'[^0-9,\-.]', '', num_str) + den_str_cleaned = re.sub(r'[^0-9,\-.]', '', den_str) + num_coeffs = np.array([float(n) for n in num_str_cleaned.split(',') if n]) + den_coeffs = np.array([float(d) for d in den_str_cleaned.split(',') if d]) + + if num_coeffs.size == 0 or den_coeffs.size == 0: + return None, "错误:分子或分母系数不能为空。", k + + open_loop_system = ct.TransferFunction(num_coeffs, den_coeffs) + fig, ax = plt.subplots(figsize=(8, 6)) + + closed_loop_system = ct.feedback(k * open_loop_system, 1) + current_poles = ct.poles(closed_loop_system) + ol_poles = open_loop_system.poles(); ol_zeros = open_loop_system.zeros() + points_of_interest = np.concatenate(([0j], ol_poles, ol_zeros, current_poles)) + + min_real = np.min(np.real(points_of_interest)); max_real = np.max(np.real(points_of_interest)) + min_imag = np.min(np.imag(points_of_interest)); max_imag = np.max(np.imag(points_of_interest)) + + center_real = (max_real + min_real) / 2; span_real = max(abs(max_real - min_real), 2) * 1.5 + center_imag = (max_imag + min_imag) / 2; span_imag = max(abs(max_imag - min_imag), 2) * 1.5 + max_span = max(span_real, span_imag) + + rlist, klist = ct.root_locus(open_loop_system, plot=False, grid=False) + for i in range(rlist.shape[1]): ax.plot(np.real(rlist[:, i]), np.imag(rlist[:, i]), 'b-') + + ax.set_xlim(center_real - max_span / 2, center_real + max_span / 2); ax.set_ylim(center_imag - max_span / 2, center_imag + max_span / 2) + ax.plot(np.real(current_poles), np.imag(current_poles), 'rx', markersize=10, markeredgewidth=2, label=f'Poles at K={k:.2f}') + ax.set_xlabel("Real Axis"); ax.set_ylabel("Imaginary Axis"); ax.set_title("Root Locus"); ax.grid(True); ax.legend(loc='upper right'); ax.set_aspect('equal', adjustable='box') + + poles_text = "Closed-Loop Poles:\n" + for p in current_poles: poles_text += f"{p.real:.3f} {'+' if p.imag >= 0 else '-'} {abs(p.imag):.3f}j\n" + return fig, poles_text, k + except Exception as e: + return None, f"错误: {e}", 10**log_k + +# --- [新增] 功能函数5: AI 智能问答 (支持 DeepSeek 和 Gemini) --- +# 注意: API 配置已移至文件开头的配置区域,方便统一管理和修改 +# 如果您在本地运行并设置了环境变量,可以在配置区域使用: +# API_KEY = os.environ.get("DEEPSEEK_API_KEY", "") + +# 异步函数以处理流式响应 +async def chat_with_ai(message, history): + """ + 与 AI 模型进行流式对话。支持 DeepSeek 和 Gemini API。 + """ + # 系统指令,设定AI的角色和回答风格 + system_prompt = "你是一位精通自动控制原理的专家教授。请用清晰、准确、专业的中文来回答有关自动控制课程内容的问题。在适当的时候,可以使用公式和示例来辅助解释。" + + # 检查 API_KEY 是否配置 + if not API_KEY or API_KEY.strip() == "": + history.append([message, "❌ 错误:API_KEY 未配置。请在文件开头配置 API_KEY。"]) + yield history + return + + # 初始化机器人回复 + bot_response = "" + history.append([message, "正在思考..."]) + yield history # 立即显示用户消息 + + try: + import aiohttp + + if API_TYPE == "deepseek": + # DeepSeek API (OpenAI 兼容格式) + api_url = f"{API_BASE_URL}/chat/completions" + + # 构造消息历史 + messages = [{"role": "system", "content": system_prompt}] + for user_msg, bot_msg in history[:-1]: # 排除最后一条(刚添加的) + if user_msg: + messages.append({"role": "user", "content": user_msg}) + if bot_msg: + messages.append({"role": "assistant", "content": bot_msg}) + messages.append({"role": "user", "content": message}) + + payload = { + "model": API_MODEL, + "messages": messages, + "stream": True, + "temperature": 0.7, + "max_tokens": 2048 + } + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {API_KEY}" + } + + async with aiohttp.ClientSession() as session: + async with session.post(api_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as response: + if response.status == 200: + # 处理流式响应 + async for line in response.content: + line = line.decode('utf-8').strip() + if not line or line == "data: [DONE]": + continue + if line.startswith("data: "): + line = line[6:] + try: + data = json.loads(line) + if "choices" in data and len(data["choices"]) > 0: + delta = data["choices"][0].get("delta", {}) + content = delta.get("content", "") + if content: + bot_response += content + history[-1][1] = bot_response + yield history + except json.JSONDecodeError: + pass + + if not bot_response: + history[-1][1] = "⚠️ API 返回了空响应,请稍后重试。" + yield history + else: + error_text = await response.text() + history[-1][1] = f"❌ API请求出错 (状态码: {response.status}):\n{error_text}" + yield history + + else: # Gemini API + api_url = f"{API_BASE_URL}/models/{API_MODEL}:streamGenerateContent?key={API_KEY}" + + # 构造 Gemini 格式的消息历史 + api_history = [] + for user_msg, bot_msg in history[:-1]: + if user_msg: + api_history.append({"role": "user", "parts": [{"text": user_msg}]}) + if bot_msg: + api_history.append({"role": "model", "parts": [{"text": bot_msg}]}) + + payload = { + "contents": api_history + [{"role": "user", "parts": [{"text": message}]}], + "systemInstruction": {"parts": [{"text": system_prompt}]}, + "generationConfig": { + "temperature": 0.7, + "topK": 1, + "topP": 1, + "maxOutputTokens": 2048, + } + } + + async with aiohttp.ClientSession() as session: + async with session.post(api_url, json=payload, headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=60)) as response: + if response.status == 200: + has_content = False + async for chunk in response.content.iter_any(): + chunk_text = chunk.decode('utf-8') + for line in chunk_text.split('\n'): + if not line.strip(): + continue + if line.startswith('data: '): + line = line[6:] + try: + data = json.loads(line) + if "candidates" in data and len(data["candidates"]) > 0: + candidate = data["candidates"][0] + if "content" in candidate and "parts" in candidate["content"]: + text_part = candidate["content"]["parts"][0].get("text", "") + if text_part: + has_content = True + bot_response += text_part + history[-1][1] = bot_response + yield history + except json.JSONDecodeError: + pass + + if not has_content: + history[-1][1] = "⚠️ API 返回了空响应,请稍后重试。" + yield history + else: + error_text = await response.text() + history[-1][1] = f"❌ API请求出错 (状态码: {response.status}):\n{error_text}" + yield history + + except aiohttp.ClientError as e: + history[-1][1] = f"❌ 网络连接错误: {e}\n请检查网络连接或 API_BASE_URL 配置。" + yield history + except Exception as e: + history[-1][1] = f"❌ 发生错误: {type(e).__name__}: {e}" + yield history + + +# --- Gradio 界面定义 --- +# 自定义 CSS 样式 +custom_css = """ +/* 整体界面样式优化 */ +.gradio-container { + font-family: 'Segoe UI', Arial, sans-serif !important; +} + +/* 标题样式 */ +.main-title { + text-align: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-size: 2.5em !important; + font-weight: bold; + margin-bottom: 0.5em; +} + +.subtitle { + text-align: center; + color: #666; + font-size: 1.1em; + margin-bottom: 2em; +} + +/* 可滚动的知识卡片 */ +.knowledge-card { + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + border-radius: 12px; + padding: 20px; + margin-top: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-height: 500px; + overflow-y: auto; +} + +.knowledge-card::-webkit-scrollbar { + width: 8px; +} + +.knowledge-card::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +.knowledge-card::-webkit-scrollbar-thumb { + background: #888; + border-radius: 10px; +} + +.knowledge-card::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* 按钮样式优化 */ +.primary-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; + border: none !important; + color: white !important; + font-weight: bold !important; + transition: transform 0.2s; +} + +.primary-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important; +} + +/* 标签页样式 */ +.tab-nav button { + font-weight: 600 !important; + font-size: 1.05em !important; +} + +/* 图表容器 */ +.plot-container { + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +/* 输入框样式 */ +.input-section { + background: #f8f9fa; + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; +} + +/* 卡片标题 */ +.card-title { + font-size: 1.3em; + font-weight: bold; + color: #333; + margin-bottom: 10px; + border-bottom: 3px solid #667eea; + padding-bottom: 8px; +} +""" + +with gr.Blocks(title="自动控制原理学习网站", css=custom_css) as demo: + gr.Markdown("

自动控制原理AI+数智平台

") + gr.Markdown("

交互式控制系统分析与设计工具 | 时域·频域·根轨迹·AI问答

") + + with gr.Tabs() as tabs: + with gr.TabItem("时域分析 (Time Domain)", id=0): + with gr.Row(): + with gr.Column(scale=1): + with gr.Group(): + gr.Markdown("
📊 输入系统参数
") + num_input = gr.Textbox(label="传递函数分子系数 (Numerator)", value="1", placeholder="例如: 1 或 1,2,3") + den_input = gr.Textbox(label="传递函数分母系数 (Denominator)", value="1,6,11,6", placeholder="例如: 1,2,1") + with gr.Row(): + confirm_button = gr.Button("✓ 显示传递函数", variant="secondary", scale=1) + analyze_button = gr.Button("🚀 开始分析", variant="primary", scale=1, elem_classes="primary-btn") + + with gr.Group(): + gr.Markdown("
🔧 系统模型
") + tf_display = gr.Markdown(label="当前传递函数") + + with gr.Group(): + gr.Markdown("
📈 动态性能指标
") + output_metrics = gr.Textbox(label="Performance Metrics", lines=8, interactive=False) + + with gr.Column(scale=2): + output_plot = gr.Plot(label="Time Response Curves", elem_classes="plot-container") + + # 使用 HTML 创建可滚动的知识卡片 + gr.HTML(""" +
+

📚 常用公式 (Common Formulas)

+ +

二阶系统标准形式

+

对于一个典型的二阶系统,其标准传递函数形式为:

+

G(s) = ωn² / (s² + 2ζωns + ωn²)

+ +

参数说明

+ + +

时域性能指标(欠阻尼系统,0 < ζ < 1)

+ +
+ 1️⃣ 上升时间 (Rise Time, tr) +

响应从终值的10%上升到90%所需的时间

+

📐 近似公式:tr ≈ 1.8 / ωn

+
+ +
+ 2️⃣ 峰值时间 (Peak Time, tp) +

响应达到第一个峰值所需的时间

+

📐 公式:tp = π / (ωn√(1-ζ²)) = π / ωd

+

其中 ωd = ωn√(1-ζ²) 是阻尼振荡频率

+
+ +
+ 3️⃣ 超调量 (Percent Overshoot, σ%) +

响应超过稳态值的最大百分比

+

📐 公式:σ% = e(-πζ/√(1-ζ²)) × 100%

+

仅与阻尼比 ζ 有关

+

💡 常见值: +
• ζ = 0.5 时,σ% ≈ 16% +
• ζ = 0.707 时,σ% ≈ 4.3% +

+
+ +
+ 4️⃣ 调节时间 (Settling Time, ts) +

响应达到并保持在稳态值 ±2%(或±5%)范围内所需的时间

+

📐 公式: +
• 2%误差带:ts ≈ 4 / (ζωn) +
• 5%误差带:ts ≈ 3 / (ζωn) +

+

主要由 ζωn 决定(系统时间常数)

+
+ +
+ 5️⃣ 稳态误差 (Steady-State Error, ess) +

不同输入下的稳态误差:

+

• 单位阶跃输入:ess = 1/(1+Kp) +
• 单位斜坡输入:ess = 1/Kv +
• 单位抛物线输入:ess = 1/Ka

+

其中 Kp, Kv, Ka 分别为位置、速度、加速度误差常数

+
+
+ """) + + with gr.TabItem("频域分析 (Frequency Domain)", id=1): + with gr.Row(): + with gr.Column(scale=1): + with gr.Group(): + gr.Markdown("
🎚️ 调整系统增益
") + log_k_slider_freq = gr.Slider(minimum=-1, maximum=3, value=1, step=0.01, label="对数增益 log10(K)") + k_number_display_freq = gr.Number(value=10.0, label="增益 K (Gain)", interactive=False) + + with gr.Group(): + gr.Markdown("
🔧 当前系统模型
") + freq_tf_display = gr.Markdown(label="含增益K的传递函数") + + with gr.Group(): + gr.Markdown("
📊 稳定裕度
") + freq_metrics_display = gr.Textbox(label="Stability Margins", lines=4, interactive=False) + freq_stability_display = gr.Markdown() + + with gr.Column(scale=2): + freq_plot_output = gr.Plot(label="Frequency Response Plots", elem_classes="plot-container") + + # 频域分析知识卡片 + gr.HTML(""" +
+

📚 常用定义 (Common Definitions)

+ +
+ 📈 频域分析基础 +
+

频域分析通过研究系统对不同频率正弦信号的响应特性来评估系统性能。

+
    +
  • Bode图:幅频和相频特性
  • +
  • Nyquist图:极坐标表示
  • +
  • 稳定裕度:系统稳定性余量
  • +
+
+
+ +
+ 🎯 增益裕度 (Gain Margin, GM) +
+

定义: 在相角为-180°时,系统增益可以增加的最大倍数(或dB数),而不会使系统变得不稳定。

+

+ GMdB = -20 log10 |G(jωpc)| +

+ +

关键概念

+

ωpc (相角交越频率):系统相角等于-180°时的频率

+ +

判断准则

+ + + + +
GM > 0 dB✅ 系统稳定
GM = 0 dB⚠️ 临界稳定
GM < 0 dB❌ 系统不稳定
+ +

+ 💡 工程要求: 通常要求 GM ≥ 6 dB (约2倍),提供对增益变化的鲁棒性 +

+
+
+ +
+ 🎯 相角裕度 (Phase Margin, PM) +
+

定义: 在增益为1(0dB)时,系统相角与-180°之间的差值。

+

+ PM = 180° + ∠G(jωgc) +

+ +

关键概念

+

ωgc (增益交越频率):系统幅值等于1(0dB)时的频率

+ +

判断准则

+ + + + +
PM > 0°✅ 系统稳定
PM = 0°⚠️ 临界稳定
PM < 0°❌ 系统不稳定
+ +

+ 💡 工程要求: 通常要求 PM ≥ 30° ~ 60°
+ • PM ≈ 45° ~ 60° : 良好的阻尼特性
+ • PM 越大,系统超调量越小 +

+ +

与时域性能的关系

+

对于二阶系统:ζ ≈ PM/100 (PM以度为单位)

+ + + + + +
PMζ超调量
30°≈ 0.3≈ 37%
45°≈ 0.45≈ 20%
60°≈ 0.6≈ 10%
+
+
+ +
+ 📊 其他重要概念 +
+

带宽 (Bandwidth, BW)

+

闭环频率响应的幅值下降到-3dB时的频率。BW 越大,系统响应越快。

+ +

谐振峰值 (Resonant Peak, Mr)

+

闭环频率响应的最大幅值。Mr 越小,系统阻尼越好。通常要求 Mr < 1.3 ~ 1.5。

+
+
+
+ """) + + with gr.TabItem("根轨迹 (Root Locus)", id=2): + with gr.Row(): + with gr.Column(scale=1): + gr.Markdown("### 1. 调整系统增益"); log_k_slider_rl = gr.Slider(minimum=-1, maximum=3, value=1, step=0.01, label="对数增益 log10(K)") + k_number_display = gr.Number(value=10.0, label="增益 K (Gain)", interactive=False); gr.Markdown("### 2. 当前增益下的闭环极点") + rl_poles_display = gr.Textbox(label="Closed-Loop Pole Locations", lines=4, interactive=False) + with gr.Column(scale=2): + rl_plot_output = gr.Plot(label="Root Locus Plot") + rl_formula_text = """ +
+

📚 根轨迹知识要点 (Root Locus Essentials)

+ +
+ + 🎯 根轨迹法简介 + +
+

定义: 根轨迹是当开环系统增益 K 从 0 变化到 时,闭环系统特征方程的根(极点)在 s 平面上描绘出的轨迹。

+ +

主要作用:

+
    +
  • ✅ 直观显示参数变化对系统极点位置的影响
  • +
  • ✅ 判断系统稳定性
  • +
  • ✅ 选择合适的增益值
  • +
  • ✅ 设计控制器参数
  • +
+
+
+ +
+ + 🔧 闭环系统与特征方程 + +
+

对于单位负反馈系统,闭环传递函数为:

+

+ T(s) = KG(s) / [1 + KG(s)H(s)] +

+ +

特征方程:

+

+ 1 + K G(s)H(s) = 0 或 K G(s)H(s) = -1 +

+ + + + + + + + + + + + + + + + + + + + + + +
符号含义
G(s)前向通道传递函数
H(s)反馈通道传递函数(单位反馈时 H(s)=1)
K可变增益参数
特征方程的根闭环极点
+
+
+ +
+ + 📐 根轨迹绘制的基本条件 + +
+

设开环传递函数为:

+

+ G(s)H(s) = K(s-z1)(s-z2)···(s-zm) / [(s-p1)(s-p2)···(s-pn)] +

+ +

根轨迹上的点 s0 必须满足:

+ +
+

1️⃣ 幅值条件(充要条件)

+

|K G(s0)H(s0)| = 1

+

💡 物理意义: 确定增益 K 的值,使得 s0 成为闭环极点。

+
+ +
+

2️⃣ 相角条件(充要条件)

+

∠G(s0)H(s0) = (2k+1)180°

+

其中 k = 0, ±1, ±2, ±3, ...

+

💡 物理意义: 判断 s 平面上某点是否在根轨迹上。

+ +

角度计算公式:

+

∠G(s0)H(s0) = Σ∠(s0-zi) - Σ∠(s0-pj)

+
    +
  • 从所有零点到 s0 的角度之和
  • +
  • 减去从所有极点到 s0 的角度之和
  • +
+
+
+
+ +
+ + 🌟 根轨迹的基本性质 + +
+ +
+

1️⃣ 起点和终点

+
    +
  • 🟢 起点 (K=0):开环极点 pj
  • +
  • 🔴 终点 (K→∞):开环零点 zi 或无穷远处
  • +
+
+ +
+

2️⃣ 根轨迹分支数

+
    +
  • 分支数 = max(n, m),其中 n=极点数,m=零点数
  • +
  • 当 n > m 时,有 (n-m) 条分支趋向无穷远
  • +
+
+ +
+

3️⃣ 实轴上的根轨迹

+

实轴上某区段,若其右侧的实数开环零点和极点总数为奇数,则该区段在根轨迹上。

+
+ +
+

4️⃣ 渐近线

+

当 n > m 时,有 (n-m) 条分支沿渐近线趋向无穷远:

+

+ 渐近线角度: φa = (2k+1)180° / (n-m) +

+

k = 0, 1, 2, ..., (n-m-1)

+ +

+ 渐近线交点(重心): σa = (Σpj - Σzi) / (n-m) +

+
+ +
+

5️⃣ 分离点/会合点

+
    +
  • 定义:多条根轨迹分支分离或会合的点
  • +
  • 求解条件:dK/ds = 0
  • +
+
+
+
+ +
+ + 🛡️ s 平面的稳定性区域 + +
+ + + + + + + + + + + + + + + + + + + + + +
区域条件稳定性
左半平面Re(s) < 0✅ 稳定
虚轴Re(s) = 0⚠️ 临界稳定
右半平面Re(s) > 0❌ 不稳定
+ +
+

稳定性判断准则:

+
    +
  • ✅ 所有闭环极点都在左半平面 → 系统稳定
  • +
  • ❌ 有极点在右半平面 → 系统不稳定
  • +
  • ⚠️ 有极点在虚轴上 → 临界稳定
  • +
+
+
+
+ +
+ + 📏 阻尼比等值线 + +
+

从原点出发的射线代表恒定阻尼比 ζ 的轨迹:

+

+ θ = arccos(ζ) +

+ + + + + + + + + + + + + + + + + +
阻尼比 ζ角度 θ系统响应特性
0.560°欠阻尼,有较大超调
0.70745°最佳阻尼,超调适中
+ +
+

💡 工程应用:

+

通过根轨迹与阻尼比等值线的交点,可以选择满足动态性能要求的增益 K 值。

+
+
+
+ +
+ """ + gr.HTML(rl_formula_text) + + with gr.TabItem("智能问答 (Q&A)", id=3): + chatbot = gr.Chatbot( + label="自控原理小助手", + bubble_full_width=False, + avatar_images=(None, "https://img.icons8.com/plasticine/100/bot.png"), + height=600, + latex_delimiters=[ + {"left": "$$", "right": "$$", "display": True}, + {"left": "$", "right": "$", "display": False}, + {"left": "\\[", "right": "\\]", "display": True}, + {"left": "\\(", "right": "\\)", "display": False} + ] + ) + chat_input = gr.Textbox(label="您的问题", placeholder="你好,请解释一下什么是PID控制器?", scale=4) + send_button = gr.Button("发送", variant="primary", scale=1) + + + # --- 事件绑定部分 --- + confirm_button.click(fn=display_transfer_function, inputs=[num_input, den_input], outputs=tf_display) + analyze_button.click(fn=time_domain_analysis, inputs=[num_input, den_input], outputs=[output_plot, output_metrics]) + + def update_frequency_analysis(num, den, log_k): + k = 10**log_k + fig, metrics, tf_latex, stability = frequency_domain_analysis(num, den, k) + return fig, metrics, tf_latex, stability, k + + log_k_slider_freq.release(fn=update_frequency_analysis, inputs=[num_input, den_input, log_k_slider_freq], outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq]) + num_input.change(fn=update_frequency_analysis, inputs=[num_input, den_input, log_k_slider_freq], outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq]) + den_input.change(fn=update_frequency_analysis, inputs=[num_input, den_input, log_k_slider_freq], outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq]) + + def update_rl_view(log_k, num, den): + fig, poles, k_val = root_locus_analysis(num, den, log_k) + return fig, poles, k_val + + log_k_slider_rl.release(fn=update_rl_view, inputs=[log_k_slider_rl, num_input, den_input], outputs=[rl_plot_output, rl_poles_display, k_number_display]) + num_input.change(fn=update_rl_view, inputs=[log_k_slider_rl, num_input, den_input], outputs=[rl_plot_output, rl_poles_display, k_number_display]) + den_input.change(fn=update_rl_view, inputs=[log_k_slider_rl, num_input, den_input], outputs=[rl_plot_output, rl_poles_display, k_number_display]) + + # 聊天机器人事件处理 - 支持 DeepSeek 和 Gemini + # 按钮点击事件 + send_button.click( + fn=chat_with_ai, + inputs=[chat_input, chatbot], + outputs=chatbot, + ).then( + lambda: "", # 清空输入框 + outputs=chat_input + ) + + # 输入框回车事件 + chat_input.submit( + fn=chat_with_ai, + inputs=[chat_input, chatbot], + outputs=chatbot, + ).then( + lambda: "", # 清空输入框 + outputs=chat_input + ) + + def on_tab_select(evt: gr.SelectData, num, den, log_k_freq, log_k_rl): + outputs = { + freq_plot_output: gr.update(), + freq_metrics_display: gr.update(), + freq_tf_display: gr.update(), + freq_stability_display: gr.update(), + k_number_display_freq: gr.update(), + rl_plot_output: gr.update(), + rl_poles_display: gr.update(), + k_number_display: gr.update() + } + if evt.index == 1: + k_freq = 10**log_k_freq + fig, metrics, tf_latex, stability = frequency_domain_analysis(num, den, k_freq) + outputs[freq_plot_output], outputs[freq_metrics_display], outputs[freq_tf_display], outputs[freq_stability_display], outputs[k_number_display_freq] = fig, metrics, tf_latex, stability, k_freq + elif evt.index == 2: + fig, poles, k_val = root_locus_analysis(num, den, log_k_rl) + outputs[rl_plot_output], outputs[rl_poles_display], outputs[k_number_display] = fig, poles, k_val + return outputs + + tabs.select( + on_tab_select, + inputs=[num_input, den_input, log_k_slider_freq, log_k_slider_rl], + outputs=[freq_plot_output, freq_metrics_display, freq_tf_display, freq_stability_display, k_number_display_freq, rl_plot_output, rl_poles_display, k_number_display] + ) + +if __name__ == "__main__": + # 需要安装 aiohttp: pip install aiohttp + demo.launch(debug=True)