Compare commits
3 Commits
c54ad369a4
...
45c99b41b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45c99b41b3 | ||
|
|
837158270e | ||
|
|
61fa870778 |
240
IFLOW.md
Normal file
240
IFLOW.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 海上风电场集电线路设计优化系统 - 项目上下文
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个用于设计和优化海上风电场集电系统拓扑的综合工具,专为海上能源业务开发部电气专业设计。该系统通过多种先进的拓扑优化算法(MST、旋转扫描法、Esau-Williams等),根据风机坐标、功率以及海缆规格,自动生成投资成本最低、损耗最优的设计方案。
|
||||
|
||||
### 核心功能
|
||||
- 🖥️ **图形化界面**:基于 NiceGUI 的现代化桌面应用,支持原生窗口模式
|
||||
- 🌊 **多种布局生成**:支持规则网格和随机分布布局的模拟数据生成
|
||||
- 🔌 **多算法优化**:
|
||||
- MST (Minimum Spanning Tree):无容量约束基准方案
|
||||
- Capacitated Sweep (Base):基础扇区扫描分组
|
||||
- Rotational Sweep:全局最优起始角度旋转扫描优化
|
||||
- Esau-Williams:经典启发式算法,在距离与容量间寻找最优平衡
|
||||
- ⚙️ **灵活参数配置**:通过 Excel 自定义系统电压、功率因数、电价及电缆规格
|
||||
- 📊 **智能方案对比**:自动运行三大场景(标准方案、含可选电缆方案、限制最大截面方案)
|
||||
- 📁 **多格式导出**:CAD图纸(.dxf)、Excel报告、压缩包
|
||||
|
||||
### 技术栈
|
||||
- **语言**:Python 3.12+
|
||||
- **GUI框架**:NiceGUI 3.4.1 + PyWebview 6.1
|
||||
- **核心库**:
|
||||
- numpy 2.4.0:数值计算
|
||||
- pandas 2.3.3:数据处理
|
||||
- matplotlib 3.10.8:可视化
|
||||
- scikit-learn 1.8.0:聚类算法
|
||||
- networkx 3.6.1:图算法
|
||||
- ezdxf 1.4.3:CAD导出
|
||||
- scipy 1.16.3:科学计算
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
D:\code\windfarm\
|
||||
├── main.py # 核心算法和业务逻辑(1388行)
|
||||
├── gui.py # NiceGUI图形界面(1067行)
|
||||
├── esau_williams.py # Esau-Williams算法实现(242行)
|
||||
├── generate_template.py # Excel模板生成器
|
||||
├── make_version.py # 版本号自动生成脚本
|
||||
├── pyproject.toml # 项目依赖配置
|
||||
├── Makefile # 构建脚本
|
||||
├── 使用说明/ # 中文操作手册和截图
|
||||
├── build/ # 构建输出目录
|
||||
└── dist/ # 打包输出目录
|
||||
```
|
||||
|
||||
## 构建和运行
|
||||
|
||||
### 环境配置
|
||||
项目使用 `uv` 作为包管理器,也支持 `pip`:
|
||||
|
||||
```bash
|
||||
# 使用 uv(推荐)
|
||||
uv sync
|
||||
|
||||
# 或使用 pip
|
||||
pip install -r requirements.txt # 如果有requirements.txt
|
||||
# 或手动安装依赖
|
||||
pip install numpy pandas matplotlib scikit-learn scipy networkx ezdxf nicegui openpyxl pywebview
|
||||
```
|
||||
|
||||
### 运行方式
|
||||
|
||||
#### 1. 图形化界面(推荐)
|
||||
```bash
|
||||
python gui.py
|
||||
```
|
||||
启动后,程序将弹出独立窗口,提供完整的交互式界面。
|
||||
|
||||
#### 2. 命令行模式
|
||||
```bash
|
||||
python main.py --excel your_data.xlsx
|
||||
```
|
||||
|
||||
### 构建可执行文件
|
||||
|
||||
使用 Makefile 进行构建:
|
||||
|
||||
```bash
|
||||
# 构建exe文件(自动生成版本号)
|
||||
make build
|
||||
|
||||
# 重新构建(先清理再构建)
|
||||
make rebuild
|
||||
|
||||
# 清理构建文件
|
||||
make clean
|
||||
|
||||
# 查看帮助
|
||||
make help
|
||||
```
|
||||
|
||||
构建过程:
|
||||
1. 运行 `make_version.py` 生成版本号
|
||||
2. 使用 `nicegui-pack` 打包为单文件exe
|
||||
3. 重命名输出文件包含版本号
|
||||
|
||||
构建输出位于 `dist/` 目录,文件名格式:`海上风电场集电线路设计优化系统_{VERSION}.exe`
|
||||
|
||||
## 输入数据规范
|
||||
|
||||
### Excel文件格式
|
||||
输入Excel文件应包含以下三个Sheet:
|
||||
|
||||
#### 1. Coordinates(坐标数据)- 必需
|
||||
| Type | ID | X | Y | Power | PlatformHeight |
|
||||
|------|----|---|---|-------|----------------|
|
||||
| Substation | Sub1 | 4000 | -800 | 0 | 0 |
|
||||
| Turbine | 1 | 0 | 0 | 8.0 | 25 |
|
||||
|
||||
- **Type**: `Substation` 或 `Turbine`
|
||||
- **X/Y**: 投影坐标(米),建议使用高斯投影坐标
|
||||
- **Power**: 功率(MW),升压站填0
|
||||
- **PlatformHeight**: 塔筒/平台高度(米)
|
||||
|
||||
#### 2. Cables(电缆规格)- 必需
|
||||
| CrossSection | Capacity | Resistance | Cost | Optional |
|
||||
|--------------|----------|------------|------|----------|
|
||||
| 35 | 150 | 0.524 | 80 | |
|
||||
| 400 | 580 | 0.0470 | 600 | Y |
|
||||
|
||||
- **CrossSection**: 导体截面(mm²)
|
||||
- **Capacity**: 额定载流量(A),需考虑降容系数
|
||||
- **Resistance**: 交流电阻(Ω/km)
|
||||
- **Cost**: 综合单价(元/m)
|
||||
- **Optional**: 可选标记(Y表示可选大截面电缆)
|
||||
|
||||
**重要规则**:
|
||||
- 电缆必须按截面从小到大排列
|
||||
- `Optional` 为 'Y' 的电缆最多只能有一条
|
||||
- 若存在可选电缆,它必须是列表中截面最大的一条
|
||||
|
||||
#### 3. Parameters(系统参数)- 必需
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Voltage (kV) / 电压 (kV) | 66 |
|
||||
| Power Factor / 功率因数 | 0.95 |
|
||||
| Electricity Price (元/kWh) / 电价 (元/kWh) | 0.4 |
|
||||
|
||||
## 核心算法说明
|
||||
|
||||
### 1. MST(最小生成树)
|
||||
- **原理**:基于 Kruskal 或 Prim 算法,寻找连接所有风机且总路径长度最短的树状结构
|
||||
- **特点**:不考虑电缆载流量限制,仅作为理论距离基准参考
|
||||
- **适用场景**:小规模风电场的理论分析
|
||||
|
||||
### 2. Capacitated Sweep(基础扇区扫描)
|
||||
- **原理**:以升压站为中心,将平面划分为扇区,按顺时针扫描风机
|
||||
- **特点**:计算速度快,拓扑结构简单清晰
|
||||
- **局限**:对起始扫描角度敏感,可能产生"长尾巴"连线
|
||||
|
||||
### 3. Rotational Sweep(旋转扫描优化)
|
||||
- **原理**:尝试 0° 到 360° 之间的所有起始扫描角度
|
||||
- **优势**:比基础扫描法节省 3%~8% 的线缆成本
|
||||
- **适用场景**:最接近人工精细化排布的自动化算法
|
||||
|
||||
### 4. Esau-Williams 启发式算法
|
||||
- **原理**:约束最小生成树(CMST)算法,迭代计算互联操作的成本节省
|
||||
- **优势**:能发现树状、多分叉等复杂但更经济的拓扑结构
|
||||
- **适用场景**:风机分布不规则、离岸距离较远或电缆造价极高的情况
|
||||
|
||||
## 方案场景说明
|
||||
|
||||
系统自动运行三种场景:
|
||||
|
||||
1. **Scenario 1 (Standard)**:仅使用非可选(标准)电缆进行优化
|
||||
2. **Scenario 2 (With Optional)**:包含标记为 'Y' 的大型电缆,适用于尝试增加单回路容量
|
||||
3. **Scenario 3 (No Max)**:排除最大截面电缆,测试电缆供应受限时的最优拓扑
|
||||
|
||||
## 输出文件说明
|
||||
|
||||
- **Excel报告**:`[文件名]_result.xlsx` - 包含所有方案总览及详细连接清单
|
||||
- **CAD图纸**:`design_[方案名].dxf` - 分层分色的拓扑图
|
||||
- **全部方案**:`[文件名]_result.zip` - 包含所有图纸及Excel报告
|
||||
|
||||
## 关键常量和配置
|
||||
|
||||
### 电气参数
|
||||
- **系统电压**:66,000 V (66kV)
|
||||
- **功率因数**:0.95
|
||||
- **电价**:0.4 元/kWh
|
||||
|
||||
### 电缆规格示例
|
||||
- 最小截面:35mm² (载流量150A)
|
||||
- 最大截面:400mm² (载流量580A)
|
||||
- 降容系数:0.8(实际载流量 = 额定载流量 × 0.8)
|
||||
|
||||
### 算法参数
|
||||
- 默认风机数量:30台
|
||||
- 默认布局:随机分布或网格
|
||||
- 默认间距:800米(网格布局)
|
||||
|
||||
## 开发约定
|
||||
|
||||
### 代码风格
|
||||
- 使用中文注释和文档字符串
|
||||
- 函数命名使用 snake_case
|
||||
- 类名使用 PascalCase
|
||||
- 常量使用 UPPER_CASE
|
||||
|
||||
### 版本管理
|
||||
- 版本号通过 `make_version.py` 自动生成
|
||||
- 版本号格式:v{major}.{minor}.{patch}
|
||||
- 版本号存储在 `version.py` 文件中
|
||||
|
||||
### 构建约定
|
||||
- 使用 `nicegui-pack` 进行打包
|
||||
- 单文件模式(--onefile)
|
||||
- 无窗口模式(--windowed)
|
||||
- 输出文件名包含版本号
|
||||
|
||||
### 测试约定
|
||||
- GUI测试使用 frontend-tester agent
|
||||
- Python代码测试使用 python-pro agent
|
||||
- 测试覆盖率要求:核心算法部分 > 80%
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: MST算法显示极高的成本和损耗?
|
||||
**A**: 这是预期行为。MST算法不考虑载流量约束,会产生单一树状结构导致根部电缆严重过载。这仅作为理论基准参考。
|
||||
|
||||
### Q2: 如何在CAD图纸中找到图形?
|
||||
**A**: 双击鼠标滚轮(Zoom Extents)全屏显示。风机坐标通常是大地坐标(数值很大),如果CAD当前视口在(0,0)附近,可能会找不到图形。
|
||||
|
||||
### Q3: 可选电缆的使用规则是什么?
|
||||
**A**:
|
||||
- 可选电缆(Optional='Y')最多只能有一条
|
||||
- 必须是列表中截面最大的电缆
|
||||
- 用于特定场景(如增加单回路容量)
|
||||
|
||||
## 技术支持
|
||||
|
||||
- **适用对象**:海上能源业务开发部 - 电气专业
|
||||
- **技术支持**:杜孟远
|
||||
- **文档版本**:v1.0
|
||||
- **编制日期**:2026年1月5日
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目仅供工程学习、研究和初步设计评估使用。详细计算应以专业设计院规范为准。
|
||||
@@ -38,15 +38,15 @@ def create_template(output_file='windfarm_template.xlsx'):
|
||||
|
||||
# Create Cable data
|
||||
cable_data = [
|
||||
{'CrossSection': 35, 'Capacity': 150, 'Resistance': 0.524, 'Cost': 80, 'Optional': ''},
|
||||
{'CrossSection': 70, 'Capacity': 215, 'Resistance': 0.268, 'Cost': 120, 'Optional': ''},
|
||||
{'CrossSection': 95, 'Capacity': 260, 'Resistance': 0.193, 'Cost': 150, 'Optional': ''},
|
||||
{'CrossSection': 120, 'Capacity': 295, 'Resistance': 0.153, 'Cost': 180, 'Optional': ''},
|
||||
{'CrossSection': 150, 'Capacity': 330, 'Resistance': 0.124, 'Cost': 220, 'Optional': ''},
|
||||
{'CrossSection': 185, 'Capacity': 370, 'Resistance': 0.0991, 'Cost': 270, 'Optional': ''},
|
||||
{'CrossSection': 240, 'Capacity': 425, 'Resistance': 0.0754, 'Cost': 350, 'Optional': ''},
|
||||
{'CrossSection': 300, 'Capacity': 500, 'Resistance': 0.0601, 'Cost': 450, 'Optional': ''},
|
||||
{'CrossSection': 400, 'Capacity': 580, 'Resistance': 0.0470, 'Cost': 600, 'Optional': ''}
|
||||
{'CrossSection': 35, 'Capacity': 150, 'Resistance': 0.524, 'Cost': 8, 'Optional': ''},
|
||||
{'CrossSection': 70, 'Capacity': 215, 'Resistance': 0.268, 'Cost': 12, 'Optional': ''},
|
||||
{'CrossSection': 95, 'Capacity': 260, 'Resistance': 0.193, 'Cost': 15, 'Optional': ''},
|
||||
{'CrossSection': 120, 'Capacity': 295, 'Resistance': 0.153, 'Cost': 18, 'Optional': ''},
|
||||
{'CrossSection': 150, 'Capacity': 330, 'Resistance': 0.124, 'Cost': 22, 'Optional': ''},
|
||||
{'CrossSection': 185, 'Capacity': 370, 'Resistance': 0.0991, 'Cost': 27, 'Optional': ''},
|
||||
{'CrossSection': 240, 'Capacity': 425, 'Resistance': 0.0754, 'Cost': 35, 'Optional': ''},
|
||||
{'CrossSection': 300, 'Capacity': 500, 'Resistance': 0.0601, 'Cost': 45, 'Optional': ''},
|
||||
{'CrossSection': 400, 'Capacity': 580, 'Resistance': 0.0470, 'Cost': 60, 'Optional': ''}
|
||||
]
|
||||
df_cables = pd.DataFrame(cable_data)
|
||||
|
||||
@@ -54,7 +54,10 @@ def create_template(output_file='windfarm_template.xlsx'):
|
||||
param_data = [
|
||||
{'Parameter': 'Voltage (kV) / 电压 (kV)', 'Value': 66},
|
||||
{'Parameter': 'Power Factor / 功率因数', 'Value': 0.95},
|
||||
{'Parameter': 'Electricity Price (元/kWh) / 电价 (元/kWh)', 'Value': 0.4}
|
||||
{'Parameter': 'Electricity Price (元/kWh) / 电价 (元/kWh)', 'Value': 0.4},
|
||||
{'Parameter': 'Project Lifetime (years) / 工程运行期限/年', 'Value': 25},
|
||||
{'Parameter': 'Discount Rate (%) / 折现率%', 'Value': 8},
|
||||
{'Parameter': 'Annual Loss Hours (hours) / 年损耗小时数/小时', 'Value': 1400}
|
||||
]
|
||||
df_params = pd.DataFrame(param_data)
|
||||
|
||||
|
||||
374
gui.py
374
gui.py
@@ -1,20 +1,25 @@
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import io
|
||||
import contextlib
|
||||
import tempfile
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.backends.backend_svg
|
||||
from nicegui import ui, events, app
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
from nicegui import app, events, ui
|
||||
|
||||
from main import (
|
||||
compare_design_methods,
|
||||
export_to_dxf,
|
||||
load_data_from_excel,
|
||||
generate_wind_farm_data,
|
||||
visualize_design,
|
||||
export_all_scenarios_to_excel,
|
||||
export_to_dxf,
|
||||
generate_wind_farm_data,
|
||||
load_data_from_excel,
|
||||
visualize_design,
|
||||
)
|
||||
import pandas as pd
|
||||
|
||||
# 尝试导入自动生成的版本号
|
||||
try:
|
||||
@@ -108,7 +113,7 @@ def index():
|
||||
v = state["system_params"]["voltage"]
|
||||
is_default_v = False
|
||||
|
||||
v_str = f"电压: {v/1000:.1f} kV" if v >= 1000 else f"电压: {v} V"
|
||||
v_str = f"电压: {v / 1000:.1f} kV" if v >= 1000 else f"电压: {v} V"
|
||||
if is_default_v:
|
||||
v_str += " (默认)"
|
||||
params_text.append(v_str)
|
||||
@@ -143,6 +148,51 @@ def index():
|
||||
ep_str += " (默认)"
|
||||
params_text.append(ep_str)
|
||||
|
||||
# 获取工程运行期限
|
||||
lifetime = 25 # Default
|
||||
is_default_lifetime = True
|
||||
if (
|
||||
state.get("system_params")
|
||||
and "project_lifetime" in state["system_params"]
|
||||
):
|
||||
lifetime = state["system_params"]["project_lifetime"]
|
||||
is_default_lifetime = False
|
||||
|
||||
lifetime_str = f"工程运行期限: {lifetime} 年"
|
||||
if is_default_lifetime:
|
||||
lifetime_str += " (默认)"
|
||||
params_text.append(lifetime_str)
|
||||
|
||||
# 获取折现率
|
||||
discount_rate = 8 # Default
|
||||
is_default_discount = True
|
||||
if (
|
||||
state.get("system_params")
|
||||
and "discount_rate" in state["system_params"]
|
||||
):
|
||||
discount_rate = state["system_params"]["discount_rate"]
|
||||
is_default_discount = False
|
||||
|
||||
discount_str = f"折现率: {discount_rate}%"
|
||||
if is_default_discount:
|
||||
discount_str += " (默认)"
|
||||
params_text.append(discount_str)
|
||||
|
||||
# 获取年损耗小时数
|
||||
annual_hours = 1400 # Default
|
||||
is_default_hours = True
|
||||
if (
|
||||
state.get("system_params")
|
||||
and "annual_loss_hours" in state["system_params"]
|
||||
):
|
||||
annual_hours = state["system_params"]["annual_loss_hours"]
|
||||
is_default_hours = False
|
||||
|
||||
hours_str = f"年损耗小时数: {annual_hours} 小时"
|
||||
if is_default_hours:
|
||||
hours_str += " (默认)"
|
||||
params_text.append(hours_str)
|
||||
|
||||
for p in params_text:
|
||||
ui.chip(p, icon="bolt").props("outline color=primary")
|
||||
|
||||
@@ -175,7 +225,7 @@ def index():
|
||||
},
|
||||
{
|
||||
"name": "cost",
|
||||
"label": "参考单价 (元/m)",
|
||||
"label": "参考单价(万元/km)",
|
||||
"field": "cost",
|
||||
"align": "center",
|
||||
},
|
||||
@@ -194,7 +244,9 @@ def index():
|
||||
"section": spec[0],
|
||||
"capacity": spec[1],
|
||||
"resistance": spec[2],
|
||||
"cost": spec[3],
|
||||
"cost": f"{spec[3] / 10:.2f}"
|
||||
if spec[3] is not None
|
||||
else "0.00",
|
||||
"is_optional": "Y" if len(spec) > 4 and spec[4] else "",
|
||||
}
|
||||
)
|
||||
@@ -305,132 +357,73 @@ def index():
|
||||
:param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None
|
||||
:param file_filter: 格式如 "Excel Files (*.xlsx)"
|
||||
"""
|
||||
# 检测是否为原生模式 (PyWebview)
|
||||
is_native = False
|
||||
native_window = None
|
||||
try:
|
||||
# 使用 getattr 安全获取 app.native,避免属性不存在错误
|
||||
# 并在 reload=True 时 native 可能未能正确初始化
|
||||
n_obj = getattr(app, "native", None)
|
||||
if n_obj and getattr(n_obj, "main_window", None):
|
||||
is_native = True
|
||||
native_window = n_obj.main_window
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Native check error: {e}")
|
||||
# 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows)
|
||||
# 这完全避免了 Python UI 库 (Tkinter/PyWebview) 的线程冲突和 PicklingError
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
print(
|
||||
f"DEBUG: save_file_with_dialog called. is_native={is_native}, filename={filename}"
|
||||
)
|
||||
|
||||
if is_native and native_window:
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
# PyWebview 的 create_file_dialog 的 file_types 参数期望一个字符串元组
|
||||
# 格式如: ('Description (*.ext)', 'All files (*.*)')
|
||||
file_types = (file_filter,)
|
||||
# 构建 PowerShell 脚本
|
||||
# 注意:过滤器格式为 "描述|*.ext|所有文件|*.*"
|
||||
ps_filter = file_filter.replace("(", "|").replace(")", "")
|
||||
if "|" not in ps_filter:
|
||||
ps_filter += f"|{os.path.splitext(filename)[1] or '*.*'}"
|
||||
|
||||
# 简单清洗 filter 字符串以适应 PowerShell (e.g., "Excel Files *.xlsx" -> "Excel Files|*.xlsx")
|
||||
# 这里做一个简化的映射,确保格式正确
|
||||
if "Excel" in file_filter:
|
||||
ps_filter = "Excel Files (*.xlsx)|*.xlsx|All Files (*.*)|*.*"
|
||||
elif "DXF" in file_filter:
|
||||
ps_filter = "DXF Files (*.dxf)|*.dxf|All Files (*.*)|*.*"
|
||||
elif "ZIP" in file_filter:
|
||||
ps_filter = "ZIP Archives (*.zip)|*.zip|All Files (*.*)|*.*"
|
||||
else:
|
||||
ps_filter = "All Files (*.*)|*.*"
|
||||
|
||||
print(f"DEBUG: calling create_file_dialog with types={file_types}")
|
||||
ps_script = f"""
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$d = New-Object System.Windows.Forms.SaveFileDialog
|
||||
$d.Filter = "{ps_filter}"
|
||||
$d.FileName = "{filename}"
|
||||
$d.Title = "保存文件"
|
||||
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
|
||||
Write-Output $d.FileName
|
||||
}}
|
||||
"""
|
||||
|
||||
# 在 Native 模式下,create_file_dialog 是同步阻塞的
|
||||
# 注意:必须使用 app.native.SAVE_DIALOG
|
||||
save_path = native_window.create_file_dialog(
|
||||
app.native.SAVE_DIALOG,
|
||||
directory="",
|
||||
save_filename=filename,
|
||||
file_types=file_types,
|
||||
# 运行 PowerShell
|
||||
# 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁)
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
|
||||
print("DEBUG: invoking PowerShell SaveFileDialog...")
|
||||
|
||||
# 在 native 模式下直接同步执行,不使用 run.io_bound()
|
||||
result = subprocess.run(
|
||||
["powershell", "-Command", ps_script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
startupinfo=startupinfo
|
||||
)
|
||||
save_path = result.stdout.strip()
|
||||
|
||||
print(f"DEBUG: save_path result: {save_path}")
|
||||
|
||||
# 用户取消
|
||||
if not save_path:
|
||||
if save_path:
|
||||
print(f"DEBUG: PowerShell returned path: {save_path}")
|
||||
await callback(save_path)
|
||||
ui.notify(f"文件已保存至: {save_path}", type="positive")
|
||||
return
|
||||
|
||||
# 处理返回类型 (PyWebview 可能返回字符串或列表)
|
||||
if isinstance(save_path, (list, tuple)):
|
||||
if not save_path:
|
||||
return
|
||||
save_path = save_path[0]
|
||||
|
||||
# 确保文件名后缀正确
|
||||
if not save_path.lower().endswith(
|
||||
os.path.splitext(filename)[1].lower()
|
||||
):
|
||||
save_path += os.path.splitext(filename)[1]
|
||||
|
||||
await callback(save_path)
|
||||
ui.notify(f"文件已保存至: {save_path}", type="positive")
|
||||
return # 成功处理,退出
|
||||
else:
|
||||
print("DEBUG: PowerShell dialog cancelled or empty result.")
|
||||
# 用户取消,直接返回,不回退
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"PowerShell dialog failed: {e}")
|
||||
# 出错则回退到 ui.download
|
||||
|
||||
traceback.print_exc()
|
||||
print(f"ERROR in save_file_with_dialog (native): {e}")
|
||||
# ui.notify(f"原生保存失败,尝试其他方式: {e}", type="warning")
|
||||
print(f"原生保存失败,尝试其他方式: {e}")
|
||||
# 继续向下执行,尝试 fallback
|
||||
|
||||
# 非 Native 模式 (或 Native 失败),尝试使用 Tkinter (仅限本地环境)
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
from nicegui import run
|
||||
|
||||
print("DEBUG: Attempting Tkinter dialog...")
|
||||
|
||||
def get_save_path_tk(default_name, f_filter):
|
||||
try:
|
||||
# 创建隐藏的根窗口
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes("-topmost", True) # 尝试置顶
|
||||
|
||||
# 转换 filter 格式: "Excel Files (*.xlsx)" -> [("Excel Files", "*.xlsx")]
|
||||
filetypes = []
|
||||
if "(" in f_filter and ")" in f_filter:
|
||||
desc = f_filter.split("(")[0].strip()
|
||||
ext = f_filter.split("(")[1].split(")")[0]
|
||||
filetypes.append((desc, ext))
|
||||
filetypes.append(("All files", "*.*"))
|
||||
|
||||
path = filedialog.asksaveasfilename(
|
||||
initialfile=default_name, filetypes=filetypes, title="保存文件"
|
||||
)
|
||||
root.destroy()
|
||||
return path
|
||||
except Exception as ex:
|
||||
print(f"Tkinter inner error: {ex}")
|
||||
return None
|
||||
|
||||
# 在线程中运行 tkinter,避免阻塞 asyncio 事件循环
|
||||
save_path = await run.io_bound(get_save_path_tk, filename, file_filter)
|
||||
|
||||
if save_path:
|
||||
print(f"DEBUG: Tkinter save_path: {save_path}")
|
||||
# 确保文件名后缀正确
|
||||
if not save_path.lower().endswith(
|
||||
os.path.splitext(filename)[1].lower()
|
||||
):
|
||||
save_path += os.path.splitext(filename)[1]
|
||||
|
||||
await callback(save_path)
|
||||
ui.notify(f"文件已保存至: {save_path}", type="positive")
|
||||
return # 成功处理
|
||||
elif save_path is None:
|
||||
print("DEBUG: Tkinter dialog cancelled or failed silently.")
|
||||
# 如果是用户取消(返回空字符串),通常不需要回退到下载。
|
||||
# 但这里如果 Tkinter 彻底失败返回 None,可能需要回退。
|
||||
# askopenfilename 返回空字符串表示取消。我们假设 None 是异常。
|
||||
# 这里简化处理:只要没拿到路径且没报错,就认为是取消。
|
||||
if save_path == "":
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
print(f"Tkinter dialog failed: {e}")
|
||||
# Fallback to ui.download if tkinter fails
|
||||
|
||||
# 最后的回退方案:浏览器下载
|
||||
print("DEBUG: Falling back to ui.download")
|
||||
# 统一回退方案:浏览器下载
|
||||
print("DEBUG: Using ui.download fallback")
|
||||
temp_path = os.path.join(state["temp_dir"], filename)
|
||||
await callback(temp_path)
|
||||
ui.download(temp_path)
|
||||
@@ -464,7 +457,6 @@ def index():
|
||||
best_res = min(state["results"], key=lambda x: x["cost"])
|
||||
|
||||
with refs["export_row"]:
|
||||
|
||||
# --- 下载 Excel ---
|
||||
async def save_excel(path):
|
||||
import shutil
|
||||
@@ -547,7 +539,7 @@ def index():
|
||||
ui.notify("缺少升压站数据,无法导出 DXF", type="negative")
|
||||
|
||||
ui.button(
|
||||
f'导出推荐方案 DXF ({best_res["name"]})', on_click=on_click_best_dxf
|
||||
f"导出推荐方案 DXF ({best_res['name']})", on_click=on_click_best_dxf
|
||||
).props("icon=architecture color=accent")
|
||||
|
||||
# --- 导出选中方案 DXF ---
|
||||
@@ -654,7 +646,7 @@ def index():
|
||||
with refs["plot_container"]:
|
||||
# 使用 ui.pyplot 上下文自动管理 figure 生命周期
|
||||
with ui.pyplot(figsize=(10, 8)) as plot:
|
||||
title = f"{result['name']}\nCost: ¥{result['cost']/10000:.2f}万 | Loss: {result['loss']:.2f} kW"
|
||||
title = f"{result['name']}\nCost: ¥{result['cost'] / 10000:.2f}万 | Loss: {result['loss']:.2f} kW"
|
||||
# 显式获取当前 ui.pyplot 创建的 axes,并传递给绘图函数
|
||||
# 确保绘图发生在正确的 figure 上
|
||||
ax = plt.gca()
|
||||
@@ -694,9 +686,10 @@ def index():
|
||||
clean_name = row_name.replace("(推荐) ", "")
|
||||
refs["export_selected_btn"].set_text(f"导出选中方案 ({clean_name})")
|
||||
|
||||
from nicegui import run
|
||||
import queue
|
||||
|
||||
from nicegui import run
|
||||
|
||||
async def run_analysis():
|
||||
if not state["excel_path"]:
|
||||
ui.notify("请先上传 Excel 坐标文件!", type="warning")
|
||||
@@ -722,9 +715,9 @@ def index():
|
||||
if msg.startswith("--- Scenario"):
|
||||
scenario_name = msg.replace("---", "").strip()
|
||||
if refs["status_label"]:
|
||||
refs["status_label"].text = (
|
||||
f"正在计算: {scenario_name}..."
|
||||
)
|
||||
refs[
|
||||
"status_label"
|
||||
].text = f"正在计算: {scenario_name}..."
|
||||
elif "开始比较电缆方案" in msg:
|
||||
if refs["status_label"]:
|
||||
refs["status_label"].text = "准备开始计算..."
|
||||
@@ -777,7 +770,7 @@ def index():
|
||||
|
||||
update_plot(best_res)
|
||||
ui.notify(
|
||||
f'计算完成!已自动加载推荐方案: {best_res["name"]}', type="positive"
|
||||
f"计算完成!已自动加载推荐方案: {best_res['name']}", type="positive"
|
||||
)
|
||||
|
||||
# 更新结果表格
|
||||
@@ -813,14 +806,16 @@ def index():
|
||||
elif "No Max" in original_name:
|
||||
note += "不包含可选电缆型号,且可使用的最大截面电缆降一档截面。"
|
||||
|
||||
# 计算总长度
|
||||
total_length = sum(d["length"] for d in res["eval"]["details"])
|
||||
# 计算总长度(转换为公里)
|
||||
total_length_m = sum(d["length"] for d in res["eval"]["details"])
|
||||
total_length_km = total_length_m / 1000
|
||||
|
||||
row_dict = {
|
||||
"name": name_display,
|
||||
"cost_wan": f"{res['cost'] / 10000:.2f}",
|
||||
"loss_kw": f"{res['loss']:.2f}",
|
||||
"total_length": f"{total_length:.2f}",
|
||||
"total_length": f"{total_length_km:.2f}",
|
||||
"total_cost_npv_wan": f"{res.get('total_cost_npv', res['cost']) / 10000:.2f}",
|
||||
"note": note,
|
||||
"original_name": res["name"],
|
||||
}
|
||||
@@ -879,16 +874,20 @@ def index():
|
||||
with ui.row().classes("w-full p-4 gap-4"):
|
||||
with ui.card().classes("w-full p-4 shadow-md"):
|
||||
ui.label("配置面板").classes("text-xl font-semibold mb-4 border-b pb-2")
|
||||
|
||||
|
||||
# 使用 items-stretch 确保所有子元素高度一致
|
||||
with ui.row().classes('w-full items-stretch gap-4'):
|
||||
with ui.row().classes("w-full items-stretch gap-4"):
|
||||
# 1. 导出模板按钮
|
||||
async def export_template():
|
||||
from generate_template import create_template
|
||||
import shutil
|
||||
|
||||
from generate_template import create_template
|
||||
|
||||
async def save_template(path):
|
||||
# 生成模板到系统临时目录
|
||||
temp_template = os.path.join(state["temp_dir"], "coordinates_template.xlsx")
|
||||
temp_template = os.path.join(
|
||||
state["temp_dir"], "coordinates_template.xlsx"
|
||||
)
|
||||
create_template(temp_template)
|
||||
if os.path.exists(temp_template):
|
||||
shutil.copy2(temp_template, path)
|
||||
@@ -900,9 +899,7 @@ def index():
|
||||
raise FileNotFoundError("无法生成模板文件")
|
||||
|
||||
await save_file_with_dialog(
|
||||
"coordinates.xlsx",
|
||||
save_template,
|
||||
"Excel Files (*.xlsx)"
|
||||
"coordinates.xlsx", save_template, "Excel Files (*.xlsx)"
|
||||
)
|
||||
|
||||
ui.button("导出 Excel 模板", on_click=export_template).classes(
|
||||
@@ -910,12 +907,18 @@ def index():
|
||||
).props("icon=file_download outline color=primary")
|
||||
|
||||
# 2. 上传文件区域 (垂直堆叠 Label 和 Upload 组件)
|
||||
with ui.column().classes('flex-1 gap-0 justify-between'):
|
||||
with ui.column().classes("flex-1 gap-0 justify-between"):
|
||||
# 使用 .no-list CSS 隐藏 Quasar 默认列表,完全自定义文件显示
|
||||
refs["upload_widget"] = ui.upload(
|
||||
label="选择Excel文件", on_upload=handle_upload, auto_upload=True
|
||||
).classes("w-full no-list h-full").props('flat bordered color=primary')
|
||||
|
||||
refs["upload_widget"] = (
|
||||
ui.upload(
|
||||
label="选择Excel文件",
|
||||
on_upload=handle_upload,
|
||||
auto_upload=True,
|
||||
)
|
||||
.classes("w-full no-list h-full")
|
||||
.props("flat bordered color=primary")
|
||||
)
|
||||
|
||||
# 自定义文件显示容器
|
||||
refs["current_file_container"] = ui.column().classes("w-full")
|
||||
# 初始状态不显示任何内容,直到选择文件后才显示
|
||||
@@ -946,9 +949,15 @@ def index():
|
||||
)
|
||||
|
||||
with ui.card().classes("w-full p-0 shadow-md overflow-hidden"):
|
||||
with ui.expansion(
|
||||
"方案对比结果 (点击行查看拓扑详情)", icon="analytics", value=True
|
||||
).classes("w-full").props("header-class=\"text-xl font-semibold\""):
|
||||
with (
|
||||
ui.expansion(
|
||||
"方案对比结果 (点击行查看拓扑详情)",
|
||||
icon="analytics",
|
||||
value=True,
|
||||
)
|
||||
.classes("w-full")
|
||||
.props('header-class="text-xl font-semibold"')
|
||||
):
|
||||
columns = [
|
||||
{
|
||||
"name": "name",
|
||||
@@ -963,24 +972,31 @@ def index():
|
||||
"field": "cost_wan",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "loss_kw",
|
||||
"label": "线损 (kW)",
|
||||
"field": "loss_kw",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "total_length",
|
||||
"label": "总长度 (m)",
|
||||
"field": "total_length",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "note",
|
||||
"label": "备注",
|
||||
"field": "note",
|
||||
"align": "left",
|
||||
}, ]
|
||||
{
|
||||
"name": "loss_kw",
|
||||
"label": "线损 (kW)",
|
||||
"field": "loss_kw",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "total_length",
|
||||
"label": "总长度/km",
|
||||
"field": "total_length",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "total_cost_npv_wan",
|
||||
"label": "总费用 (万元)",
|
||||
"field": "total_cost_npv_wan",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "note",
|
||||
"label": "备注",
|
||||
"field": "note",
|
||||
"align": "left",
|
||||
},
|
||||
]
|
||||
# 使用内置的 selection='single' 结合行点击事件实现背景高亮
|
||||
# 这样可以完全由 Python 事件逻辑控制,不依赖 CSS 伪类
|
||||
refs["results_table"] = ui.table(
|
||||
@@ -1055,12 +1071,18 @@ if getattr(sys, "frozen", False):
|
||||
)
|
||||
else:
|
||||
# 普通使用环境保留日志功能
|
||||
ui.run(title="海上风电场集电线路优化", host='127.0.0.1', reload=True, port=target_port, native=False)
|
||||
# ui.run(
|
||||
# title="海上风电场集电线路优化",
|
||||
# host="127.0.0.1",
|
||||
# port=target_port,
|
||||
# reload=True,
|
||||
# window_size=(1280, 800),
|
||||
# native=True,
|
||||
# port=target_port,
|
||||
# native=False,
|
||||
# )
|
||||
ui.run(
|
||||
title="海上风电场集电线路优化",
|
||||
host="127.0.0.1",
|
||||
port=target_port,
|
||||
reload=True,
|
||||
window_size=(1280, 800),
|
||||
native=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user