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问答
") 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("对于一个典型的二阶系统,其标准传递函数形式为:
G(s) = ωn² / (s² + 2ζωns + ωn²)
响应从终值的10%上升到90%所需的时间
📐 近似公式:tr ≈ 1.8 / ωn
响应达到第一个峰值所需的时间
📐 公式:tp = π / (ωn√(1-ζ²)) = π / ωd
其中 ωd = ωn√(1-ζ²) 是阻尼振荡频率
响应超过稳态值的最大百分比
📐 公式:σ% = e(-πζ/√(1-ζ²)) × 100%
仅与阻尼比 ζ 有关
💡 常见值:
• ζ = 0.5 时,σ% ≈ 16%
• ζ = 0.707 时,σ% ≈ 4.3%
响应达到并保持在稳态值 ±2%(或±5%)范围内所需的时间
📐 公式:
• 2%误差带:ts ≈ 4 / (ζωn)
• 5%误差带:ts ≈ 3 / (ζωn)
主要由 ζωn 决定(系统时间常数)
不同输入下的稳态误差:
• 单位阶跃输入:ess = 1/(1+Kp)
• 单位斜坡输入:ess = 1/Kv
• 单位抛物线输入:ess = 1/Ka
其中 Kp, Kv, Ka 分别为位置、速度、加速度误差常数
频域分析通过研究系统对不同频率正弦信号的响应特性来评估系统性能。
定义: 在相角为-180°时,系统增益可以增加的最大倍数(或dB数),而不会使系统变得不稳定。
GMdB = -20 log10 |G(jωpc)|
ωpc (相角交越频率):系统相角等于-180°时的频率
| GM > 0 dB | ✅ 系统稳定 |
| GM = 0 dB | ⚠️ 临界稳定 |
| GM < 0 dB | ❌ 系统不稳定 |
💡 工程要求: 通常要求 GM ≥ 6 dB (约2倍),提供对增益变化的鲁棒性
定义: 在增益为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% |
闭环频率响应的幅值下降到-3dB时的频率。BW 越大,系统响应越快。
闭环频率响应的最大幅值。Mr 越小,系统阻尼越好。通常要求 Mr < 1.3 ~ 1.5。
定义: 根轨迹是当开环系统增益 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)
1️⃣ 起点和终点
2️⃣ 根轨迹分支数
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️⃣ 分离点/会合点
| 区域 | 条件 | 稳定性 |
|---|---|---|
| 左半平面 | Re(s) < 0 | ✅ 稳定 |
| 虚轴 | Re(s) = 0 | ⚠️ 临界稳定 |
| 右半平面 | Re(s) > 0 | ❌ 不稳定 |
稳定性判断准则:
从原点出发的射线代表恒定阻尼比 ζ 的轨迹:
θ = arccos(ζ)
| 阻尼比 ζ | 角度 θ | 系统响应特性 |
|---|---|---|
| 0.5 | 60° | 欠阻尼,有较大超调 |
| 0.707 | 45° | 最佳阻尼,超调适中 |
💡 工程应用:
通过根轨迹与阻尼比等值线的交点,可以选择满足动态性能要求的增益 K 值。