From 6f2f851a6e95d98bd8597cd9beb02ef0aeb93711 Mon Sep 17 00:00:00 2001 From: dmy Date: Sun, 4 Jan 2026 11:53:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EWeb=20GUI=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BA=A4=E4=BA=92=E5=BC=8F?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=AF=B9=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gui.py | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 94 ++++++++++++++-------- 2 files changed, 300 insertions(+), 32 deletions(-) create mode 100644 gui.py diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..535cc4a --- /dev/null +++ b/gui.py @@ -0,0 +1,238 @@ +import os +import sys +import io +import contextlib +import matplotlib.pyplot as plt +from nicegui import ui, events +from main import compare_design_methods, export_to_dxf, load_data_from_excel, generate_wind_farm_data, visualize_design +import pandas as pd + +# 设置matplotlib支持中文显示 +plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'Arial'] +plt.rcParams['axes.unicode_minus'] = False + +class Logger(io.StringIO): + def __init__(self, log_element): + super().__init__() + self.log_element = log_element + + def write(self, message): + if message.strip(): + self.log_element.push(message.strip()) + super().write(message) + +# 状态变量 +state = { + 'excel_path': None, + 'results': [], + 'substation': None, + 'turbines': None, + 'temp_dir': '.gemini/tmp/gui_uploads' +} + +# 确保临时目录存在 +if not os.path.exists(state['temp_dir']): + os.makedirs(state['temp_dir'], exist_ok=True) + +@ui.page('/') +def index(): + ui.query('body').style('background-color: #f0f2f5; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;') + + # 定义 UI 元素引用容器,方便在函数中更新 + refs = { + 'log_box': None, + 'results_table': None, + 'plot_container': None, + 'export_row': None, + 'status_label': None, + 'upload_widget': None, + 'clusters_input': None, + 'run_btn': None + } + + async def handle_upload(e: events.UploadEventArguments): + try: + filename = None + content = None + if hasattr(e, 'name'): + filename = e.name + if hasattr(e, 'content'): + content = e.content + if content is None and hasattr(e, 'file'): + file_obj = e.file + if not filename: + filename = getattr(file_obj, 'name', getattr(file_obj, 'filename', None)) + if hasattr(file_obj, 'file') and hasattr(file_obj.file, 'read'): + content = file_obj.file + elif hasattr(file_obj, 'read'): + content = file_obj + if not filename: + filename = 'uploaded_file.xlsx' + if content is None: + ui.notify('上传失败: 无法解析文件内容', type='negative') + return + path = os.path.join(state['temp_dir'], filename) + if hasattr(content, 'seek'): + try: content.seek(0) + except Exception: pass + data = content.read() + import inspect + if inspect.iscoroutine(data): + data = await data + with open(path, 'wb') as f: + f.write(data) + state['excel_path'] = path + ui.notify(f'文件已上传: {filename}', type='positive') + state['turbines'], state['substation'], _ = load_data_from_excel(path) + except Exception as ex: + ui.notify(f'上传处理失败: {ex}', type='negative') + + def update_export_buttons(): + if refs['export_row']: + refs['export_row'].clear() + if not state['results'] or not refs['export_row']: + return + with refs['export_row']: + ui.button('下载 Excel 对比表', on_click=lambda: ui.download('wind_farm_design.xlsx')).props('icon=download') + best_idx = 0 + for i, res in enumerate(state['results']): + if res['cost'] < state['results'][best_idx]['cost']: + best_idx = i + best_res = state['results'][best_idx] + def export_best_dxf(): + dxf_name = 'best_design.dxf' + if state['substation'] is not None: + export_to_dxf(best_res['turbines'], state['substation'], best_res['eval']['details'], dxf_name) + ui.download(dxf_name) + ui.notify(f'已导出推荐方案: {best_res["name"]}', type='positive') + else: + ui.notify('缺少升压站数据,无法导出 DXF', type='negative') + ui.button(f'导出推荐方案 DXF ({best_res["name"]})', on_click=export_best_dxf).props('icon=architecture color=accent') + + def update_plot(result): + if refs['plot_container']: + refs['plot_container'].clear() + with refs['plot_container']: + with ui.pyplot(figsize=(10, 8)) as plot: + title = f"{result['name']}\nCost: ¥{result['cost']/10000:.2f}万 | Loss: {result['loss']:.2f} kW" + # 获取当前 ui.pyplot 创建的 axes + ax = plt.gca() + visualize_design(result['turbines'], state['substation'], result['eval']['details'], title, ax=ax) + + def handle_row_click(e): + if not e.args or 'data' not in e.args: return + row_name = e.args['data']['name'] + selected_res = next((r for r in state['results'] if r['name'] == row_name), None) + if selected_res: + update_plot(selected_res) + ui.notify(f'已切换至方案: {row_name}') + + from nicegui import run + import queue + + async def run_analysis(n_clusters): + if not state['excel_path']: + ui.notify('请先上传 Excel 坐标文件!', type='warning') + if refs['log_box']: + refs['log_box'].clear() + log_queue = queue.Queue() + class QueueLogger(io.StringIO): + def write(self, message): + if message and message.strip(): + log_queue.put(message.strip()) + super().write(message) + def process_log_queue(): + if refs['log_box']: + while not log_queue.empty(): + try: + msg = log_queue.get_nowait() + refs['log_box'].push(msg) + if msg.startswith('--- Scenario'): + scenario_name = msg.replace('---', '').strip() + if refs['status_label']: + refs['status_label'].text = f"正在计算: {scenario_name}..." + elif '开始比较电缆方案' in msg: + if refs['status_label']: refs['status_label'].text = "准备开始计算..." + except queue.Empty: break + log_timer = ui.timer(0.1, process_log_queue) + if refs['status_label']: refs['status_label'].text = "初始化中..." + processing_dialog.open() + try: + # 2. 定义在线程中运行的任务 + def task(): + # 捕获 stdout 到我们的 QueueLogger + with contextlib.redirect_stdout(QueueLogger()): + return compare_design_methods( + excel_path=state['excel_path'], + n_clusters_override=n_clusters, + interactive=False, + plot_results=False # 禁止后台绘图,避免线程安全问题 + ) + results = await run.io_bound(task) + state['results'] = results + if not state['excel_path'] and results: + if state['substation'] is None: + _, state['substation'] = generate_wind_farm_data(n_turbines=30, layout='grid', spacing=800) + if refs['results_table']: + table_data = [] + for res in results: + table_data.append({'name': res['name'], 'cost_wan': round(res['cost'] / 10000, 2), 'loss_kw': round(res['loss'], 2)}) + refs['results_table'].rows = table_data + refs['results_table'].update() + + # 计算完成后,自动寻找并显示最佳方案的拓扑图 (不再显示4合1大图) + if results: + best_res = min(results, key=lambda x: x['cost']) + update_plot(best_res) + ui.notify(f'计算完成!已自动加载推荐方案: {best_res["name"]}', type='positive') + + update_export_buttons() + if refs['status_label']: refs['status_label'].text = "计算完成!" + except Exception as ex: + ui.notify(f'运行出错: {ex}', type='negative') + finally: + log_timer.cancel() + process_log_queue() + processing_dialog.close() + + with ui.dialog() as processing_dialog: + with ui.card().classes('w-96 items-center justify-center p-6'): + ui.label('正在计算方案...').classes('text-xl font-bold text-primary mb-2') + ui.spinner(size='lg', color='primary') + refs['status_label'] = ui.label('准备中...').classes('mt-4 text-sm text-gray-700 font-medium') + with ui.expansion('查看实时日志', icon='terminal', value=True).classes('w-full mt-4 text-sm'): + refs['log_box'] = ui.log(max_lines=100).classes('w-full h-32 text-xs font-mono bg-black text-green-400') + processing_dialog.props('persistent') + + with ui.header().classes('bg-primary text-white p-4 shadow-lg'): + ui.label('海上风电场集电线路设计优化系统').classes('text-2xl font-bold') + ui.label('Wind Farm Collector System Design Optimizer').classes('text-sm opacity-80') + + with ui.row().classes('w-full p-4 gap-4'): + with ui.card().classes('w-1/4 p-4 shadow-md'): + ui.label('配置面板').classes('text-xl font-semibold mb-4 border-b pb-2') + ui.label('1. 上传坐标文件 (.xlsx)').classes('font-medium') + refs['upload_widget'] = ui.upload(label='选择Excel文件', on_upload=handle_upload, auto_upload=True).classes('w-full mb-4') + ui.label('2. 参数设置').classes('font-medium mt-4') + refs['clusters_input'] = ui.number('指定回路数 (可选)', value=None, format='%d', placeholder='自动计算').classes('w-full mb-4') + refs['run_btn'] = ui.button('运行方案对比', on_click=lambda: run_analysis(refs['clusters_input'].value)).classes('w-full mt-4 py-4').props('icon=play_arrow color=secondary') + + with ui.column().classes('w-3/4 gap-4'): + with ui.card().classes('w-full p-4 shadow-md'): + ui.label('方案对比结果 (点击行查看拓扑详情)').classes('text-xl font-semibold mb-2') + columns = [ + {'name': 'name', 'label': '方案名称', 'field': 'name', 'required': True, 'align': 'left'}, + {'name': 'cost_wan', 'label': '总投资 (万元)', 'field': 'cost_wan', 'sortable': True}, + {'name': 'loss_kw', 'label': '线损 (kW)', 'field': 'loss_kw', 'sortable': True}, + ] + # 移除 selection='single',改为纯行点击交互 + refs['results_table'] = ui.table(columns=columns, rows=[]).classes('w-full') + refs['results_table'].on('rowClick', handle_row_click) + with ui.card().classes('w-full p-4 shadow-md'): + ui.label('拓扑可视化').classes('text-xl font-semibold mb-2') + refs['plot_container'] = ui.column().classes('w-full items-center') + with ui.card().classes('w-full p-4 shadow-md'): + ui.label('导出与下载').classes('text-xl font-semibold mb-2') + refs['export_row'] = ui.row().classes('gap-4') + +ui.run(title='海上风电场集电线路优化', port=8080) diff --git a/main.py b/main.py index 0dc06b9..75a9f36 100644 --- a/main.py +++ b/main.py @@ -287,20 +287,17 @@ def design_with_capacitated_sweep(turbines, substation, cable_specs=None): """ # 1. 获取电缆最大容量 max_mw = get_max_cable_capacity_mw(cable_specs) - # print(f"DEBUG: 扇区扫描算法启动 - 单回路容量限制: {max_mw:.2f} MW") substation_coord = substation[0] # 2. 计算角度 (使用 arctan2 返回 -pi 到 pi) - # 避免直接修改原始DataFrame,使用副本 work_df = turbines.copy() dx = work_df['x'] - substation_coord[0] dy = work_df['y'] - substation_coord[1] work_df['angle'] = np.arctan2(dy, dx) # 3. 寻找最佳起始角度 (最大角度间隙) - # 按角度排序 - work_df = work_df.sort_values('angle').reset_index(drop=True) # 重置索引方便切片 + work_df = work_df.sort_values('angle').reset_index(drop=True) angles = work_df['angle'].values n = len(angles) @@ -392,7 +389,6 @@ def design_with_rotational_sweep(turbines, substation, cable_specs=None): """ # 1. 获取电缆最大容量 max_mw = get_max_cable_capacity_mw(cable_specs) - # print(f"DEBUG: 扇区扫描算法启动 - 单回路容量限制: {max_mw:.2f} MW") substation_coord = substation[0] @@ -410,6 +406,7 @@ def design_with_rotational_sweep(turbines, substation, cable_specs=None): best_connections = [] best_turbines_state = None best_start_idx = -1 + best_id_to_cluster = {} # 遍历所有可能的起始点 for start_idx in range(n_turbines): @@ -463,8 +460,7 @@ def design_with_rotational_sweep(turbines, substation, cable_specs=None): # 2. 连接升压站长度 dists = np.sqrt((cluster_rows['x'] - substation_coord[0])**2 + (cluster_rows['y'] - substation_coord[1])**2) - min_dist = dists.min() - current_total_length += min_dist + current_total_length += dists.min() # --- 比较并保存最佳结果 --- if current_total_length < best_cost: @@ -582,8 +578,8 @@ def evaluate_design(turbines, connections, substation, cable_specs=None, is_offs try: # 找到通往升压站的最短路径上的下一个节点 path = nx.shortest_path(graph, source=node, target='substation') - if len(path) > 1: - parent = path[1] # path[0]是node自己,path[1]是父节点 + if len(path) > 1: # path[0]是node自己,path[1]是父节点 + parent = path[1] power_flow[parent] += power_flow[node] except nx.NetworkXNoPath: pass @@ -865,7 +861,7 @@ def export_all_scenarios_to_excel(results, filename): # 2. 每个方案的详细 Sheet for res in results: # 清理 Sheet 名称 - safe_name = res['name'].replace(':', '').replace('/', '-').replace('\\', '-') + safe_name = res['name'].replace(':', '').replace('/', '-').replace('\\', '-').replace(' ', '_') # 截断过长的名称 (Excel限制31字符) if len(safe_name) > 25: safe_name = safe_name[:25] @@ -1004,11 +1000,13 @@ def visualize_design(turbines, substation, connections, title, ax=None, show_cos return ax # 7. 主函数:比较两种设计方法 -def compare_design_methods(excel_path=None, n_clusters_override=None): +def compare_design_methods(excel_path=None, n_clusters_override=None, interactive=True, plot_results=True): """ 比较MST和三种电缆方案下的K-means设计方法 :param excel_path: Excel文件路径 :param n_clusters_override: 可选,手动指定簇的数量 + :param interactive: 是否启用交互式导出 (CLI模式) + :param plot_results: 是否生成和保存对比图表 """ cable_specs = None if excel_path: @@ -1018,7 +1016,7 @@ def compare_design_methods(excel_path=None, n_clusters_override=None): scenario_title = "Offshore Wind Farm (Imported Data)" except Exception: print("回退到自动生成数据模式...") - return compare_design_methods(excel_path=None, n_clusters_override=n_clusters_override) + return compare_design_methods(excel_path=None, n_clusters_override=n_clusters_override, interactive=interactive, plot_results=plot_results) else: print("正在生成海上风电场数据 (规则阵列布局)...") turbines, substation = generate_wind_farm_data(n_turbines=30, layout='grid', spacing=800) @@ -1029,7 +1027,12 @@ def compare_design_methods(excel_path=None, n_clusters_override=None): # 准备三种电缆方案 # 原始 specs 是 5 元素元组: (section, capacity, resistance, cost, is_optional) # 下游函数期望 4 元素元组: (section, capacity, resistance, cost) + has_optional_cables = False + if cable_specs: + # 检查是否存在 Optional 为 Y 的电缆 + has_optional_cables = any(s[4] for s in cable_specs) + # 方案 1: 不含 Optional='Y' (Standard) specs_1 = [s[:4] for s in cable_specs if not s[4]] @@ -1049,25 +1052,34 @@ def compare_design_methods(excel_path=None, n_clusters_override=None): specs_1 = default_specs specs_2 = default_specs specs_3 = default_specs[:-1] + # 默认库视为没有 optional + has_optional_cables = False scenarios = [ - ("Scenario 1 (Standard)", specs_1), - ("Scenario 2 (With Optional)", specs_2), - ("Scenario 3 (No Max)", specs_3) + ("Scenario 1 (Standard)", specs_1) ] + if has_optional_cables: + scenarios.append(("Scenario 2 (With Optional)", specs_2)) + scenarios.append(("Scenario 3 (No Max)", specs_3)) + else: + # 重新编号,保证连续性 + scenarios.append(("Scenario 2 (No Max)", specs_3)) + # 1. MST 方法作为基准 (使用 Scenario 1) mst_connections = design_with_mst(turbines, substation) mst_evaluation = evaluate_design(turbines, mst_connections, substation, cable_specs=specs_1, is_offshore=is_offshore, method_name="MST Method") # 准备画布 2x2 - fig, axes = plt.subplots(2, 2, figsize=(20, 18)) - axes = axes.flatten() - - # 绘制 MST - visualize_design(turbines, substation, mst_evaluation['details'], - f"MST Method (Standard Cables)\nTotal Cost: ¥{mst_evaluation['total_cost']/10000:.2f}万", - ax=axes[0]) + fig = None + axes = [] + if plot_results: + fig, axes = plt.subplots(2, 2, figsize=(20, 18)) + axes = axes.flatten() + # 绘制 MST + visualize_design(turbines, substation, mst_evaluation['details'], + f"MST Method (Standard Cables)\nTotal Cost: ¥{mst_evaluation['total_cost']/10000:.2f}万", + ax=axes[0]) print(f"\n===== 开始比较电缆方案 =====") @@ -1184,15 +1196,17 @@ def compare_design_methods(excel_path=None, n_clusters_override=None): # 可视化 (只画 Base 版本) ax_idx = i + 1 - if ax_idx < 4: + if plot_results and ax_idx < 4: n_circuits = turbines_base['cluster'].nunique() title = f"{base_name} ({n_circuits} circuits)\nCost: ¥{eval_base['total_cost']/10000:.2f}万" visualize_design(turbines_base, substation, eval_base['details'], title, ax=axes[ax_idx]) - plt.tight_layout() - output_filename = 'wind_farm_design_comparison.png' - plt.savefig(output_filename, dpi=300) - print(f"\n比较图(Base版)已保存至: {output_filename}") + if plot_results: + plt.tight_layout() + output_filename = 'wind_farm_design_comparison.png' + plt.savefig(output_filename, dpi=300) + plt.close() + print(f"\n比较图(Base版)已保存至: {output_filename}") # 准备文件路径 if excel_path: @@ -1208,6 +1222,10 @@ def compare_design_methods(excel_path=None, n_clusters_override=None): if comparison_results: export_all_scenarios_to_excel(comparison_results, excel_out_filename) + if not interactive: + print(f"非交互模式:已自动导出 Excel 对比报表: {excel_out_filename}") + return comparison_results + # 交互式选择导出 DXF print("\n===== 方案选择 =====") best_idx = 0 @@ -1240,13 +1258,25 @@ def compare_design_methods(excel_path=None, n_clusters_override=None): choice = best_idx selected_res = comparison_results[choice] - print(f"正在导出 '{selected_res['name']}' 到 DXF: {dxf_filename} ...") - export_to_dxf(selected_res['turbines'], substation, selected_res['eval']['details'], dxf_filename) + + # 生成带方案名称的文件名 + base_dxf_name, ext = os.path.splitext(dxf_filename) + safe_suffix = selected_res['name'].replace(' ', '_').replace(':', '').replace('(', '').replace(')', '').replace('/', '-') + final_filename = f"{base_dxf_name}_{safe_suffix}{ext}" + + print(f"正在导出 '{selected_res['name']}' 到 DXF: {final_filename} ...") + export_to_dxf(selected_res['turbines'], substation, selected_res['eval']['details'], final_filename) except Exception as e: print(f"输入处理出错: {e},将使用默认推荐方案。") selected_res = comparison_results[best_idx] - print(f"正在导出 '{selected_res['name']}' 到 DXF: {dxf_filename} ...") - export_to_dxf(selected_res['turbines'], substation, selected_res['eval']['details'], dxf_filename) + + # 生成带方案名称的文件名 + base_dxf_name, ext = os.path.splitext(dxf_filename) + safe_suffix = selected_res['name'].replace(' ', '_').replace(':', '').replace('(', '').replace(')', '').replace('/', '-') + final_filename = f"{base_dxf_name}_{safe_suffix}{ext}" + + print(f"正在导出 '{selected_res['name']}' 到 DXF: {final_filename} ...") + export_to_dxf(selected_res['turbines'], substation, selected_res['eval']['details'], final_filename) return comparison_results @@ -1260,4 +1290,4 @@ if __name__ == "__main__": # 3. 运行比较 # 如果没有提供excel文件,将自动回退到生成数据模式 - compare_design_methods(args.excel, n_clusters_override=args.clusters) + compare_design_methods(args.excel, n_clusters_override=args.clusters, interactive=True)