Compare commits

...

66 Commits

Author SHA1 Message Date
dmy
94080e71ae refactor: 将版本号注入移至构建配置 2026-03-05 12:36:29 +08:00
dmy
395d364a8f refactor: 暂时禁用 DXF 文件保存功能 2026-03-05 12:22:15 +08:00
dmy
7c2728c004 chore: 更新版本号至1.0.22并添加应用图标 2026-03-05 12:19:11 +08:00
dmy
8f67a3dd0a chore: 更新版本至1.0.18 2026-03-04 19:22:34 +08:00
dmy
36139d4ab5 style: 调整布局样式使组件高度自适应 2026-03-04 17:12:27 +08:00
dmy
6471c066df fix: 禁用数字输入框的滚轮调整和箭头显示 2026-03-04 16:49:07 +08:00
dmy
3465cda361 fix: 区分交直流电压计算 2026-03-04 16:31:44 +08:00
dmy
aed5c5e3cb fix: 强制将电压份数设置为1 2026-03-04 16:28:37 +08:00
dmy
d5a9bb8798 feat: 显示导出配置的文件路径 2026-03-04 16:20:05 +08:00
dmy
e4da22868d refactor: 移除电压份数相关逻辑 2026-03-04 16:14:27 +08:00
dmy
568d7d3ef6 fix: 修正电压计算逻辑 2026-03-04 16:12:36 +08:00
dmy
9d69b1bad2 fix: 修复 pywebview 参数序列化问题 2026-03-04 15:54:48 +08:00
dmy
d1baa87ae4 feat: 在页脚显示应用版本 2026-03-04 14:49:58 +08:00
dmy
195beb3520 fix: 更新保护角标注文本 2026-03-04 11:16:02 +08:00
dmy
18fc8fcb0e fix: 修正保护角计算公式 2026-03-04 11:13:16 +08:00
dmy
3498650f5f feat: 添加保护角可视化绘制 2026-03-04 11:08:20 +08:00
dmy
fb3276d49d refactor: 移除地面填充绘制逻辑 2026-03-04 10:58:19 +08:00
dmy
45b5dbaab2 fix: 修复几何坐标轴绘制范围问题 2026-03-04 10:57:07 +08:00
dmy
52a1ca7c2e fix: 增加导地线挂点垂直坐标顺序验证 2026-03-04 10:50:17 +08:00
dmy
8c1e6c2068 feat: 添加绝缘子串长计算与显示 2026-03-04 10:34:02 +08:00
dmy
b7d73e61a7 fix: 修复几何数据类型转换及折叠问题 2026-03-04 10:09:46 +08:00
dmy
6665b142e2 chore: 调整窗口宽度 2026-03-04 09:37:22 +08:00
dmy
4184a53a86 refactor: 优化参数表单布局,几何可视化并列显示 2026-03-04 09:27:58 +08:00
dmy
4b75c6a521 feat: 添加杆塔几何结构可视化组件 2026-03-04 09:13:51 +08:00
dmy
7f4a6751b4 build: 更新版本号至1.0.14并调整构建配置 2026-03-04 08:36:13 +08:00
dmy
86b294baf9 feat: 添加配置文件的导入功能及文件路径显示
新增通过系统对话框导入配置文件的功能
在界面上显示当前打开的配置文件路径
添加对50%击穿电压的验证
优化开发模式下的文件导入备用方案
2026-03-03 18:58:19 +08:00
dmy
7dd466a28a feat: 添加50%击穿电压参数支持
支持用户自定义50%击穿电压值,默认-1表示自动计算
在UI中添加相关配置开关
2026-03-03 18:26:01 +08:00
dmy
8a74a576c0 feat: 添加版本管理功能并更新依赖
添加版本管理脚本和文件,更新webui依赖至最新版本,优化参数表单显示
2026-03-03 18:06:01 +08:00
dmy
0927c94a23 feat: 添加GUI构建目标并更新文档
添加Makefile中的GUI构建目标,更新README文档结构,调整默认参数值,优化webview应用的生产环境检测逻辑
2026-03-03 17:36:19 +08:00
dmy
68328a68f1 feat: 在参数显示中添加更多字段并简化条件判断 2026-03-03 16:56:10 +08:00
dmy
4aa56c71d5 feat: 添加雷电波阻抗和导线波阻抗参数支持
在参数类中添加z_0和z_c字段,并在计算最小雷电流时使用这些参数
更新前端表单和类型定义以支持新参数
修改webview应用以接收并处理新参数
2026-03-03 16:51:28 +08:00
dmy
fd6684c884 fix: 默认折叠日志面板 2026-03-03 16:35:28 +08:00
dmy
c19e7b7631 feat: 将动画组件改为可折叠式设计 2026-03-03 16:17:40 +08:00
dmy
07063ec638 feat: 将动画启用控制权交给前端用户
后端不再主动启用动画,改为由前端通过开关控制动画状态
移除后端冗余的动画禁用逻辑,仅根据前端状态传递动画对象
2026-03-03 16:09:03 +08:00
dmy
a65ce23cee feat: 添加 EGM 计算动画可视化功能
在 web 界面中实现 EGM 计算过程的动画展示,包括地线保护弧、导线暴露弧和地面线的动态绘制。重构 main.py 以支持可选的动画参数传递,并新增 Animation.vue 组件和 WebAnimation 类实现前后端交互。
2026-03-03 15:58:57 +08:00
dmy
cee451914a fix: 添加雷电流概率密度系数验证 2026-03-03 15:20:31 +08:00
dmy
dfdf952425 feat: 添加雷电流概率密度系数设置开关
在参数表单中添加开关控件,用于显示/隐藏雷电流概率密度系数输入项,并在关闭时自动重置为标准参数
2026-03-03 15:16:33 +08:00
dmy
8091791fde feat: 更新电压类型标签并自动设置电压份数
修改电流类型标签为更准确的"电压类型 (AC/DC)"
根据AC/DC自动设置电压份数(DC为1,AC为3)
调整默认最大电流值为300
2026-03-03 15:11:58 +08:00
dmy
b34da837e2 feat: 优化计算结果展示并添加交直流标识
在参数表单中重新排列计算结果和日志组件的位置,将跳闸率显示格式化为4位小数并添加单位。同时在后端日志中添加交直流标识输出,并自动根据电压等级更新ac_or_dc字段。
2026-03-03 15:07:50 +08:00
dmy
355fb2d689 feat: 改进计算结果展示和雷电密度计算逻辑
优化雷电密度计算条件判断,移除冗余条件
改进前端结果展示格式,显示各导线跳闸率
移除雷暴日输入框的禁用状态
更新电压离散化份数的工具提示说明
2026-03-03 14:54:49 +08:00
dmy
acf1fa1c60 fix: 将 h_arm 类型从 float 改为 List[float] 2026-03-03 14:33:54 +08:00
dmy
e386da0e34 fix: 导线掉落时返回更详细的错误信息 2026-03-03 14:28:44 +08:00
dmy
3b590f9a1f feat: 添加日志导出功能 2026-03-03 14:26:58 +08:00
dmy
02bfcc18e4 feat: 使用队列优化日志处理并添加线程安全机制 2026-03-03 11:48:35 +08:00
dmy
9557e18fd1 fix: 修正雷电流密度计算条件并修复单位显示错误
修正雷暴日判断条件从等于改为小于等于,并添加中间范围判断
修复日志中电流单位显示错误(kV改为kA)
初始化时根据雷暴日自动计算地闪密度
2026-03-03 10:39:53 +08:00
dmy
2401b0b19a feat: 添加跳闸率解析并实现雷暴日与地闪密度自动转换
在Log组件中解析跳闸率数值并暴露给父组件
在ParameterForm组件中实现雷暴日与地闪密度的双向自动转换
2026-03-03 10:19:33 +08:00
dmy
5a8953d1e5 feat: 添加参数校验并调整导线数量操作逻辑
在main.py中添加gc_x和h_arm长度校验
在ParameterForm.vue中修改导线数量操作逻辑,仅允许1或3条导线
2026-03-02 23:20:04 +08:00
dmy
bcaa4a5a9e refactor: 移除全局参数对象并改为函数参数传递 2026-03-02 23:11:27 +08:00
dmy
630598d498 refactor: 将参数对象作为参数传递给run_egm函数
修改run_egm函数,使其接收参数对象而不是依赖全局变量,并更新相关调用代码
2026-03-02 23:06:34 +08:00
dmy
759b8b9a25 feat: 添加雷暴日与地闪密度的联动逻辑
当地闪密度大于0时自动禁用雷暴日输入并设为-1
当地闪密度小于0时自动启用雷暴日输入并设为20
2026-03-02 23:04:16 +08:00
dmy
8aa2f600ed feat: 添加配置导入功能
将重置参数按钮改为导入配置按钮,并实现TOML文件解析和参数导入功能
2026-03-02 22:57:13 +08:00
dmy
73681f629d refactor: 调整计算结果卡片在表单中的位置 2026-03-02 22:51:00 +08:00
dmy
6f0f8d02a8 feat: 重构EGM计算核心并添加实时日志推送功能
将EGM计算逻辑从webview_app.py移到main.py中的run_egm函数
添加实时日志推送和计算结果回调机制
支持后台线程计算不阻塞前端
2026-03-02 22:49:38 +08:00
dmy
47d3b7b6b4 feat: 将配置导出功能从JSON改为TOML格式并添加保存对话框 2026-03-02 22:18:32 +08:00
dmy
3f3527f7af feat: 根据交直流类型计算相电压 2026-03-02 22:11:59 +08:00
dmy
dfb6399073 feat: 添加交流/直流标识支持
在参数模型中添加ac_or_dc字段,支持从配置文件中读取交流或直流标识,并在前端界面根据电压等级自动判断显示
2026-03-02 22:09:46 +08:00
dmy
d7ed999da6 feat: 同步调整导线参数数组操作 2026-03-02 22:03:01 +08:00
dmy
12565e971d refactor: 简化地面倾角输入为单个输入框 2026-03-02 22:01:04 +08:00
dmy
dbd6c4f702 feat: 限制导线和地线坐标数组的最大长度并更新标签
限制导线和地线坐标数组的最大长度为4(1地线+3导线),更新相关按钮的禁用逻辑
更新坐标输入框的标签文本
2026-03-02 21:58:09 +08:00
dmy
3bda0260b6 feat: 将输入框提示信息改为工具提示样式 2026-03-02 21:52:20 +08:00
dmy
73d4f0ac01 docs: 更新 2026-03-02 21:49:35 +08:00
dmy
4ae2f36049 feat: 将额定电压输入改为下拉选择框
将额定电压从数字输入改为预定义选项的下拉选择框,支持常见的电压等级
2026-03-02 21:17:34 +08:00
dmy
9a5e8e0076 feat: 优化日志组件并简化后端日志处理
为日志组件添加折叠功能并显示空状态提示
移除参数表单中冗余的后端日志调试信息
2026-03-02 21:13:26 +08:00
dmy
89e4cd4973 feat: 添加运行日志组件并集成到参数表单
在参数表单中添加运行日志组件,用于显示计算过程中的日志信息
后端增加日志处理功能,将计算日志返回给前端显示
优化tsconfig配置,添加路径别名支持
2026-03-02 21:02:58 +08:00
dmy
6ebfcf848d feat: 添加基于pywebview的图形界面支持
新增图形界面模块webui,使用Vue 3 + Quasar + TypeScript + Tailwind CSS开发
扩展README文档说明图形界面使用方法
更新.gitignore忽略前端相关文件
添加Python版本配置文件
2026-03-02 19:39:28 +08:00
dmy
a153e69eb7 提交。 2026-03-02 18:18:46 +08:00
33 changed files with 6899 additions and 284 deletions

8
.gitignore vendored
View File

@@ -11,3 +11,11 @@ dist
*.toml
launch.json
settings.json
node_modules
*.log
*.lock
*.pdf
lightening.ico
metadata.yml
VERSION
生成exe图标.png

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

View File

@@ -1,6 +1,15 @@
target: dist build
create-version-file metadata.yml --outfile build/file_version_info.txt
pyinstaller -F main.py --version-file build/file_version_info.txt -n Lightening
gui: build
uv run python update_version.py
cd webui && npm run build
cd ..
uv run pyinstaller webview_app.py -n LighteningGUI --noconsole --add-data "webui/dist;webui/dist" -y --icon lightening.ico
console: dist build
uv run python update_version.py
cd webui && npm run build
cd ..
uv run create-version-file metadata.yml --outfile build/file_version_info.txt
uv run pyinstaller -F main.py --version-file build/file_version_info.txt -n Lightening
dist:
mkdir dist

222
README.md Normal file
View File

@@ -0,0 +1,222 @@
# EGM - 输电线路绕击跳闸率计算程序
基于电气几何模型Electro-Geometric Model, EGM的架空输电线路雷电防护性能计算工具用于评估输电线路的绕击跳闸率。
## 功能特点
- 支持单回和双回输电线路的绕击跳闸率计算
- 考虑工作电压对雷电击距的影响
- 支持地面倾角参数
- 支持自定义地闪密度和雷电流概率密度曲线
- 支持交流/直流线路计算
- 输出CAD图形DXF格式可视化击距模型
- 提供动画演示模式(可选项)
- **提供图形化界面pywebview**,支持可视化参数配置和计算
## 安装
### 环境要求
- Python >= 3.12
- Node.js >= 18图形界面开发需要
### 安装依赖
```bash
# 使用 uv推荐
uv sync
# 或使用 pip
pip install -r requirements.txt
```
### 依赖包
- ezdxf - DXF文件生成
- loguru - 日志记录
- numpy - 数值计算
- tomli - TOML配置文件解析
- pywebview - 图形界面框架
## 使用方法
### 基本使用
```bash
# 使用默认配置文件
python main.py
# 指定配置文件
python main.py <配置文件路径>.toml
```
### 批量计算
批量计算不同保护角下的跳闸率:
```bash
python main-batch.py <配置文件路径>.toml
```
结果将输出到 `r.txt` 文件中。
### 打包为可执行文件
使用 Makefile 打包:
```bash
make
```
生成的可执行文件位于 `dist/Lightening.exe`
### 图形界面使用
程序提供基于 pywebview 的图形界面,使用 Vue 3 + Quasar + TypeScript + Tailwind CSS 开发。
#### 安装前端依赖
```bash
cd webui
npm install
```
#### 开发模式运行
```bash
# 终端1启动前端开发服务器
cd webui
npm run dev
# 终端2启动 pywebview 窗口
python webview_app.py
```
#### 生产模式运行
```bash
# 构建前端
cd webui
npm run build
# 启动 pywebview 窗口
python webview_app.py
```
图形界面支持:
- 可视化参数配置(基本参数、高级参数、可选参数)
- 动态添加/删除数组参数
- 参数导出功能
- 计算结果展示
详细说明请参考 `webui/README.md`
## 配置文件格式
配置文件使用 TOML 格式,包含三个主要部分:
### [parameter] - 基本参数
```toml
[parameter]
rated_voltage = 750 # 额定电压等级 (kV)
h_c_sag = 14.43 # 导线弧垂 (m)
h_g_sag = 11.67 # 地线弧垂 (m)
insulator_c_len = 7.4 # 导线串子绝缘长度 (m)
string_c_len = 9.2 # 导线串长 (m)
string_g_len = 0.5 # 地线串长 (m)
h_arm = [130, 100] # 导、地线挂点垂直距离 (m),第一个值为地线挂点高度
gc_x = [17.9, 17] # 导、地线水平坐标 (m)
ground_angels = [0] # 地面倾角 (°),向下为正,支持多个角度
altitude = 1000 # 海拔高度 (m)
td = 20 # 雷暴日 (d)
```
### [advance] - 高级参数
```toml
[advance]
ng = -1 # 地闪密度 (次/(km²·a))大于0时使用此值否则通过雷暴日计算
Ip_a = -1 # 雷电流概率密度曲线系数a大于0时使用此值
Ip_b = -1 # 雷电流概率密度曲线系数b大于0时使用此值
```
**注意**:当 `ng` > 0 时,不会通过雷暴日计算地闪密度;当 `Ip_a``Ip_b` > 0 时,不会使用默认雷暴日对应的概率密度。
### [optional] - 可选参数
```toml
[optional]
voltage_n = 3 # 计算时电压分成多少份(考虑电压波动影响)
max_i = 200 # 最大尝试雷电流 (kA)
```
## 计算原理
### 击距模型
程序使用基于电气几何模型的方法计算绕击跳闸率,主要涉及以下击距公式:
1. **地线击距**$r_s = 10 \times I^{0.65}$
2. **导线击距**$r_c = 1.63 \times (5.015 \times I^{0.578} - 0.001 \times U_{ph})^{1.125}$
3. **地面击距**
- $h_{av} < 40m$: $r_g = (3.6 + 1.7 \ln(43 - h_{av})) \times I^{0.65}$
- $h_{av} \ge 40m$: $r_g = 5.5 \times I^{0.65}$
### 地闪密度计算
根据 Q/GDW 11452-2015 导则:
$N_g = 0.023 \times T_d^{1.3}$
### 跳闸率计算
通过积分暴露弧面积与雷电流概率密度的乘积得到最终跳闸率,并考虑建弧率。
## 输出结果
1. **控制台日志**:显示详细的计算过程和中间结果
2. **DXF文件**egm1.dxf、egm2.dxf 等,可视化击距模型
3. **跳闸率结果**:单位为 次/(100km·a)
## 项目结构
```
EGM/
├── main.py # 主程序入口
├── main-batch.py # 批量计算程序
├── core.py # 核心计算模块
├── animation.py # 动画演示模块
├── webview_app.py # pywebview 图形界面后端
├── article.toml # 示例配置文件
├── default.toml # 默认配置文件
├── Makefile # 构建脚本
├── pyproject.toml # 项目配置
├── README.md # 说明文档
├── webui/ # 图形界面前端项目
│ ├── src/
│ │ ├── components/
│ │ │ ├── ParameterForm.vue # 参数表单组件
│ │ │ ├── Animation.vue # 动画可视化组件
│ │ │ └── Log.vue # 日志显示组件
│ │ ├── types/
│ │ │ └── index.ts # TypeScript 类型定义
│ │ ├── App.vue # 主应用组件
│ │ ├── main.ts # 应用入口
│ │ └── style.css # 全局样式
│ ├── package.json # 前端依赖配置
│ ├── vite.config.ts # Vite 配置
│ ├── tsconfig.json # TypeScript 配置
│ ├── tailwind.config.js # Tailwind CSS 配置
│ └── index.html # HTML 入口
├── CSharp/ # C# 版本实现
└── 历史/ # 历史配置文件和DXF文件
```
## 技术支持
程序基于新版大手册公式和 Q/GDW 11452-2015《架空输电线路防雷导则》实现。
## 许可证
请参考项目许可证文件。

View File

@@ -1,94 +0,0 @@
import matplotlib.pyplot as plt
from functools import wraps
import numpy as np
class Animation:
def __init__(self) -> None:
fig, ax = plt.subplots()
self._fig = fig
self._ax = ax
self._ticks = 0
self._disable = False
self.init_fig()
pass
@staticmethod
def switch_decorator(func):
@wraps(func)
def not_run(cls, *args, **kwargs):
# print("not run")
pass
@wraps(func)
def wrapTheFunction(cls, *args, **kwargs):
if not cls._disable:
# print("desc")
return func(cls, *args, **kwargs)
return not_run(cls, *args, **kwargs)
return wrapTheFunction
def disable(self, _disable):
self._disable = _disable
@switch_decorator
def init_fig(self):
ax = self._ax
ax.set_aspect(1)
ax.set_xlim([-500, 500])
ax.set_ylim([-500, 500])
@switch_decorator
def show(self):
self._fig.show()
@switch_decorator
def add_rg_line(self, line_func):
ax = self._ax
x = np.linspace(0, 300)
y = line_func(x)
ax.plot(x, y)
@switch_decorator
def add_rs(self, rs, rs_x, rs_y):
ax = self._ax
ax.add_artist(plt.Circle((rs_x, rs_y), rs, fill=False))
@switch_decorator
def add_rc(self, rc, rc_x, rc_y):
ax = self._ax
ax.add_artist(plt.Circle((rc_x, rc_y), rc, fill=False))
# 增加暴露弧范围
@switch_decorator
def add_expose_area(
self,
rc_x,
rc_y,
intersection_x1,
intersection_y1,
intersection_x2,
intersection_y2,
):
ax = self._ax
ax.plot([rc_x, intersection_x1], [rc_y, intersection_y1], color="red")
ax.plot([rc_x, intersection_x2], [rc_y, intersection_y2], color="red")
pass
@switch_decorator
def clear(self):
ax = self._ax
ax.cla()
@switch_decorator
def pause(self):
ax = self._ax
self._ticks += 1
ticks = self._ticks
ax.set_title(f"{ticks}")
plt.pause(0.02)
self.clear()
self.init_fig()
pass

View File

@@ -3,13 +3,13 @@ title = "绕击跳闸率计算文件"
rated_voltage = 750 # 额定电压等级
h_c_sag = 14.43 # 导线弧垂
h_g_sag = 11.67 # 地线弧垂
insulator_c_len = 7.0 # 导线串子绝缘长度
insulator_c_len = 7.02 # 导线串子绝缘长度
string_c_len = 9.2 # 导线串长
string_g_len = 0.5 # 地线串长
h_arm = [100, 80] # 导、地线挂点垂直距离,计算的是中相
h_arm = [150, 130] # 导、地线挂点垂直距离,计算的是中相
gc_x = [17.9, 17] # 导、地线水平坐标,计算的是中相
ground_angels = [0] # 地面倾角,向下为正,单位°
altitude = 1500 # 海拔,单位米
altitude = 1000 # 海拔,单位米
td = 20 # 雷暴日
[advance]
# ng=29.6 #地闪密度 !!注意!! 如果地闪密度大于0则不会通过雷暴日计算地闪密度。填-1则忽略该项数据。

31
core.py
View File

@@ -2,7 +2,7 @@ import math
import ezdxf
import numpy as np
from typing import List
from loguru import logger
gCAD = None
gMSP = None
gCount = 1
@@ -18,16 +18,17 @@ class Parameter:
string_g_len: float
gc_x: List[float] # 导、地线水平坐标
ground_angels: List[float] # 地面倾角,向下为正
h_arm: float # 导、地线垂直坐标
h_arm: List[float] # 导、地线垂直坐标
altitude: int # 海拔,单位米
max_i: float # 最大尝试电流单位kA
rated_voltage: float # 额定电压
ng: float # 地闪密度 次/(每平方公里·每年)
Ip_a: float # 概率密度曲线系数a
Ip_b: float # 概率密度曲线系数b
para = Parameter()
ac_or_dc: str # 交流或直流标识,"AC" 或 "DC",默认 "AC"
z_0: float # 雷电波阻抗,默认 300
z_c: float # 导线波阻抗,默认 251
u_50: float # 50%击穿电压,-1表示自动计算
def rg_line_function_factory(_rg, ground_angel): # 返回一个地面捕雷线的直线方程
@@ -187,20 +188,20 @@ def solve_circle_line_intersection(
return [_x, _y]
def min_i(string_len, u_ph):
def min_i(string_len, u_ph, altitude: float = 0, z_0: float = 300, z_c: float = 251, u_50: float = None):
# 海拔修正
altitude = para.altitude
if altitude > 1000:
k_a = math.exp((altitude - 1000) / 8150) # 气隙海拔修正
else:
k_a = 1
u_50 = 1 / k_a * (530 * string_len + 35) # 50045 上附录的公式,实际应该用负极性电压的公式
# 只有在u_50未提供时才使用公式计算
if u_50 is None:
u_50 = 1 / k_a * (530 * string_len + 35) # 50045 上附录的公式,实际应该用负极性电压的公式
logger.info(f"50%击穿电压为: {u_50}kV")
# u_50 = 1 / k_a * (533 * string_len + 132) # 串放电路径 1000m海拔
# u_50 = 1 / k_a * (477 * string_len + 99) # 串放电路径 2000m海拔
# u_50 = 615 * string_len # 导线对塔身放电 1000m海拔
# u_50= 263.32647401+533.90081562*string_len
z_0 = 300 # 雷电波阻抗
z_c = 251 # 导线波阻抗
# 新版大手册公式 3-277
r = (u_50 + 2 * z_0 / (2 * z_0 + z_c) * u_ph) * (2 * z_0 + z_c) / (z_0 * z_c)
# r = 2 * (u_50 - u_ph) / z_c
@@ -218,10 +219,10 @@ def thunder_density(i, td, ip_a, ip_b): # 雷电流幅值密度函数
)
return r
else:
if td == 20:
if td <= 20:
r = -(10 ** (-i / 44)) * math.log(10) * (-1 / 44) # 雷暴日20d
return r
if td == 40:
if td>20:
r = -(10 ** (-i / 88)) * math.log(10) * (-1 / 88) # 雷暴日40d
return r
raise Exception("检查雷电参数!")
@@ -455,9 +456,9 @@ def tangent_line_k(line_x, line_y, center_x, center_y, radius, init_k=10.0):
return np.array(k_candidate)[np.max(k_angle) == k_angle].tolist()[-1]
def func_ng(td): # 地闪密度,通过雷暴日计算
if para.ng > 0:
r = para.ng
def func_ng(td, ng: float = 0): # 地闪密度,通过雷暴日计算
if ng > 0:
r = ng
else:
r = 0.023 * (td**1.3)
return r

372
main-batch.py Normal file
View File

@@ -0,0 +1,372 @@
import math
import os.path
import sys
import tomllib as tomli
from loguru import logger
from core import *
import timeit
# 打印参数
def parameter_display(para_dis: Parameter):
logger.info(f"额定电压 kV {para_dis.rated_voltage}")
logger.info(f"导线弧垂 m {para_dis.h_c_sag}")
logger.info(f"地线弧垂 m {para_dis.h_g_sag}")
logger.info(f"全塔高 m {para_dis.h_arm[0]}")
logger.info(f"串绝缘距离 m {para_dis.insulator_c_len}")
logger.info(f"导线串长 m {para_dis.string_c_len}")
logger.info(f"地线串长 m {para_dis.string_g_len}")
logger.info(f"挂点垂直坐标 m {para_dis.h_arm}")
logger.info(f"挂点水平坐标 m {para_dis.gc_x}")
logger.info(f"地面倾角 ° {[an * 180 / math.pi for an in para_dis.ground_angels]}")
logger.info(f"海拔高度 m {para_dis.altitude}")
if para_dis.ng > 0:
logger.info("不采用雷暴日计算地闪密度和雷电流密度")
logger.info(f"地闪密度 次/(每平方公里·每年) {para_dis.ng}")
logger.info(f"概率密度曲线系数a {para_dis.Ip_a}")
logger.info(f"概率密度曲线系数b {para_dis.Ip_b}")
pass
else:
logger.info(f"雷暴日 d {para_dis.td}")
def read_parameter(toml_file_path) -> Parameter:
para = Parameter()
with open(toml_file_path, "rb") as toml_fs:
toml_dict = tomli.load(toml_fs)
toml_parameter = toml_dict["parameter"]
para.h_g_sag = toml_parameter["h_g_sag"] # 地线弧垂
para.h_c_sag = toml_parameter["h_c_sag"] # 导线弧垂
# para.h_whole = toml_parameter["h_whole"] # 杆塔全高
para.td = toml_parameter["td"] # 雷暴日
para.insulator_c_len = toml_parameter["insulator_c_len"] # 串子绝缘长度
para.string_c_len = toml_parameter["string_c_len"]
para.string_g_len = toml_parameter["string_g_len"]
para.gc_x = toml_parameter["gc_x"] # 导、地线水平坐标
para.ground_angels = [
angel / 180 * math.pi for angel in toml_parameter["ground_angels"]
] # 地面倾角,向下为正
para.h_arm = toml_parameter["h_arm"]
para.altitude = toml_parameter["altitude"]
para.rated_voltage = toml_parameter["rated_voltage"]
para.z_0 = toml_parameter.get("z_0", 300) # 雷电波阻抗
para.z_c = toml_parameter.get("z_c", 251) # 导线波阻抗
toml_advance = toml_dict["advance"]
para.ng = toml_advance["ng"] # 地闪密度
para.Ip_a = toml_advance["Ip_a"] # 概率密度曲线系数a
para.Ip_b = toml_advance["Ip_b"] # 概率密度曲线系数b
para.u_50 = toml_advance.get("u_50", -1) # 50%击穿电压,-1表示自动计算
toml_optional = toml_dict["optional"]
para.voltage_n = toml_optional["voltage_n"] # 工作电压分成多少份来计算
para.max_i = toml_optional["max_i"]
return para
def egm():
if len(sys.argv) < 2:
toml_file_path = r"ZC27103B-2000m.toml"
else:
toml_file_path = sys.argv[1]
if not os.path.exists(toml_file_path):
logger.info(f"无法找到数据文件{toml_file_path},程序退出。")
sys.exit(0)
logger.info(f"读取文件{toml_file_path}")
para = read_parameter(toml_file_path)
#########################################################
# 以上是需要设置的参数
parameter_display(para)
h_whole = para.h_arm[0] # 塔全高
string_g_len = para.string_g_len
string_c_len = para.string_c_len
h_g_sag = para.h_g_sag
h_c_sag = para.h_c_sag
gc_x = para.gc_x
shield_angle = [0, 10, 11, 12, 13, 14, 15, 16, 17, 18]
with open('r.txt','w',encoding='utf-8') as sa_result_file:
for sa in shield_angle:
gc_x[0] = gc_x[1] + math.tan(sa / 180 * math.pi) * (10 + 6 - 0.5)
h_arm = para.h_arm
gc_y = [
h_whole - string_g_len - h_g_sag * 2 / 3, # 地线对地平均高
]
if len(h_arm) > 1:
for hoo in h_arm[1:]:
gc_y.append(hoo - string_c_len - h_c_sag * 2 / 3) # 导线平均高
if len(gc_y) > 2: # 双回路
phase_n = 3 # 边相导线数量
else:
phase_n = 1
# 地闪密度 利用QGDW 11452-2015 架空输电线路防雷导则的公式 Ng=0.023*Td^(1.3) 20天雷暴日地闪密度为1.13
td = para.td
ng = func_ng(td, para.ng)
avr_n_sf = 0 # 考虑电压的影响计算的跳闸率
ground_angels = para.ground_angels
for ground_angel in ground_angels:
logger.info(f"地面倾角{ground_angel/math.pi*180:.3f}°")
rg_type = None
rg_x = None
rg_y = None
cad = Draw()
voltage_n = para.voltage_n
n_sf_phases = np.zeros((phase_n, voltage_n)) # 存储每一相的跳闸率
if np.any(np.array(gc_y) < 0):
logger.info("导线可能掉地面下了,程序退出。")
return 0
for phase_conductor_foo in range(phase_n):
exposed_curve_shielded = False
rs_x = gc_x[phase_conductor_foo]
rs_y = gc_y[phase_conductor_foo]
rc_x = gc_x[phase_conductor_foo + 1]
rc_y = gc_y[phase_conductor_foo + 1]
if phase_n == 1:
rg_type = "g"
if phase_n > 1: # 多回路
if phase_conductor_foo < 2:
rg_type = "c" # 捕捉弧有下面一相导线的击距代替
rg_x = gc_x[phase_conductor_foo + 2]
rg_y = gc_y[phase_conductor_foo + 2]
else:
rg_type = "g"
# TODO 保护角公式可能有问题,后面改
shield_angle_at_avg_height = (
math.atan(
(rc_x - rs_x)
/ (
(h_arm[0] - string_g_len - h_arm[phase_conductor_foo + 1])
+ string_c_len
)
)
* 180
/ math.pi
) # 挂点处保护角
logger.info(f"挂点处保护角{shield_angle_at_avg_height:.3f}°")
logger.debug(f"最低相防护标识{rg_type}")
rated_voltage = para.rated_voltage
for u_bar in range(voltage_n): # 计算不同工作电压下的跳闸率
# u_ph = (
# math.sqrt(2)
# * 750
# * math.cos(2 * math.pi / voltage_n * u_bar)
# / 1.732
# ) # 运行相电压
u_ph = rated_voltage
logger.info(f"计算第{phase_conductor_foo + 1}相,电压为{u_ph:.2f}kV")
# 迭代法计算最大电流
i_max = 0
insulator_c_len = para.insulator_c_len
# i_min = min_i(insulator_c_len, u_ph / 1.732)
# u_50: -1表示自动计算其他值表示使用提供的值
u_50_value = para.u_50 if para.u_50 > 0 else None
i_min = min_i(insulator_c_len, u_ph, para.altitude, para.z_0, para.z_c, u_50_value)
_min_i = i_min # 尝试的最小电流
_max_i = para.max_i # 尝试的最大电流
# cad.draw(i_min, u_ph, rs_x, rs_y, rc_x, rc_y, rg_x, rg_y, rg_type, 2)
for i_bar in np.linspace(
_min_i, _max_i, int((_max_i - _min_i) / 0.1)
): # 雷电流
# logger.info(f"尝试计算电流为{i_bar:.2f}")
rs = rs_fun(i_bar)
rc = rc_fun(i_bar, u_ph)
rg = rg_fun(i_bar, rc_y, u_ph, typ=rg_type)
rg_line_func = None
if rg_type == "g":
rg_line_func = rg_line_function_factory(rg, ground_angel)
#######
# cccCount += 1
# if cccCount % 30 == 0:
# import core
#
# core.gMSP.add_circle((0, h_gav), rs)
# core.gMSP.add_circle(
# (dgc, h_cav), rc_fun(i_bar, -u_ph), dxfattribs={"color": 4}
# )
# core.gMSP.add_circle((dgc, h_cav), rc)
#######
rg_rc_circle_intersection = solve_circle_intersection(
rs, rc, rs_x, rs_y, rc_x, rc_y
)
i_max = i_bar
if (
not rg_rc_circle_intersection
): # if circle_intersection is []
logger.debug("保护弧和暴露弧无交点,检查设置参数。")
continue
circle_rc_line_or_rg_intersection = None
if rg_type == "g":
circle_rc_line_or_rg_intersection = (
solve_circle_line_intersection(
rc, rc_x, rc_y, rg_line_func
)
)
elif rg_type == "c":
circle_rc_line_or_rg_intersection = (
solve_circle_intersection(
rg, rc, rg_x, rg_y, rc_x, rc_y
)
)
if not circle_rc_line_or_rg_intersection:
# 暴露弧和捕捉弧无交点
if rg_type == "g":
if rg_line_func(rc_x) > rc_y:
i_min = i_bar # 用于后面判断最小和最大电流是否相等,相等意味着暴露弧一直被屏蔽
logger.info(f"捕捉面在暴露弧之上,设置最小电流为{i_min:.2f}")
else:
logger.info("暴露弧和地面捕捉弧无交点,检查设置参数。")
continue
else:
logger.info("上面的导地线无法保护下面的导地线,检查设置参数。")
continue
min_distance_intersection = (
np.sum(
(
np.array(rg_rc_circle_intersection)
- np.array(circle_rc_line_or_rg_intersection)
)
** 2
)
** 0.5
) # 计算两圆交点和地面直线交点的最小距离
if min_distance_intersection < 0.1:
break # 已经找到了最大电流
# 判断是否以完全被保护
if (
rg_rc_circle_intersection[1]
< circle_rc_line_or_rg_intersection[1]
):
circle_rs_line_or_rg_intersection = None
if rg_type == "g":
circle_rs_line_or_rg_intersection = (
solve_circle_line_intersection(
rs, rs_x, rs_y, rg_line_func
) # 保护弧和捕雷弧的交点
)
if rg_type == "c":
circle_rs_line_or_rg_intersection = (
solve_circle_intersection(
rs, rg, rs_x, rs_y, rg_x, rg_y
)
)
# 判断与保护弧的交点是否在暴露弧外面
distance = (
np.sum(
(
np.array(circle_rs_line_or_rg_intersection)
- np.array([rc_x, rc_y])
)
** 2
)
** 0.5
)
if distance > rc:
logger.info(f"电流为{i_bar}kV时暴露弧已经完全被屏蔽")
exposed_curve_shielded = True
break
# if phase_conductor_foo == 2:
cad.draw(
i_min,
u_ph,
rs_x,
rs_y,
rc_x,
rc_y,
rg_x,
rg_y,
rg_type,
ground_angel,
2,
)
cad.draw(
i_max,
u_ph,
rs_x,
rs_y,
rc_x,
rc_y,
rg_x,
rg_y,
rg_type,
ground_angel,
6,
)
cad.save_as(f"egm{phase_conductor_foo + 1}.dxf")
# 判断是否导线已经被完全保护
if abs(i_max - _max_i) < 1e-5:
logger.info("无法找到最大电流,可能是杆塔较高。")
logger.info(f"最大电流设置为自然界最大电流{i_max}kA")
logger.info(f"最大电流为{i_max:.2f}")
logger.info(f"最小电流为{i_min:.2f}")
if exposed_curve_shielded:
logger.info("暴露弧已经完全被屏蔽,不会跳闸。")
continue
curt_fineness = 0.1 # 电流积分细度
if i_min > i_max or abs(i_min - i_max) < curt_fineness:
logger.info("最大电流小于等于最小电流,没有暴露弧。")
continue
# 开始积分
curt_segment_n = int((i_max - i_min) / curt_fineness) # 分成多少份
i_curt_samples, d_curt = np.linspace(
i_min, i_max, curt_segment_n + 1, retstep=True
)
bd_area_vec = np.vectorize(bd_area)
td = para.td
ip_a = para.Ip_a
ip_b = para.Ip_b
cal_bd_np = bd_area_vec(
i_curt_samples,
u_ph,
rc_x,
rc_y,
rs_x,
rs_y,
rg_x,
rg_y,
ground_angel,
rg_type,
) * thunder_density(i_curt_samples, td, ip_a, ip_b)
calculus = np.sum(cal_bd_np[:-1] + cal_bd_np[1:]) / 2 * d_curt
# for i_curt in i_curt_samples[:-1]:
# cal_bd_first = bd_area(i_curt, u_ph, dgc, h_gav, h_cav)
# cal_bd_second = bd_area(i_curt + d_curt, u_ph, dgc, h_gav, h_cav)
# cal_thunder_density_first = thunder_density(i_curt)
# cal_thunder_density_second = thunder_density(i_curt + d_curt)
# calculus += (
# (
# cal_bd_first * cal_thunder_density_first
# + cal_bd_second * cal_thunder_density_second
# )
# / 2
# * d_curt
# )
# if abs(calculus-0.05812740052770032)<1e-5:
# abc=123
# pass
rated_voltage = para.rated_voltage
n_sf = (
2
* ng
/ 10
* calculus
* arc_possibility(rated_voltage, insulator_c_len)
)
avr_n_sf += n_sf / voltage_n
n_sf_phases[phase_conductor_foo][u_bar] = n_sf
logger.info(f"工作电压为{u_ph:.2f}kV时,跳闸率是{n_sf:.16f}次/(100km·a)")
logger.info(f"线路跳闸率是{avr_n_sf:.16f}次/(100km·a)")
logger.info(
f"不同相跳闸率是{np.array2string(np.mean(n_sf_phases,axis=1),precision=16)}次/(100km·a)"
)
sa_result_file.write(f'{sa},{avr_n_sf}\n')
def speed():
a = 0
for bar in range(100000000):
a += bar
if __name__ == "__main__":
logger.remove()
logger.add(sys.stderr, level="DEBUG")
run_time = timeit.timeit("egm()", globals=globals(), number=1)
logger.info(f"运行时间:{run_time:.2f}s")
logger.info("Finished.")

179
main.py
View File

@@ -2,16 +2,16 @@ import math
import os.path
import sys
import time
import tomli
import tomllib as tomli
from loguru import logger
from core import *
import timeit
from animation import Animation
# 打印参数
def parameter_display(para_dis: Parameter):
logger.info(f"额定电压 kV {para_dis.rated_voltage}")
logger.info(f"交、直流标识 {para_dis.ac_or_dc}")
logger.info(f"导线弧垂 m {para_dis.h_c_sag}")
logger.info(f"地线弧垂 m {para_dis.h_g_sag}")
logger.info(f"全塔高 m {para_dis.h_arm[0]}")
@@ -22,17 +22,22 @@ def parameter_display(para_dis: Parameter):
logger.info(f"挂点水平坐标 m {para_dis.gc_x}")
logger.info(f"地面倾角 ° {[an * 180 / math.pi for an in para_dis.ground_angels]}")
logger.info(f"海拔高度 m {para_dis.altitude}")
if para_dis.ng > 0:
logger.info("不采用雷暴日计算地闪密度和雷电流密度")
logger.info(f"地闪密度 次/(每平方公里·每年) {para_dis.ng}")
logger.info(f"概率密度曲线系数a {para_dis.Ip_a}")
logger.info(f"概率密度曲线系数b {para_dis.Ip_b}")
pass
else:
logger.info(f"雷暴日 d {para_dis.td}")
logger.info(f"雷电波阻抗 Ω {para_dis.z_0}")
logger.info(f"导线波阻抗 Ω {para_dis.z_c}")
logger.info(f"工作电压分份数 {para_dis.voltage_n}")
logger.info(f"最大尝试电流 kA {para_dis.max_i}")
# if para_dis.ng > 0:
# logger.info("不采用雷暴日计算地闪密度和雷电流密度")
logger.info(f"地闪密度 次/(每平方公里·每年) {para_dis.ng}")
logger.info(f"概率密度曲线系数a {para_dis.Ip_a}")
logger.info(f"概率密度曲线系数b {para_dis.Ip_b}")
# pass
# else:
logger.info(f"雷暴日 d {para_dis.td}")
def read_parameter(toml_file_path):
def read_parameter(toml_file_path) -> Parameter:
para = Parameter()
with open(toml_file_path, "rb") as toml_fs:
toml_dict = tomli.load(toml_fs)
toml_parameter = toml_dict["parameter"]
@@ -50,28 +55,41 @@ def read_parameter(toml_file_path):
para.h_arm = toml_parameter["h_arm"]
para.altitude = toml_parameter["altitude"]
para.rated_voltage = toml_parameter["rated_voltage"]
para.ac_or_dc = toml_parameter.get("ac_or_dc", "AC") # 交流或直流标识默认AC
para.z_0 = toml_parameter.get("z_0", 300) # 雷电波阻抗
para.z_c = toml_parameter.get("z_c", 251) # 导线波阻抗
toml_advance = toml_dict["advance"]
para.ng = toml_advance["ng"] # 地闪密度
para.Ip_a = toml_advance["Ip_a"] # 概率密度曲线系数a
para.Ip_b = toml_advance["Ip_b"] # 概率密度曲线系数b
para.u_50 = toml_advance.get("u_50", -1) # 50%击穿电压,-1表示自动计算
toml_optional = toml_dict["optional"]
para.voltage_n = toml_optional["voltage_n"] # 工作电压分成多少份来计算
para.max_i = toml_optional["max_i"]
return para
def egm():
if len(sys.argv) < 2:
toml_file_path = r"内自500kV-ZCK上相.toml"
else:
toml_file_path = sys.argv[1]
if not os.path.exists(toml_file_path):
logger.info(f"无法找到数据文件{toml_file_path},程序退出。")
sys.exit(0)
logger.info(f"读取文件{toml_file_path}")
read_parameter(toml_file_path)
#########################################################
# 以上是需要设置的参数
def run_egm(para: Parameter, animation=None) -> dict:
"""
执行 EGM 计算的核心函数,可被外部调用。
Args:
para: 参数对象,包含所有计算所需的参数。
animation: 可选的动画对象,用于可视化。需要实现 add_rs, add_rc, add_rg_line, add_expose_area, pause 方法。
Returns:
计算结果字典。
"""
parameter_display(para)
# 参数校验gc_x 和 h_arm 长度必须一致
if len(para.gc_x) != len(para.h_arm):
error_msg = f"参数错误gc_x 长度({len(para.gc_x)})与 h_arm 长度({len(para.h_arm)})不一致"
logger.error(error_msg)
return {
"success": False,
"message": error_msg,
"data": None
}
h_whole = para.h_arm[0] # 挂点高
string_g_len = para.string_g_len
string_c_len = para.string_c_len
@@ -91,12 +109,12 @@ def egm():
phase_n = 1
# 地闪密度 利用QGDW 11452-2015 架空输电线路防雷导则的公式 Ng=0.023*Td^(1.3) 20天雷暴日地闪密度为1.13
td = para.td
ng = func_ng(td)
ng = func_ng(td, para.ng)
avr_n_sf = 0 # 考虑电压的影响计算的跳闸率
ground_angels = para.ground_angels
# 初始化动画
animate = Animation()
animate.disable(False)
# 动画对象:如果传入了 animation 则使用,否则不启用动画
# 注意:动画的启用由前端用户通过"启用动画"开关控制,后端不主动启用
animate = animation
# animate.show()
for ground_angel in ground_angels:
logger.info(f"地面倾角{ground_angel/math.pi*180:.3f}°")
@@ -107,7 +125,11 @@ def egm():
n_sf_phases = np.zeros((phase_n, voltage_n)) # 存储每一相的跳闸率
if np.any(np.array(gc_y) < 0):
logger.info("导线可能掉地面下了,程序退出。")
return 0
return {
"success": False,
"message": "导线可能掉地面下了,程序退出。",
"data": None
}
for phase_conductor_foo in range(phase_n):
exposed_curve_shielded = False
rs_x = gc_x[phase_conductor_foo]
@@ -123,37 +145,37 @@ def egm():
rg_y = gc_y[phase_conductor_foo + 2]
else:
rg_type = "g"
# TODO 保护角公式可能有问题,后面改
# 使用实际高度(考虑弧垂)计算保护角
shield_angle_at_avg_height = (
math.atan(
(rc_x - rs_x)
/ (
(h_arm[0] - string_g_len - h_arm[phase_conductor_foo + 1])
+ string_c_len
)
)
math.atan2(rc_x - rs_x, rs_y - rc_y)
* 180
/ math.pi
) # 挂点处保护角
logger.info(f"挂点处保护角{shield_angle_at_avg_height:.3f}°")
logger.debug(f"最低相防护标识{rg_type}")
)
logger.info(f"地线保护角(平均高处){shield_angle_at_avg_height:.2f}°")
logger.debug(f"最低相防护标识{rg_type}g表示地面c表示下导线")
rated_voltage = para.rated_voltage
logger.info(f"交、直流标识{para.ac_or_dc}")
for u_bar in range(voltage_n): # 计算不同工作电压下的跳闸率
# TODO 需要区分交、直流
# u_ph = (
# math.sqrt(2)
# * rated_voltage
# * math.cos(2 * math.pi / voltage_n * u_bar)
# / 1.732
# ) # 运行相电压
u_ph = rated_voltage / 1.732
if para.ac_or_dc=="AC":
# TODO 需要区分交、直流
# u_ph = (
# math.sqrt(2)
# * rated_voltage
# * math.cos(2 * math.pi / voltage_n * u_bar)
# / 1.732
# ) # 运行相电压
u_ph = rated_voltage/1.732
else:
u_ph = rated_voltage
logger.info(f"计算第{phase_conductor_foo + 1}相,电压为{u_ph:.2f}kV")
# 迭代法计算最大电流
i_max = 0
insulator_c_len = para.insulator_c_len
# i_min = min_i(insulator_c_len, u_ph / 1.732)
# TODO 需要考虑交、直流
i_min = min_i(insulator_c_len, u_ph)
# u_50: -1表示自动计算其他值表示使用提供的值
u_50_value = para.u_50 if para.u_50 > 0 else None
i_min = min_i(insulator_c_len, u_ph, para.altitude, para.z_0, para.z_c, u_50_value)
_min_i = i_min # 尝试的最小电流
_max_i = para.max_i # 尝试的最大电流
# cad.draw(i_min, u_ph, rs_x, rs_y, rc_x, rc_y, rg_x, rg_y, rg_type, 2)
@@ -162,14 +184,17 @@ def egm():
): # 雷电流
logger.info(f"尝试计算电流为{i_bar:.2f}")
rs = rs_fun(i_bar)
animate.add_rs(rs, rs_x, rs_y)
if animate:
animate.add_rs(rs, rs_x, rs_y)
rc = rc_fun(i_bar, u_ph)
animate.add_rc(rc, rc_x, rc_y)
if animate:
animate.add_rc(rc, rc_x, rc_y)
rg = rg_fun(i_bar, rc_y, u_ph, typ=rg_type)
rg_line_func = None
if rg_type == "g":
rg_line_func = rg_line_function_factory(rg, ground_angel)
animate.add_rg_line(rg_line_func)
if animate:
animate.add_rg_line(rg_line_func)
rs_rc_circle_intersection = solve_circle_intersection(
rs, rc, rs_x, rs_y, rc_x, rc_y
)
@@ -202,12 +227,13 @@ def egm():
"上面的导地线无法保护下面的导地线,检查设置参数。"
)
continue
animate.add_expose_area(
rc_x,
rc_y,
*rs_rc_circle_intersection,
*circle_rc_or_rg_line_intersection,
)
if animate:
animate.add_expose_area(
rc_x,
rc_y,
*rs_rc_circle_intersection,
*circle_rc_or_rg_line_intersection,
)
cad = Draw()
cad.draw(
i_min,
@@ -235,7 +261,8 @@ def egm():
ground_angel,
6,
) # 最大电流时
cad.save_as(f"egm{phase_conductor_foo + 1}.dxf")
# TODO: 暂时先不用。
# cad.save_as(f"egm{phase_conductor_foo + 1}.dxf")
min_distance_intersection = (
np.sum(
(
@@ -278,10 +305,11 @@ def egm():
** 0.5
)
if distance > rc:
logger.info(f"电流为{i_bar}kV时,暴露弧已经完全被屏蔽")
logger.info(f"电流为{i_bar}kA时,暴露弧已经完全被屏蔽")
exposed_curve_shielded = True
break
animate.pause()
if animate:
animate.pause()
# 判断是否导线已经被完全保护
if abs(i_max - _max_i) < 1e-5:
logger.info("无法找到最大电流,可能是杆塔较高。")
@@ -353,6 +381,37 @@ def egm():
f"不同相跳闸率是{np.array2string(np.mean(n_sf_phases,axis=1),precision=16)}次/(100km·a)"
)
return {
"success": True,
"message": "计算完成",
"data": {
"tripping_rate": f"{avr_n_sf:.16f} 次/(100km·a)",
"avr_n_sf": avr_n_sf,
"n_sf_phases": np.mean(n_sf_phases, axis=1).tolist(),
"parameters": {
"rated_voltage": para.rated_voltage,
"td": para.td,
"altitude": para.altitude,
"ground_angels": [a / math.pi * 180 for a in para.ground_angels],
"max_i": para.max_i
}
}
}
def egm():
"""命令行入口函数"""
if len(sys.argv) < 2:
toml_file_path = r"D:/code/EGM/历史/平乾750kV.toml"
else:
toml_file_path = sys.argv[1]
if not os.path.exists(toml_file_path):
logger.info(f"无法找到数据文件{toml_file_path},程序退出。")
sys.exit(0)
logger.info(f"读取文件{toml_file_path}")
para = read_parameter(toml_file_path)
run_egm(para)
def speed():
a = 0

View File

@@ -1,7 +0,0 @@
Version: 1.2.0.2
#CompanyName: My Imaginary Company
#FileDescription: Simple App
#InternalName: Simple App
#LegalCopyright: © My Imaginary Company. All rights reserved.
#OriginalFilename: SimpleApp.exe
ProductName: Lightening

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "EGM",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

100
plot.py
View File

@@ -1,100 +0,0 @@
import matplotlib
from plot_data import *
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
matplotlib.use("Qt5Agg")
# 解决中文乱码
plt.rcParams["font.sans-serif"] = ["simsun"]
plt.rcParams["font.family"] = "sans-serif"
# plt.rcParams["font.weight"] = "bold"
# 解决负号无法显示的问题
plt.rcParams["axes.unicode_minus"] = False
plt.rcParams["savefig.dpi"] = 1200 # 图片像素
# plt.savefig("port.png", dpi=600, bbox_inches="tight")
fontsize = 12
################################################
witdh_of_bar=0.3
color=plt.cm.BuPu(np.linspace(152/255, 251/255,152/255))
percent1 = data_150m塔高_不同地线保护角[:, 1] / data_150m塔高_不同地线保护角[:, 0]
# percent1 = data_66m串长_不同塔高[:, 1] / data_66m串长_不同塔高[:, 0]
# percent2 = data_68m串长_不同塔高[:, 1] / data_68m串长_不同塔高[:, 0]
fig, ax = plt.subplots()
x = np.arange(len(category_names_150m塔高_不同地线保护角)) # the label locations
p1 = ax.bar(category_names_150m塔高_不同地线保护角, percent1, witdh_of_bar, label="绕击/反击跳闸率比值",color=color,hatch='-')
# p1 = ax.bar(x - 0.3 / 2, percent1, 0.3, label="6.6m绝缘距离")
# p2 = ax.bar(x + 0.3 / 2, percent2, 0.3, label="6.8m绝缘距离")
ax.xaxis.set_major_locator(mticker.FixedLocator(x))
ax.set_xticklabels(category_names_150m塔高_不同地线保护角)
ax.set_ylabel("比值", fontsize=fontsize)
ax.set_xlabel("地线保护角(°)", fontsize=fontsize)
# ax.set_xlabel("接地电阻(Ω)", fontsize=fontsize)
plt.xticks(fontsize=fontsize)
plt.yticks(fontsize=fontsize)
ax.bar_label(p1, padding=0, fontsize=fontsize)
# ax.bar_label(p2, padding=0, fontsize=fontsize)
ax.legend(fontsize=fontsize)
fig.tight_layout()
plt.show()
# results = {
# "100m": 100 * data[0, :] / np.sum(data[0, :]),
# "110m": data[1, :] / np.sum(data[1, :]),
# "120m": data[2, :] / np.sum(data[2, :]),
# "130m": data[3, :] / np.sum(data[3, :]),
# "140m": data[4, :] / np.sum(data[4, :]),
# "150m": data[5, :] / np.sum(data[5, :]),
# }
# def survey(results, category_names):
# """
# Parameters
# ----------
# results : dict
# A mapping from question labels to a list of answers per category.
# It is assumed all lists contain the same number of entries and that
# it matches the length of *category_names*.
# category_names : list of str
# The category labels.
# """
# labels = list(results.keys())
# data = np.array(list(results.values()))
# data_cum = data.cumsum(axis=1)
# category_colors = plt.get_cmap("RdYlGn")(np.linspace(0.15, 0.85, data.shape[1]))
#
# fig, ax = plt.subplots(figsize=(9.2, 5))
# ax.invert_yaxis()
# ax.xaxis.set_visible(False)
# ax.set_xlim(0, np.sum(data, axis=1).max())
#
# for i, (colname, color) in enumerate(zip(category_names, category_colors)):
# widths = data[:, i]
# starts = data_cum[:, i] - widths
# rects = ax.barh(
# labels, widths, left=starts, height=0.5, label=colname, color=color
# )
#
# r, g, b, _ = color
# text_color = "white" if r * g * b < 0.5 else "darkgrey"
# ax.bar_label(rects, label_type="center", color=text_color)
# ax.legend(
# ncol=len(category_names),
# bbox_to_anchor=(0, 1),
# loc="lower left",
# fontsize="small",
# )
#
# return fig, ax
# percent=data/np.sum(data,axis=1)[:,None]*100
# percent = data[:, 1] / data[:, 0]
# plt.bar(category_names, percent, 0.3, label="黑")
# # plt.bar(category_names, percent[:,0], 0.2, label="r")
#
# # plt.bar(category_names, [0.014094 / 100, 0.025094 / 100], 0.2, label="h")
# plt.legend()
# # survey(results, category_names)
# plt.show()

58
update_version.py Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""更新版本号脚本"""
import sys
from pathlib import Path
def get_version():
"""从 VERSION 文件读取版本号"""
version_file = Path(__file__).parent / "VERSION"
return version_file.read_text().strip()
def increment_version(version: str) -> str:
"""递增版本号的修订号"""
parts = version.split(".")
if len(parts) == 3:
parts[2] = str(int(parts[2]) + 1)
return ".".join(parts)
return version
def update_version_file(version: str):
"""更新 VERSION 文件"""
version_file = Path(__file__).parent / "VERSION"
version_file.write_text(version + "\n")
def create_metadata(version: str):
"""创建 metadata.yml 文件"""
metadata_file = Path(__file__).parent / "metadata.yml"
content = f"""version: {version}
company_name: EGM
file_description: EGM Lightning Protection Calculator
product_name: Lightening
"""
metadata_file.write_text(content)
print(f"Created metadata.yml with version {version}")
def main():
# 检查是否只获取版本号
if len(sys.argv) > 1 and sys.argv[1] == "--get":
print(get_version())
return
# 获取当前版本并递增
current_version = get_version()
new_version = increment_version(current_version)
# 更新所有文件
update_version_file(new_version)
create_metadata(new_version)
print(f"Version updated: {current_version} -> {new_version}")
if __name__ == "__main__":
main()

149
webui/README.md Normal file
View File

@@ -0,0 +1,149 @@
# EGM Web 界面
基于 pywebview 的输电线路绕击跳闸率计算程序图形界面。
## 技术栈
- **后端**: Python + pywebview
- **前端**: Vue 3 + Quasar + TypeScript + Tailwind CSS
- **构建工具**: Vite
## 安装步骤
### 1. 安装 Python 依赖
```bash
# 在项目根目录
uv sync
# 或
pip install pywebview
```
### 2. 安装前端依赖
```bash
cd webui
npm install
```
## 运行方式
### 开发模式
1. 启动前端开发服务器:
```bash
cd webui
npm run dev
```
2. 在另一个终端启动 pywebview
```bash
cd ..
python webview_app.py
```
### 生产模式
1. 构建前端:
```bash
cd webui
npm run build
```
2. 启动 pywebview
```bash
cd ..
python webview_app.py
```
## 项目结构
```
webui/
├── src/
│ ├── components/
│ │ └── ParameterForm.vue # 参数表单组件
│ ├── types/
│ │ └── index.ts # TypeScript 类型定义
│ ├── App.vue # 主应用组件
│ ├── main.ts # 应用入口
│ └── style.css # 全局样式
├── package.json # 前端依赖配置
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
├── tailwind.config.js # Tailwind CSS 配置
└── index.html # HTML 入口
```
## 功能说明
### 参数配置
界面提供三个参数分组:
1. **基本参数**
- 额定电压等级 (kV)
- 导线弧垂 (m)
- 地线弧垂 (m)
- 导线串子绝缘长度 (m)
- 导线串长 (m)
- 地线串长 (m)
- 导、地线挂点垂直距离 (m)
- 导、地线水平坐标 (m)
- 地面倾角 (°)
- 海拔高度 (m)
- 雷暴日 (d)
2. **高级参数**
- 地闪密度 (次/(km²·a))
- 雷电流概率密度曲线系数 a
- 雷电流概率密度曲线系数 b
3. **可选参数**
- 计算时电压分成多少份
- 最大尝试雷电流 (kA)
### 操作功能
- **开始计算**: 执行 EGM 计算
- **重置参数**: 恢复默认参数值
- **导出配置**: 将当前配置导出为 JSON 文件
## 开发说明
### 添加新功能
1.`src/types/index.ts` 中添加类型定义
2.`src/components/ParameterForm.vue` 中添加 UI 组件
3.`webview_app.py` 中添加后端 API 方法
### 样式定制
- 使用 Tailwind CSS 类名进行样式定制
- Quasar 组件自带 Material Design 风格
## 常见问题
### Q: pywebview 窗口无法打开?
A: 请确保已安装 pywebview`pip install pywebview`
### Q: 前端开发服务器无法连接?
A: 请确保先运行 `npm run dev`,然后再启动 `python webview_app.py`
### Q: 计算功能不工作?
A: 当前版本仅提供界面框架,需要进一步集成 core.py 中的计算逻辑。
## 后续开发
- [ ] 集成 core.py 中的实际计算函数
- [ ] 添加计算结果可视化
- [ ] 支持导入/导出 TOML 配置文件
- [ ] 添加批量计算功能
- [ ] 添加结果导出DXF、PDF 等)

13
webui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EGM 输电线路绕击跳闸率计算</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3280
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

1
webui/package.json Normal file
View File

@@ -0,0 +1 @@
{"name":"egm-webui","version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"vue-tsc && vite build","preview":"vite preview"},"dependencies":{"@quasar/extras":"^1.17.0","@quasar/vite-plugin":"^1.10.0","quasar":"^2.14.0","vue":"^3.4.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.0.0","autoprefixer":"^10.4.0","postcss":"^8.4.0","sass-embedded":"^1.97.3","tailwindcss":"^3.4.0","typescript":"^5.3.0","vite":"^5.0.0","vue-tsc":"^2.0.0"}}

6
webui/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

26
webui/src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<div class="app-container">
<ParameterForm />
<div class="version-footer">v{{ appVersion }}</div>
</div>
</template>
<script setup lang="ts">
import ParameterForm from '@/components/ParameterForm.vue'
const appVersion = __APP_VERSION__
</script>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.version-footer {
text-align: center;
padding: 8px;
color: #999;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,257 @@
<template>
<q-card class="shadow-2">
<q-card-section class="bg-indigo-50 cursor-pointer" @click="toggleExpand">
<div class="text-h6 text-indigo-900 flex items-center gap-2">
<q-icon name="animation" />
EGM 动画可视化
<q-space />
<q-icon :name="expanded ? 'expand_less' : 'expand_more'" />
</div>
</q-card-section>
<q-slide-transition>
<q-card-section v-show="expanded">
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
class="animation-canvas"
/>
</q-card-section>
</q-slide-transition>
</q-card>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// Canvas 尺寸
const canvasWidth = 600
const canvasHeight = 600
// 坐标范围(对应 animation.py 的 [-500, 500]
const coordRange = {
xMin: -500,
xMax: 500,
yMin: -500,
yMax: 500
}
// 展开/折叠状态
const expanded = ref(false)
const canvasRef = ref<HTMLCanvasElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
let tick = 0
// 切换展开/折叠
const toggleExpand = () => {
expanded.value = !expanded.value
if (expanded.value) {
// 展开时初始化画布
setTimeout(() => {
if (canvasRef.value) {
ctx = canvasRef.value.getContext('2d')
initFig()
}
}, 350) // 等待动画完成
}
}
// 坐标转换:数据坐标 -> Canvas 坐标
const toCanvasX = (x: number): number => {
return ((x - coordRange.xMin) / (coordRange.xMax - coordRange.xMin)) * canvasWidth
}
const toCanvasY = (y: number): number => {
// Canvas Y 轴向下,需要反转
return canvasHeight - ((y - coordRange.yMin) / (coordRange.yMax - coordRange.yMin)) * canvasHeight
}
// 初始化画布
const initFig = () => {
if (!ctx || !expanded.value) return
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
// 绘制坐标轴
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
// X 轴
ctx.beginPath()
ctx.moveTo(0, toCanvasY(0))
ctx.lineTo(canvasWidth, toCanvasY(0))
ctx.stroke()
// Y 轴
ctx.beginPath()
ctx.moveTo(toCanvasX(0), 0)
ctx.lineTo(toCanvasX(0), canvasHeight)
ctx.stroke()
// 绘制刻度
ctx.fillStyle = '#666'
ctx.font = '10px Arial'
ctx.textAlign = 'center'
for (let x = coordRange.xMin; x <= coordRange.xMax; x += 100) {
if (x !== 0) {
const canvasX = toCanvasX(x)
ctx.fillText(x.toString(), canvasX, toCanvasY(0) + 15)
}
}
ctx.textAlign = 'right'
for (let y = coordRange.yMin; y <= coordRange.yMax; y += 100) {
if (y !== 0) {
const canvasY = toCanvasY(y)
ctx.fillText(y.toString(), toCanvasX(0) - 5, canvasY + 3)
}
}
}
// 清除画布
const clear = () => {
if (!ctx) return
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
}
// 添加 RG 线(地面线)
const addRgLine = (points: [number, number][]) => {
if (!ctx || !expanded.value || !points || points.length === 0) return
ctx.strokeStyle = '#2196F3'
ctx.lineWidth = 2
ctx.beginPath()
points.forEach((point, index) => {
const canvasX = toCanvasX(point[0])
const canvasY = toCanvasY(point[1])
if (index === 0) {
ctx!.moveTo(canvasX, canvasY)
} else {
ctx!.lineTo(canvasX, canvasY)
}
})
ctx.stroke()
}
// 添加 RS 圆(地线保护弧)- 这是每帧第一个绘制的元素,先清除画布
const addRs = (rs: number, rsX: number, rsY: number) => {
if (!ctx || !expanded.value) return
// 清除并重新初始化画布,准备绘制新的一帧
clear()
initFig()
const canvasX = toCanvasX(rsX)
const canvasY = toCanvasY(rsY)
const canvasRadius = rs * (canvasWidth / (coordRange.xMax - coordRange.xMin))
ctx.strokeStyle = '#4CAF50'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(canvasX, canvasY, canvasRadius, 0, Math.PI * 2)
ctx.stroke()
}
// 添加 RC 圆(导线暴露弧)
const addRc = (rc: number, rcX: number, rcY: number) => {
if (!ctx || !expanded.value) return
const canvasX = toCanvasX(rcX)
const canvasY = toCanvasY(rcY)
const canvasRadius = rc * (canvasWidth / (coordRange.xMax - coordRange.xMin))
ctx.strokeStyle = '#FF9800'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(canvasX, canvasY, canvasRadius, 0, Math.PI * 2)
ctx.stroke()
}
// 添加暴露弧区域(两条红线)
const addExposeArea = (
rcX: number,
rcY: number,
intersectionX1: number,
intersectionY1: number,
intersectionX2: number,
intersectionY2: number
) => {
if (!ctx || !expanded.value) return
ctx.strokeStyle = '#F44336'
ctx.lineWidth = 3
// 第一条线
ctx.beginPath()
ctx.moveTo(toCanvasX(rcX), toCanvasY(rcY))
ctx.lineTo(toCanvasX(intersectionX1), toCanvasY(intersectionY1))
ctx.stroke()
// 第二条线
ctx.beginPath()
ctx.moveTo(toCanvasX(rcX), toCanvasY(rcY))
ctx.lineTo(toCanvasX(intersectionX2), toCanvasY(intersectionY2))
ctx.stroke()
}
// 暂停并刷新 - 用于下一帧绘制前清除
const pause = () => {
if (!ctx || !expanded.value) return
tick += 1
// 不立即清除,等待下一次绑图时清除
// 这样用户可以看到当前帧
}
// 暴露方法给父组件或全局调用
const animationApi = {
enable: (enable: boolean) => {
expanded.value = enable
if (enable && canvasRef.value) {
ctx = canvasRef.value.getContext('2d')
initFig()
}
},
isEnabled: () => expanded.value,
initFig,
clear,
addRgLine,
addRs,
addRc,
addExposeArea,
pause
}
// 注册到全局,供后端调用
declare global {
interface Window {
animationApi?: typeof animationApi
}
}
onMounted(() => {
// 注册全局 API
window.animationApi = animationApi
})
onUnmounted(() => {
window.animationApi = undefined
})
// 导出方法供父组件使用
defineExpose(animationApi)
</script>
<style scoped>
.animation-canvas {
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fafafa;
display: block;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,422 @@
<template>
<q-card class="full-height">
<q-card-section class="bg-indigo-50">
<div class="text-h6 text-indigo-900 flex items-center gap-2">
<q-icon name="architecture" />
杆塔几何结构
</div>
</q-card-section>
<q-card-section>
<div class="geometry-container">
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
class="geometry-canvas"
/>
<!-- 图例 -->
<div class="legend q-mt-sm">
<div class="row q-gutter-md justify-center">
<div class="flex items-center gap-1">
<div class="legend-color" style="background: #4CAF50;"></div>
<span class="text-caption">地线</span>
</div>
<div class="flex items-center gap-1">
<div class="legend-color" style="background: #FF9800;"></div>
<span class="text-caption">导线</span>
</div>
<div class="flex items-center gap-1">
<div class="legend-color" style="background: #795548;"></div>
<span class="text-caption">地面</span>
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
// Props
const props = defineProps<{
hArm: number[] // 导、地线挂点垂直坐标 [地线, 导线1, ...]
gcX: number[] // 导、地线水平坐标 [地线, 导线1, ...]
hCSag: number // 导线弧垂
hGSag: number // 地线弧垂
stringCLen: number // 导线串长
stringGLen: number // 地线串长
groundAngels: number[] // 地面倾角
}>()
// Canvas 尺寸
const canvasWidth = 600
const canvasHeight = 500
const canvasRef = ref<HTMLCanvasElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
// 计算参数
const margin = { top: 40, right: 40, bottom: 60, left: 60 }
const plotWidth = canvasWidth - margin.left - margin.right
const plotHeight = canvasHeight - margin.top - margin.bottom
// 计算实际导地线高度(考虑弧垂和串长)
const calculateActualHeights = () => {
const hArmNums = props.hArm.map(v => Number(v))
const hGSagNum = Number(props.hGSag)
const hCSagNum = Number(props.hCSag)
const stringGLenNum = Number(props.stringGLen)
const stringCLenNum = Number(props.stringCLen)
// 地线实际高度 = 挂点高度 - 地线串长 - 地线弧垂 * 2/3
// 导线实际高度 = 挂点高度 - 导线串长 - 导线弧垂 * 2/3
return hArmNums.map((h, index) => {
if (index === 0) {
// 地线
return h - stringGLenNum - hGSagNum * 2 / 3
} else {
// 导线
return h - stringCLenNum - hCSagNum * 2 / 3
}
})
}
// 计算坐标范围
const calculateRange = () => {
// 确保将字符串转换为数字
const hArmNums = props.hArm.map(v => Number(v))
const gcXNums = props.gcX.map(v => Number(v))
const actualHeights = calculateActualHeights()
// 包含挂点高度和实际高度
const allHeights = [...hArmNums, ...actualHeights, 0]
const allX = [...gcXNums, -gcXNums[0] * 0.5, gcXNums[0] * 1.5] // 扩展水平范围
const yMin = -10
const yMax = Math.max(...allHeights) * 1.15
const xMin = Math.min(...allX) * 1.2
const xMax = Math.max(...allX) * 1.2
return { xMin, xMax, yMin, yMax }
}
// 坐标转换:数据坐标 -> Canvas 坐标
const toCanvasX = (x: number, range: ReturnType<typeof calculateRange>): number => {
return margin.left + ((x - range.xMin) / (range.xMax - range.xMin)) * plotWidth
}
const toCanvasY = (y: number, range: ReturnType<typeof calculateRange>): number => {
// Canvas Y 轴向下,需要反转
return margin.top + plotHeight - ((y - range.yMin) / (range.yMax - range.yMin)) * plotHeight
}
// 绘制函数
const draw = () => {
if (!ctx) return
const range = calculateRange()
// 清除画布
ctx.fillStyle = '#fafafa'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
// 绘制背景网格
ctx.strokeStyle = '#e8e8e8'
ctx.lineWidth = 1
// 垂直网格线
const xStep = Math.ceil((range.xMax - range.xMin) / 10)
for (let x = Math.ceil(range.xMin / xStep) * xStep; x <= range.xMax; x += xStep) {
ctx.beginPath()
ctx.moveTo(toCanvasX(x, range), margin.top)
ctx.lineTo(toCanvasX(x, range), margin.top + plotHeight)
ctx.stroke()
}
// 水平网格线
const yStep = Math.ceil((range.yMax - range.yMin) / 8)
for (let y = Math.ceil(range.yMin / yStep) * yStep; y <= range.yMax; y += yStep) {
ctx.beginPath()
ctx.moveTo(margin.left, toCanvasY(y, range))
ctx.lineTo(margin.left + plotWidth, toCanvasY(y, range))
ctx.stroke()
}
// 绘制坐标轴
ctx.strokeStyle = '#333'
ctx.lineWidth = 1.5
// Y 轴
ctx.beginPath()
ctx.moveTo(margin.left, margin.top)
ctx.lineTo(margin.left, margin.top + plotHeight)
ctx.stroke()
// X 轴(地面)
ctx.beginPath()
ctx.moveTo(margin.left, toCanvasY(range.yMin, range))
ctx.lineTo(margin.left + plotWidth, toCanvasY(range.yMin, range))
ctx.stroke()
// 绘制刻度标签
ctx.fillStyle = '#666'
ctx.font = '11px Arial'
// Y 轴刻度
ctx.textAlign = 'right'
for (let y = yStep; y <= range.yMax; y += yStep) {
const canvasY = toCanvasY(y, range)
ctx.fillText(`${y}`, margin.left - 8, canvasY + 4)
// 刻度线
ctx.beginPath()
ctx.moveTo(margin.left - 4, canvasY)
ctx.lineTo(margin.left, canvasY)
ctx.stroke()
}
// X 轴刻度
ctx.textAlign = 'center'
for (let x = Math.ceil(range.xMin / xStep) * xStep; x <= range.xMax; x += xStep) {
if (x !== 0) {
const canvasX = toCanvasX(x, range)
ctx.fillText(`${x}`, canvasX, toCanvasY(0, range) + 18)
// 刻度线
ctx.beginPath()
ctx.moveTo(canvasX, toCanvasY(0, range))
ctx.lineTo(canvasX, toCanvasY(0, range) + 4)
ctx.stroke()
}
}
// 轴标签
ctx.font = '12px Arial'
ctx.fillStyle = '#333'
ctx.textAlign = 'center'
ctx.fillText('水平距离 (m)', margin.left + plotWidth / 2, canvasHeight - 10)
ctx.save()
ctx.translate(15, margin.top + plotHeight / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText('高度 (m)', 0, 0)
ctx.restore()
// 绘制地面倾角
drawGround(range)
// 绘制导线和地线挂点
drawWirePoints(range)
// 绘制保护角
drawShieldingAngle(range)
}
// 绘制导线和地线挂点
const drawWirePoints = (range: ReturnType<typeof calculateRange>) => {
if (!ctx) return
const c = ctx
const actualHeights = calculateActualHeights()
props.hArm.forEach((height, index) => {
// 确保将字符串转换为数字
const heightNum = Number(height)
const actualHeight = actualHeights[index]
const wireX = Number(props.gcX[index]) || 0
const isGroundWire = index === 0
const canvasX = toCanvasX(wireX, range)
const canvasY = toCanvasY(heightNum, range)
const actualCanvasY = toCanvasY(actualHeight, range)
// 绘制从挂点到实际位置的虚线(绝缘子串 + 弧垂)
c.strokeStyle = isGroundWire ? '#4CAF50' : '#FF9800'
c.lineWidth = 2
c.setLineDash([4, 4])
c.beginPath()
c.moveTo(canvasX, canvasY)
c.lineTo(canvasX, actualCanvasY)
c.stroke()
c.setLineDash([])
// 绘制挂点标记(方形,表示杆塔挂点)
c.fillStyle = '#666'
c.fillRect(canvasX - 5, canvasY - 5, 10, 10)
// 绘制实际导地线位置(圆形)
c.fillStyle = isGroundWire ? '#4CAF50' : '#FF9800'
c.beginPath()
c.arc(canvasX, actualCanvasY, 8, 0, Math.PI * 2)
c.fill()
// 标注信息
c.fillStyle = '#333'
c.font = 'bold 11px Arial'
c.textAlign = 'left'
const labelX = canvasX + 12
const labelY = actualCanvasY - 8
const wireName = isGroundWire ? '地线' : `导线${index}`
const heightLabel = `H=${actualHeight.toFixed(1)}m`
const xLabel = `X=${wireX}m`
c.fillText(wireName, labelX, labelY)
c.font = '10px Arial'
c.fillStyle = '#666'
c.fillText(heightLabel, labelX, labelY + 14)
c.fillText(xLabel, labelX, labelY + 26)
})
}
// 绘制地面
const drawGround = (range: ReturnType<typeof calculateRange>) => {
if (!ctx) return
// 确保将字符串转换为数字
const groundAngle = Number(props.groundAngels[0]) || 0
const angleRad = (groundAngle * Math.PI) / 180
ctx.strokeStyle = '#795548'
ctx.lineWidth = 2
// 地面线(考虑倾角)
const groundLength = range.xMax - range.xMin
const dy = Math.tan(angleRad) * groundLength
const leftX = range.xMin
const rightX = range.xMax
const leftY = groundAngle >= 0 ? dy : 0
const rightY = groundAngle >= 0 ? 0 : -dy
ctx.beginPath()
ctx.moveTo(toCanvasX(leftX, range), toCanvasY(leftY, range))
ctx.lineTo(toCanvasX(rightX, range), toCanvasY(rightY, range))
ctx.stroke()
}
// 绘制保护角
const drawShieldingAngle = (range: ReturnType<typeof calculateRange>) => {
if (!ctx || props.hArm.length < 2) return
const actualHeights = calculateActualHeights()
const gwX = Number(props.gcX[0]) || 0
const gwY = actualHeights[0]
const cwX = Number(props.gcX[1]) || 0
const cwY = actualHeights[1]
const gwCanvasX = toCanvasX(gwX, range)
const gwCanvasY = toCanvasY(gwY, range)
const cwCanvasX = toCanvasX(cwX, range)
const cwCanvasY = toCanvasY(cwY, range)
// 计算保护角(地线与导线连线与垂直线的夹角)
const dx = cwX - gwX
const dy = gwY - cwY
const shieldingAngle = Math.atan2(dx, dy) * (180 / Math.PI)
// 绘制从地线到导线1的虚线
ctx.strokeStyle = '#9C27B0'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.beginPath()
ctx.moveTo(gwCanvasX, gwCanvasY)
ctx.lineTo(cwCanvasX, cwCanvasY)
ctx.stroke()
ctx.setLineDash([])
// // 绘制垂直参考线(从地线向下)
// ctx.strokeStyle = 'rgba(156, 39, 176, 0.3)'
// ctx.lineWidth = 1
// ctx.setLineDash([4, 4])
// ctx.beginPath()
// ctx.moveTo(gwCanvasX, gwCanvasY)
// ctx.lineTo(gwCanvasX, gwCanvasY + 80)
// ctx.stroke()
// ctx.setLineDash([])
// // 绘制角度弧
// const arcRadius = 30
// const verticalAngle = Math.PI / 2 // 向下
// const lineAngle = Math.atan2(cwCanvasY - gwCanvasY, cwCanvasX - gwCanvasX)
ctx.strokeStyle = '#9C27B0'
ctx.lineWidth = 1.5
ctx.beginPath()
// if (dx >= 0) {
// ctx.arc(gwCanvasX, gwCanvasY, arcRadius, Math.PI / 2, lineAngle, true)
// } else {
// ctx.arc(gwCanvasX, gwCanvasY, arcRadius, lineAngle, Math.PI / 2, false)
// }
// ctx.stroke()
// 计算标注位置(在线的右侧)
const midX = (gwCanvasX + cwCanvasX) / 2
const midY = (gwCanvasY + cwCanvasY) / 2
const labelOffsetX = dx >= 0 ? -45 : 45
const labelOffsetY = 0
// 绘制引线
ctx.strokeStyle = '#9C27B0'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(midX, midY)
ctx.lineTo(midX + labelOffsetX, midY + labelOffsetY)
ctx.stroke()
// 绘制标注文字
const labelText = `保护角(平均高处): ${Math.abs(shieldingAngle).toFixed(2)}°`
ctx.font = 'bold 12px Arial'
// 绘制标注文字
ctx.fillStyle = '#9C27B0'
ctx.textAlign = 'left'
ctx.fillText(labelText, midX + labelOffsetX, midY + labelOffsetY)
}
// 监听参数变化
watch(
() => [props.hArm, props.gcX, props.hCSag, props.hGSag, props.stringCLen, props.stringGLen, props.groundAngels],
() => {
draw()
},
{ deep: true }
)
// 初始化
onMounted(() => {
if (canvasRef.value) {
ctx = canvasRef.value.getContext('2d')
draw()
}
})
</script>
<style scoped>
.geometry-container {
display: flex;
flex-direction: column;
align-items: center;
}
.geometry-canvas {
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fafafa;
}
.legend-color {
width: 16px;
height: 4px;
border-radius: 2px;
}
.legend {
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<q-card class="q-mt-md shadow-2">
<q-card-section class="bg-blue-grey-1 cursor-pointer" @click="expanded = !expanded">
<div class="text-h6 text-blue-grey-9 flex items-center gap-2">
<q-icon name="terminal" />
运行日志
<q-space />
<q-icon :name="expanded ? 'expand_less' : 'expand_more'" />
</div>
</q-card-section>
<div v-show="expanded" class="q-pa-none">
<div ref="logContainer" class="log-container">
<div
v-for="(log, index) in logs"
:key="index"
:class="['log-line', `log-${log.level}`]"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
<div v-if="logs.length === 0" class="log-empty">暂无日志</div>
</div>
</div>
</q-card>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
defineOptions({
name: 'LogComponent'
})
interface LogEntry {
level: 'info' | 'warning' | 'error' | 'debug'
time: string
message: string
}
const logs = ref<LogEntry[]>([])
const logContainer = ref<HTMLElement | null>(null)
const expanded = ref(false) // 默认折叠
const lastTripRates = ref<number[]>([])
const addLog = (level: LogEntry['level'], message: string) => {
const now = new Date()
const time = now.toLocaleTimeString('zh-CN', { hour12: false })
logs.value.push({ level, time, message })
// 解析跳闸率数值
const match = message.match(/不同相跳闸率是\[([\d.\s]+)\]/)
if (match) {
const values = match[1].trim().split(/\s+/).map(Number)
lastTripRates.value = values
}
// 自动滚动到底部
nextTick(() => {
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
})
}
const clearLog = () => {
logs.value = []
}
// 获取日志文本
const getLogsText = (): string => {
return logs.value.map(log => `[${log.time}] [${log.level.toUpperCase()}] ${log.message}`).join('\n')
}
// 暴露方法给父组件
defineExpose({
addLog,
clearLog,
lastTripRates,
getLogsText
})
</script>
<style scoped>
.log-container {
max-height: 300px;
overflow-y: auto;
background-color: #1e1e1e;
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
padding: 8px;
user-select: text;
cursor: text;
}
.log-line {
padding: 2px 0;
display: flex;
gap: 8px;
}
.log-time {
color: #6a9955;
flex-shrink: 0;
}
.log-info .log-message {
color: #d4d4d4;
}
.log-warning .log-message {
color: #dcdcaa;
}
.log-error .log-message {
color: #f48771;
}
.log-debug .log-message {
color: #9cdcfe;
}
.log-empty {
color: #666;
font-style: italic;
padding: 8px;
}
</style>

View File

@@ -0,0 +1,971 @@
<template>
<q-layout view="lHh lpr lFf">
<q-header elevated class="bg-indigo-600 text-white">
<q-toolbar>
<q-toolbar-title class="q-py-sm">
<div class="flex items-center gap-2">
<q-icon name="flash_on" size="md" />
<span class="text-xl font-bold">EGM 输电线路绕击跳闸率计算</span>
</div>
<div v-if="currentFilePath" class="text-sm truncate max-w-2xl bg-white text-green-700 px-2 py-0.5 rounded mt-1" :title="currentFilePath">
{{ currentFilePath }}
</div>
</q-toolbar-title>
</q-toolbar>
</q-header>
<q-page-container>
<q-page class="q-pa-md">
<div class="max-w-7xl mx-auto">
<!-- 基本参数 + 杆塔几何结构 并排布局 -->
<div class="row q-col-gutter-md q-mb-md items-stretch" style="min-height: 500px;">
<!-- 左侧基本参数 -->
<div class="col-12 col-lg-6" style="display: flex;">
<q-card class="shadow-2" style="flex: 1;">
<q-card-section class="bg-indigo-50">
<div class="text-h6 text-indigo-900 flex items-center gap-2">
<q-icon name="settings" />
基本参数
</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-select
v-model="params.parameter.rated_voltage"
:options="voltageOptions"
label="额定电压等级 (kV)"
/>
</div>
<div class="col-12 col-md-6">
<q-input
:model-value="currentType"
label="电压类型 (AC/DC)"
readonly
>
<q-tooltip>交流(AC)或直流(DC)由电压等级自动判断</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.h_c_sag"
type="number"
step="0.01"
label="导线弧垂 (m)"
>
<q-tooltip>导线在最大弧垂状态下相对于挂点的垂直距离</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.h_g_sag"
type="number"
step="0.01"
label="地线弧垂 (m)"
>
<q-tooltip>地线在最大弧垂状态下相对于挂点的垂直距离</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.insulator_c_len"
type="number"
step="0.01"
label="导线串子绝缘长度 (m)"
>
<q-tooltip>绝缘子串的总长度</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.string_c_len"
type="number"
step="0.1"
label="导线串长 (m)"
>
<q-tooltip>导线绝缘子串的总长度</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.string_g_len"
type="number"
step="0.1"
label="地线串长 (m)"
>
<q-tooltip>地线绝缘子串的总长度</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.altitude"
type="number"
label="海拔高度 (m)"
>
<q-tooltip>用于修正绝缘子串的闪络电压</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.td"
type="number"
label="雷暴日 (d)"
>
<q-tooltip>一年中雷暴天数用于计算地闪密度</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.z_0"
type="number"
label="雷电波阻抗 (Ω)"
>
<q-tooltip>雷电波阻抗用于计算最小雷电流</q-tooltip>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.z_c"
type="number"
label="导线波阻抗 (Ω)"
>
<q-tooltip>导线波阻抗用于计算最小雷电流</q-tooltip>
</q-input>
</div>
</div>
<!-- 地线挂点高度 -->
<div class="q-mt-md">
<div class="text-subtitle2 q-mb-sm">
地线挂点垂直坐标 (m)
</div>
<div class="row q-col-gutter-sm">
<div class="col" v-for="(_, index) in params.parameter.h_arm" :key="index">
<q-input
v-model="params.parameter.h_arm[index]"
type="number"
step="0.1"
:label="index === 0 ? '地线' : `导线 ${index}`"
dense
/>
</div>
<div class="col-auto">
<q-btn flat round color="primary" icon="add" @click="addHArm" :disable="params.parameter.h_arm.length >= 4" v-show="params.parameter.h_arm.length === 2" />
</div>
<div class="col-auto">
<q-btn flat round color="negative" icon="remove" @click="removeHArm" :disable="params.parameter.h_arm.length <= 2" v-show="params.parameter.h_arm.length === 4" />
</div>
</div>
</div>
<!-- 地线水平坐标 -->
<div class="q-mt-md">
<div class="text-subtitle2 q-mb-sm">
地线挂点水平坐标 (m)
</div>
<div class="row q-col-gutter-sm">
<div class="col" v-for="(_, index) in params.parameter.gc_x" :key="index">
<q-input
v-model="params.parameter.gc_x[index]"
type="number"
step="0.1"
:label="index === 0 ? '地线' : `导线 ${index}`"
dense
/>
</div>
<div class="col-auto">
<q-btn flat round color="primary" icon="add" @click="addGcX" :disable="params.parameter.gc_x.length >= 4" v-show="params.parameter.gc_x.length === 2" />
</div>
<div class="col-auto">
<q-btn flat round color="negative" icon="remove" @click="removeGcX" :disable="params.parameter.gc_x.length <= 2" v-show="params.parameter.gc_x.length === 4" />
</div>
</div>
</div>
<!-- 地面倾角 -->
<div class="q-mt-md">
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-6">
<q-input
v-model="params.parameter.ground_angels[0]"
type="number"
step="1"
label="地面倾角 (°) - 向下为正"
>
<q-tooltip>地面倾斜角度向下为正值</q-tooltip>
</q-input>
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- 右侧杆塔几何结构可视化 -->
<div class="col-12 col-lg-6" style="display: flex;">
<Geometry
:h-arm="params.parameter.h_arm"
:gc-x="params.parameter.gc_x"
:h-c-sag="params.parameter.h_c_sag"
:h-g-sag="params.parameter.h_g_sag"
:string-c-len="params.parameter.string_c_len"
:string-g-len="params.parameter.string_g_len"
:ground-angels="params.parameter.ground_angels"
class="shadow-2"
style="flex: 1;"
/>
</div>
</div>
<!-- 高级参数 -->
<q-card class="q-mb-md shadow-2">
<q-card-section class="bg-indigo-50">
<div class="text-h6 text-indigo-900 flex items-center gap-2">
<q-icon name="tune" />
高级参数
</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-input
v-model="params.advance.ng"
type="number"
step="0.01"
label="地闪密度 (次/(km²·a))"
>
<q-tooltip>每平方公里每年的地闪次数默认-1表示自动计算</q-tooltip>
</q-input>
</div>
</div>
<!-- 雷电流概率密度系数设置开关 -->
<div class="q-mt-md">
<q-toggle
v-model="showIpCoefficients"
label="设置雷电流概率密度系数"
color="primary"
/>
</div>
<div class="row q-col-gutter-md q-mt-sm" v-if="showIpCoefficients">
<div class="col-6">
<q-input
v-model="params.advance.Ip_a"
type="number"
step="0.01"
label="雷电流概率密度曲线系数 a"
dense
>
<q-tooltip>雷电流幅值概率密度函数参数默认-1表示使用标准参数</q-tooltip>
</q-input>
</div>
<div class="col-6">
<q-input
v-model="params.advance.Ip_b"
type="number"
step="0.01"
label="雷电流概率密度曲线系数 b"
dense
>
<q-tooltip>雷电流幅值概率密度函数参数默认-1表示使用标准参数</q-tooltip>
</q-input>
</div>
</div>
<!-- 50%击穿电压设置开关 -->
<div class="q-mt-md">
<q-toggle
v-model="showU50"
label="设置50%击穿电压 (U_50)"
color="primary"
/>
</div>
<div class="row q-col-gutter-md q-mt-sm" v-if="showU50">
<div class="col-12">
<q-input
v-model="params.advance.u_50"
type="number"
step="1"
label="50%击穿电压 U_50 (kV)"
>
<q-tooltip>自定义50%击穿电压值默认-1表示使用公式计算</q-tooltip>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
<!-- 可选参数 -->
<q-card class="q-mb-md shadow-2">
<q-card-section class="bg-indigo-50">
<div class="text-h6 text-indigo-900 flex items-center gap-2">
<q-icon name="more_horiz" />
可选参数
</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
v-model="params.optional.max_i"
type="number"
label="最大尝试雷电流 (kA)"
>
<q-tooltip>计算时尝试的最大雷电流幅值</q-tooltip>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
<!-- 操作按钮 -->
<div class="row q-gutter-md justify-center q-mt-lg">
<q-btn
color="primary"
size="lg"
label="开始计算"
icon="calculate"
@click="calculate"
:loading="calculating"
class="px-8"
/>
<q-btn
color="orange"
size="lg"
label="导入配置"
icon="upload"
@click="importConfig"
class="px-8"
/>
<q-btn
color="positive"
size="lg"
label="导出配置"
icon="download"
@click="exportConfig"
class="px-8"
/>
<q-btn
color="grey"
size="lg"
label="导出日志"
icon="description"
@click="exportLog"
class="px-8"
/>
</div>
<!-- 隐藏的文件输入开发模式备用 -->
<input
ref="fileInput"
type="file"
accept=".toml"
style="display: none"
@change="handleFileSelect"
/>
<!-- 错误信息 -->
<q-card v-if="error" class="q-mt-md shadow-2 bg-red-50">
<q-card-section>
<div class="text-negative q-mb-sm flex items-center gap-2">
<q-icon name="error" />
错误信息
</div>
<p class="text-negative">{{ error }}</p>
</q-card-section>
</q-card>
<!-- 计算结果 -->
<q-card v-if="result" class="q-mt-md shadow-2 bg-green-50">
<q-card-section class="bg-green-100">
<div class="text-h6 text-green-900 flex items-center gap-2">
<q-icon name="check_circle" />
计算结果
</div>
</q-card-section>
<q-card-section>
<div class="text-body1 result-text">
<div class="q-mb-sm"><strong>跳闸率</strong>{{ result.tripping_rate.toFixed(4) }} /(100km·a)</div>
<div v-if="result.n_sf_phases && result.n_sf_phases.length > 0" class="q-mb-sm">
<strong>各导线跳闸率</strong>
<span v-for="(rate, index) in result.n_sf_phases" :key="index" class="q-mr-md">
导线{{ index + 1 }}: {{ rate.toFixed(4) }} /(100km·a)
</span>
</div>
<div class="text-green-800 q-mt-md">{{ result.message }}</div>
</div>
</q-card-section>
</q-card>
<!-- 运行日志 -->
<LogComponent ref="logRef" />
<!-- EGM 动画可视化 -->
<Animation ref="animationRef" class="q-mt-md" />
</div>
</q-page>
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch, toRaw } from 'vue'
import type { AllParameters } from '@/types'
import LogComponent from './Log.vue'
import Animation from './Animation.vue'
import Geometry from './Geometry.vue'
// 默认参数
const defaultParams: AllParameters = {
parameter: {
rated_voltage: '500kV',
ac_or_dc: 'AC',
h_c_sag: 14.43,
h_g_sag: 11.67,
insulator_c_len: 7.02,
string_c_len: 9.2,
string_g_len: 0.5,
h_arm: [150, 130],
gc_x: [17.9, 17],
ground_angels: [0],
altitude: 1000,
td: 20,
z_0: 300,
z_c: 251
},
advance: {
ng: -1,
Ip_a: -1,
Ip_b: -1,
u_50: -1
},
optional: {
voltage_n: 1,
max_i: 300
}
}
const params = reactive<AllParameters>(JSON.parse(JSON.stringify(defaultParams)))
const calculating = ref(false)
const result = ref<{ tripping_rate: number; n_sf_phases: number[]; message: string } | null>(null)
const error = ref<string | null>(null)
const logRef = ref<InstanceType<typeof LogComponent> | null>(null)
const animationRef = ref<InstanceType<typeof Animation> | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
// 当前打开的文件路径
const currentFilePath = ref<string>('')
// 雷电流概率密度系数设置开关
const showIpCoefficients = ref(false)
// 50%击穿电压设置开关
const showU50 = ref(false)
const voltageOptions = [
'110kV', '220kV', '330kV', '500kV', '750kV','1000kV',
'±500kV', '±660kV', '±800kV', '±1100kV'
]
// 根据电压等级自动判断交流/直流
const currentType = computed(() => {
return params.parameter.rated_voltage.includes('±') ? 'DC' : 'AC'
})
// 监听电压等级变化,同步更新 ac_or_dc 字段
watch(
() => params.parameter.rated_voltage,
(newVoltage) => {
const isDC = newVoltage.includes('±')
params.parameter.ac_or_dc = isDC ? 'DC' : 'AC'
},
{ immediate: true }
)
// 监听雷电流概率密度系数开关
watch(
showIpCoefficients,
(show) => {
if (!show) {
// 关闭时重置为 -1使用标准参数
params.advance.Ip_a = -1
params.advance.Ip_b = -1
}
}
)
// 监听50%击穿电压开关
watch(
showU50,
(show) => {
if (!show) {
// 关闭时重置为 -1使用公式计算
params.advance.u_50 = -1
}
}
)
// 雷暴日与地闪密度相互转换公式ng = 0.023 * td^3
// 标志位避免循环更新
let isUpdatingFromWatch = false
watch(
() => params.advance.ng,
(newNg) => {
if (isUpdatingFromWatch) return
const ng = Number(newNg)
if (ng > 0) {
isUpdatingFromWatch = true
// td = (ng / 0.023)^(1/1.3)
params.parameter.td = Math.round(Math.pow(ng / 0.023, 1/1.3) * 100) / 100
isUpdatingFromWatch = false
}
}
)
watch(
() => params.parameter.td,
(newTd) => {
if (isUpdatingFromWatch) return
const td = Number(newTd)
if (td > 0) {
isUpdatingFromWatch = true
// ng = 0.023 * td^1.3
params.advance.ng = Math.round(0.023 * Math.pow(td, 1.3) * 100) / 100
isUpdatingFromWatch = false
}
}
)
// 数组操作函数导线数量只能是1或3条即数组长度为2或41地线+导线)
// 两个数组同步增减
const addHArm = () => {
if (params.parameter.h_arm.length === 2) {
// 从1条导线增加到3条导线添加2个元素
const last = params.parameter.h_arm[params.parameter.h_arm.length - 1] || 100
params.parameter.h_arm.push(last - 20, last - 40)
// 同步增加 gc_x
const lastX = params.parameter.gc_x[params.parameter.gc_x.length - 1] || 10
params.parameter.gc_x.push(lastX, lastX)
}
}
const removeHArm = () => {
if (params.parameter.h_arm.length === 4) {
// 从3条导线减少到1条导线移除2个元素
params.parameter.h_arm.pop()
params.parameter.h_arm.pop()
// 同步删除 gc_x
params.parameter.gc_x.pop()
params.parameter.gc_x.pop()
}
}
const addGcX = () => {
if (params.parameter.gc_x.length === 2) {
// 从1条导线增加到3条导线添加2个元素
const lastX = params.parameter.gc_x[params.parameter.gc_x.length - 1] || 10
params.parameter.gc_x.push(lastX, lastX)
// 同步增加 h_arm
const last = params.parameter.h_arm[params.parameter.h_arm.length - 1] || 100
params.parameter.h_arm.push(last - 20, last - 40)
}
}
const removeGcX = () => {
if (params.parameter.gc_x.length === 4) {
// 从3条导线减少到1条导线移除2个元素
params.parameter.gc_x.pop()
params.parameter.gc_x.pop()
// 同步删除 h_arm
params.parameter.h_arm.pop()
params.parameter.h_arm.pop()
}
}
// 计算函数
const calculate = async () => {
// 验证雷电流概率密度系数
if (showIpCoefficients.value) {
const ipA = Number(params.advance.Ip_a)
const ipB = Number(params.advance.Ip_b)
if (ipA < 0 && ipB < 0) {
error.value = '请检查参数:"雷电流概率密度曲线系数 a"和"雷电流概率密度曲线系数 b"的值应该大于 0'
logRef.value?.addLog('error', '请检查参数:"雷电流概率密度曲线系数 a"和"雷电流概率密度曲线系数 b"的值应该大于 0')
return
}
}
// 验证50%击穿电压
if (showU50.value) {
const u50 = Number(params.advance.u_50)
if (u50 < 1000) {
error.value = '请检查参数:"50%击穿电压 U_50"的值应该大于等于 1000 kV'
logRef.value?.addLog('error', '请检查参数:"50%击穿电压 U_50"的值应该大于等于 1000 kV')
return
}
}
// 验证导、地线挂点垂直坐标顺序:地线 > 导线1 > 导线2 > 导线3
const hArm = params.parameter.h_arm.map(Number)
for (let i = 0; i < hArm.length - 1; i++) {
if (hArm[i] <= hArm[i + 1]) {
const labels = ['地线', '导线1', '导线2', '导线3']
error.value = `请检查参数:${labels[i]}垂直坐标应大于${labels[i + 1]}垂直坐标`
logRef.value?.addLog('error', error.value)
return
}
}
calculating.value = true
result.value = null
error.value = null
try {
// 调用 pywebview 的 Python 函数
if (window.pywebview) {
// 后台线程启动计算,实时日志通过 addLogFromBackend 推送
// 结果通过 receiveResult 回调接收
// 传递动画启用状态
// 使用 toRaw 解包响应式对象,确保 pywebview 能正确序列化参数
const rawParams = toRaw(params)
const paramsWithAnimation = {
parameter: toRaw(rawParams.parameter),
advance: toRaw(rawParams.advance),
optional: {
...toRaw(rawParams.optional),
voltage_n: 1 // 强制将电压份数设置为1
},
animation_enabled: animationRef.value?.isEnabled() ?? false
}
await window.pywebview.api.calculate(paramsWithAnimation)
// 不在这里设置 calculating = false等待 receiveResult 回调
} else {
// 开发模式下的模拟
await new Promise(resolve => setTimeout(resolve, 1000))
logRef.value?.addLog('info', '开始 EGM 计算(开发模式)...')
logRef.value?.addLog('info', '参数: 额定电压=750kV, 雷暴日=20d, 海拔=1000m')
logRef.value?.addLog('info', '计算完成')
result.value = {
tripping_rate: 0.0581,
n_sf_phases: [0.0421, 0.0581, 0.0392],
message: '计算完成'
}
calculating.value = false
}
} catch (e: any) {
error.value = e.message || '计算失败'
logRef.value?.addLog('error', e.message || '计算失败')
calculating.value = false
}
}
// 将参数转换为 TOML 格式
const tomlStringify = (obj: any, indent: string = ''): string => {
let result = ''
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) continue
if (Array.isArray(value)) {
result += `${indent}${key} = [${value.join(', ')}]\n`
} else if (typeof value === 'object') {
result += `\n${indent}[${key}]\n`
result += tomlStringify(value, indent)
} else if (typeof value === 'string') {
result += `${indent}${key} = "${value}"\n`
} else if (typeof value === 'boolean') {
result += `${indent}${key} = ${value}\n`
} else {
result += `${indent}${key} = ${value}\n`
}
}
return result
}
// 解析 TOML 格式字符串
const parseToml = (tomlStr: string): any => {
const result: any = {}
let currentSection: any = result
let currentSectionName = ''
const lines = tomlStr.split('\n')
for (let line of lines) {
line = line.trim()
// 跳过空行和注释
if (!line || line.startsWith('#')) continue
// 匹配 section [xxx]
const sectionMatch = line.match(/^\[([^\]]+)\]$/)
if (sectionMatch) {
currentSectionName = sectionMatch[1]
currentSection = {}
result[currentSectionName] = currentSection
continue
}
// 匹配 key = value
const kvMatch = line.match(/^([^=]+)=(.*)$/)
if (kvMatch) {
const key = kvMatch[1].trim()
let value: any = kvMatch[2].trim()
// 解析数组 [1, 2, 3]
if (value.startsWith('[') && value.endsWith(']')) {
const arrStr = value.slice(1, -1).trim()
if (arrStr) {
value = arrStr.split(',').map((s: string) => {
s = s.trim()
if (s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, -1)
}
return isNaN(Number(s)) ? s : Number(s)
})
} else {
value = []
}
}
// 解析字符串 "xxx"
else if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1)
}
// 解析布尔值
else if (value === 'true') {
value = true
} else if (value === 'false') {
value = false
}
// 解析数字
else if (!isNaN(Number(value))) {
value = Number(value)
}
currentSection[key] = value
}
}
return result
}
// 导出日志
const exportLog = async () => {
try {
const logText = logRef.value?.getLogsText() || ''
if (!logText) {
logRef.value?.addLog('warning', '暂无日志可导出')
return
}
if (window.pywebview) {
const response = await window.pywebview.api.export_log(logText)
if (response.success) {
logRef.value?.addLog('info', response.message)
} else {
logRef.value?.addLog('warning', response.message)
}
} else {
// 开发模式下的模拟
logRef.value?.addLog('info', '导出日志(开发模式,直接下载)')
const blob = new Blob([logText], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')
a.download = `egm_log_${timestamp}.txt`
a.click()
URL.revokeObjectURL(url)
}
} catch (e: any) {
error.value = e.message || '导出日志失败'
logRef.value?.addLog('error', e.message || '导出日志失败')
}
}
// 导出配置
const exportConfig = async () => {
try {
if (window.pywebview) {
const response = await window.pywebview.api.export_config(params)
if (response.success) {
// 显示导出的文件路径
currentFilePath.value = response.file_path || ''
logRef.value?.addLog('info', response.message)
} else {
logRef.value?.addLog('warning', response.message)
}
} else {
// 开发模式下的模拟
logRef.value?.addLog('info', '导出配置(开发模式,直接下载)')
const config = tomlStringify(params)
const blob = new Blob([config], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')
a.download = `egm_config_${timestamp}.toml`
a.click()
URL.revokeObjectURL(url)
}
} catch (e: any) {
error.value = e.message || '导出失败'
logRef.value?.addLog('error', e.message || '导出失败')
}
}
// 导入配置 - 调用后端文件对话框
const importConfig = async () => {
try {
if (window.pywebview) {
const response = await window.pywebview.api.import_config()
if (response.success && response.params) {
// 合并导入的参数到当前参数
if (response.params.parameter) {
Object.assign(params.parameter, response.params.parameter)
}
if (response.params.advance) {
Object.assign(params.advance, response.params.advance)
}
if (response.params.optional) {
Object.assign(params.optional, response.params.optional)
}
// 显示完整文件路径
currentFilePath.value = response.file_path || ''
logRef.value?.addLog('info', `成功导入配置: ${response.file_path}`)
result.value = null
error.value = null
} else if (!response.success && response.message !== '用户取消了选择') {
error.value = response.message || '导入失败'
logRef.value?.addLog('error', response.message || '导入失败')
}
} else {
// 开发模式下使用 HTML 文件输入
fileInput.value?.click()
}
} catch (e: any) {
error.value = e.message || '导入失败'
logRef.value?.addLog('error', e.message || '导入失败')
}
}
// 处理文件选择(开发模式备用)
const handleFileSelect = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const content = await file.text()
const importedParams = parseToml(content)
// 合并导入的参数到当前参数
if (importedParams.parameter) {
Object.assign(params.parameter, importedParams.parameter)
}
if (importedParams.advance) {
Object.assign(params.advance, importedParams.advance)
}
if (importedParams.optional) {
Object.assign(params.optional, importedParams.optional)
}
currentFilePath.value = file.name
logRef.value?.addLog('info', `成功导入配置: ${file.name}`)
result.value = null
error.value = null
} catch (e: any) {
error.value = e.message || '导入失败'
logRef.value?.addLog('error', e.message || '导入失败')
}
// 清空 input 以便可以重复选择同一个文件
input.value = ''
}
// 声明 pywebview API 类型
declare global {
interface Window {
pywebview?: {
api: {
calculate: (params: AllParameters) => Promise<any>
import_config: () => Promise<any>
export_config: (params: AllParameters) => Promise<any>
export_log: (logText: string) => Promise<any>
}
}
addLogFromBackend?: (log: { level: string; time: string; message: string }) => void
receiveResult?: (result: { success: boolean; message: string; data?: any; error?: string }) => void
}
}
// 禁用数字输入框的滚轮调整功能
const preventWheelOnNumberInput = (e: Event) => {
const target = e.target as HTMLInputElement
if (target && target.type === 'number') {
e.preventDefault()
;(target as HTMLElement).blur()
}
}
// 注册全局日志接收函数,供后端实时调用
onMounted(() => {
// 程序启动时,根据雷暴日初始化地闪密度
if (params.parameter.td > 0 && params.advance.ng < 0) {
params.advance.ng = Math.round(0.023 * Math.pow(params.parameter.td, 1.3) * 100) / 100
}
// 禁用数字输入框的滚轮调整
document.addEventListener('wheel', preventWheelOnNumberInput, { passive: false })
// 实时日志推送
window.addLogFromBackend = (log: { level: string; time: string; message: string }) => {
logRef.value?.addLog(log.level as any, log.message)
}
// 接收计算结果
window.receiveResult = (res: { success: boolean; message: string; data?: any; error?: string }) => {
calculating.value = false
if (res.success && res.data) {
result.value = {
tripping_rate: res.data.avr_n_sf || 0,
n_sf_phases: res.data.n_sf_phases || [],
message: res.message || '计算完成'
}
logRef.value?.addLog('info', '计算完成')
} else {
error.value = res.error || res.message
}
}
})
onUnmounted(() => {
document.removeEventListener('wheel', preventWheelOnNumberInput)
window.addLogFromBackend = undefined
window.receiveResult = undefined
})
</script>
<style scoped>
.result-text {
user-select: text;
cursor: text;
}
/* 隐藏数字输入框的上下箭头 */
:deep(input[type="number"]) {
-moz-appearance: textfield;
}
:deep(input[type="number"]::-webkit-inner-spin-button),
:deep(input[type="number"]::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
</style>

14
webui/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { Quasar } from 'quasar'
import '@quasar/extras/material-icons/material-icons.css'
import 'quasar/src/css/index.sass'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(Quasar, {
plugins: {}
})
app.mount('#app')

View File

@@ -0,0 +1,9 @@
// Quasar SCSS Variables
$primary : #1976D2
$secondary : #26A69A
$accent : #9C27B0
$dark : #1D1D1D
$positive : #21BA45
$negative : #C10015
$info : #31CCEC
$warning : #F2C037

19
webui/src/style.css Normal file
View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100vh;
overflow: auto;
}

44
webui/src/types/index.ts Normal file
View File

@@ -0,0 +1,44 @@
// EGM 计算参数类型定义
// Version: 1.0.11
export interface Parameter {
// 基本参数
rated_voltage: string // 额定电压等级 (kV)
ac_or_dc: string // 交流或直流标识,"AC" 或 "DC"
h_c_sag: number // 导线弧垂 (m)
h_g_sag: number // 地线弧垂 (m)
insulator_c_len: number // 导线串子绝缘长度 (m)
string_c_len: number // 导线串长 (m)
string_g_len: number // 地线串长 (m)
h_arm: number[] // 导、地线挂点垂直距离 (m)
gc_x: number[] // 导、地线水平坐标 (m)
ground_angels: number[] // 地面倾角 (°)
altitude: number // 海拔高度 (m)
td: number // 雷暴日 (d)
z_0: number // 雷电波阻抗 (Ω),默认 300
z_c: number // 导线波阻抗 (Ω),默认 251
}
export interface AdvanceParameter {
ng: number // 地闪密度 (次/(km²·a))
Ip_a: number // 雷电流概率密度曲线系数a
Ip_b: number // 雷电流概率密度曲线系数b
u_50: number // 50%击穿电压 (kV)-1表示自动计算
}
export interface OptionalParameter {
voltage_n: number // 计算时电压分成多少份
max_i: number // 最大尝试雷电流 (kA)
}
export interface AllParameters {
parameter: Parameter
advance: AdvanceParameter
optional: OptionalParameter
}
export interface CalculationResult {
success: boolean
message?: string
data?: any
}

3
webui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string

11
webui/tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

25
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

1
webui/tsconfig.node.json Normal file
View File

@@ -0,0 +1 @@
{"compilerOptions": {"composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true}, "include": ["vite.config.ts"]}

38
webui/vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
import { fileURLToPath, URL } from 'node:url'
import { readFileSync } from 'node:fs'
const version = readFileSync(fileURLToPath(new URL('../VERSION', import.meta.url)), 'utf-8').trim()
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(version)
},
base: './',
plugins: [
vue({
template: { transformAssetUrls }
}),
quasar(),
{
name: 'html-version',
transformIndexHtml(html) {
return html.replace(
/<title>EGM 输电线路绕击跳闸率计算( v[\d.]+)?<\/title>/,
`<title>EGM 输电线路绕击跳闸率计算 v${version}</title>`
)
}
}
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
host: true
}
})

662
webview_app.py Normal file
View File

@@ -0,0 +1,662 @@
"""
EGM 输电线路绕击跳闸率计算程序 - Pywebview 界面
使用 Vue 3 + Quasar + TypeScript + Tailwind CSS 作为前端
"""
import os
import sys
import json
import math
import threading
import queue
import tomllib
from pathlib import Path
from typing import Dict, Any, List
from datetime import datetime
import webview
from loguru import logger
# 添加项目根目录到路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from core import Parameter
from main import parameter_display, run_egm
class WebAnimation:
"""
Web 动画类,将 Python 端的 Animation 调用映射到前端 JavaScript
对应 Animation.vue 的功能
注意:动画的启用/禁用由前端用户通过"启用动画"开关控制,
后端只负责发送绘制指令,前端根据 enabled 状态决定是否执行
"""
def __init__(self, window=None):
self._window = window
def set_window(self, window):
"""设置窗口对象"""
self._window = window
def enable(self, enabled: bool):
"""
启用/禁用动画(由前端用户控制)
此方法保留以兼容接口,但实际启用状态由前端控制
"""
if self._window:
js_code = f'if(window.animationApi){{window.animationApi.enable({str(enabled).lower()})}}'
self._window.evaluate_js(js_code)
def init_fig(self):
"""初始化画布"""
if not self._window:
return
js_code = 'if(window.animationApi){window.animationApi.initFig()}'
self._window.evaluate_js(js_code)
def add_rs(self, rs: float, rs_x: float, rs_y: float):
"""
添加地线保护弧RS 圆)
对应 animation.py 的 add_rs 方法
"""
if not self._window:
return
js_code = f'if(window.animationApi){{window.animationApi.addRs({rs}, {rs_x}, {rs_y})}}'
self._window.evaluate_js(js_code)
def add_rc(self, rc: float, rc_x: float, rc_y: float):
"""
添加导线暴露弧RC 圆)
对应 animation.py 的 add_rc 方法
"""
if not self._window:
return
js_code = f'if(window.animationApi){{window.animationApi.addRc({rc}, {rc_x}, {rc_y})}}'
self._window.evaluate_js(js_code)
def add_rg_line(self, line_func):
"""
添加地面线RG 线)
对应 animation.py 的 add_rg_line 方法
Args:
line_func: 一个函数,接收 x 返回 y
"""
if not self._window:
return
# 生成线上的点,传递给前端
# 由于无法直接传递函数,我们预先计算一些点
import numpy as np
x_points = np.linspace(0, 300, 50)
y_points = [line_func(x) for x in x_points]
points = list(zip(x_points.tolist(), y_points))
js_code = f'''
if(window.animationApi){{
window.animationApi.addRgLine({json.dumps(points)})
}}
'''
self._window.evaluate_js(js_code)
def add_expose_area(
self,
rc_x: float,
rc_y: float,
intersection_x1: float,
intersection_y1: float,
intersection_x2: float,
intersection_y2: float
):
"""
添加暴露弧区域(两条红线)
对应 animation.py 的 add_expose_area 方法
"""
if not self._window:
return
js_code = f'''if(window.animationApi){{
window.animationApi.addExposeArea(
{rc_x}, {rc_y},
{intersection_x1}, {intersection_y1},
{intersection_x2}, {intersection_y2}
)
}}'''
self._window.evaluate_js(js_code)
def clear(self):
"""清除画布"""
if not self._window:
return
js_code = 'if(window.animationApi){window.animationApi.clear()}'
self._window.evaluate_js(js_code)
def pause(self):
"""
暂停并刷新
对应 animation.py 的 pause 方法
"""
if not self._window:
return
js_code = 'if(window.animationApi){window.animationApi.pause()}'
self._window.evaluate_js(js_code)
# 添加延迟以便动画可见
import time
time.sleep(0.1) # 增加延迟,让用户看清动画
class WebHandler:
"""Web日志处理器"""
def __init__(self, callback=None):
self.callback = callback
self.logs: List[Dict[str, str]] = []
def write(self, message):
if message.strip():
log_entry = {
"level": "info",
"time": datetime.now().strftime("%H:%M:%S"),
"message": message.strip()
}
self.logs.append(log_entry)
if self.callback:
self.callback(log_entry)
class LoguruWebHandler:
"""Loguru 日志处理器,将 loguru 日志转发到 Web 界面"""
def __init__(self, log_queue: queue.Queue):
self.log_queue = log_queue
def write(self, message):
"""loguru handler 的写入方法"""
record = message.record
level = record['level'].name.lower()
# 映射 loguru 级别到前端级别
level_map = {
'trace': 'debug',
'debug': 'debug',
'info': 'info',
'success': 'info',
'warning': 'warning',
'error': 'error',
'critical': 'error'
}
frontend_level = level_map.get(level, 'info')
# 提取消息文本
msg = record['message']
if msg.strip():
log_entry = {
"level": frontend_level,
"time": datetime.now().strftime("%H:%M:%S"),
"message": msg
}
# 将日志放入队列,由主线程处理
self.log_queue.put(log_entry)
class EGMWebApp:
"""EGM 计算程序的 Web 界面后端"""
def __init__(self):
self.window = None
self.web_handler = None
self.logs: List[Dict[str, str]] = []
self._loguru_handler_id = None
self._log_queue: queue.Queue = queue.Queue()
self._running = False
self.animation = WebAnimation() # Web 动画实例
def _process_log_queue(self):
"""处理日志队列,在主线程中定时调用"""
if not self._running:
return
try:
# 处理队列中的所有日志
while not self._log_queue.empty():
try:
log_entry = self._log_queue.get_nowait()
self._push_log_to_frontend(log_entry)
except queue.Empty:
break
except Exception as e:
print(f"处理日志队列失败: {e}")
# 继续定时检查
if self._running:
threading.Timer(0.05, self._process_log_queue).start()
def _push_log_to_frontend(self, log_entry: Dict[str, str]):
"""推送单条日志到前端"""
if self.window:
try:
js_code = f'if(window.addLogFromBackend){{window.addLogFromBackend({json.dumps(log_entry)})}}'
self.window.evaluate_js(js_code)
except Exception as e:
print(f"推送日志到前端失败: {e}")
def add_log(self, level: str, message: str):
"""添加日志并实时推送到前端"""
log_entry = {
"level": level,
"time": datetime.now().strftime("%H:%M:%S"),
"message": message
}
self.logs.append(log_entry)
# 将日志放入队列,由主线程处理
self._log_queue.put(log_entry)
def get_logs(self) -> List[Dict[str, str]]:
"""获取日志列表"""
logs = self.logs.copy()
self.logs = []
return logs
def _setup_loguru_handler(self):
"""设置 loguru 处理器,捕获所有 logger 调用"""
self._loguru_handler_id = logger.add(
LoguruWebHandler(self._log_queue),
format="{message}",
level="DEBUG"
)
def _remove_loguru_handler(self):
"""移除 loguru 处理器"""
if self._loguru_handler_id is not None:
logger.remove(self._loguru_handler_id)
self._loguru_handler_id = None
def calculate(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
执行 EGM 计算(启动后台线程,立即返回)
Args:
params: 包含 parameter, advance, optional 的字典
Returns:
计算状态字典
"""
self.logs = [] # 清空日志
self._log_queue = queue.Queue() # 重置队列
# 启动日志队列处理器
self._running = True
self._process_log_queue()
# 启动后台线程执行计算
thread = threading.Thread(target=self._calculate_thread, args=(params,))
thread.daemon = True
thread.start()
return {"status": "started", "message": "计算已启动"}
def _calculate_thread(self, params: Dict[str, Any]):
"""后台线程中执行计算"""
# 设置 loguru 处理器,捕获所有 logger.info/debug 等调用
self._setup_loguru_handler()
self.add_log("info", "开始 EGM 计算...")
try:
# 解析参数
parameter_data = params.get('parameter', {})
advance_data = params.get('advance', {})
optional_data = params.get('optional', {})
# 创建局部参数对象
para = Parameter()
para.h_g_sag = float(parameter_data.get('h_g_sag', 11.67))
para.h_c_sag = float(parameter_data.get('h_c_sag', 14.43))
para.td = int(parameter_data.get('td', 20))
para.insulator_c_len = float(parameter_data.get('insulator_c_len', 7.02))
para.string_c_len = float(parameter_data.get('string_c_len', 9.2))
para.string_g_len = float(parameter_data.get('string_g_len', 0.5))
# 确保数组元素转换为数字类型
para.gc_x = [float(x) for x in parameter_data.get('gc_x', [17.9, 17])]
para.ground_angels = [
float(angel) / 180 * math.pi
for angel in parameter_data.get('ground_angels', [0])
]
para.h_arm = [float(h) for h in parameter_data.get('h_arm', [150, 130])]
para.altitude = int(parameter_data.get('altitude', 1000))
# 解析电压等级字符串,如 "500kV" -> 500
rated_voltage_str = str(parameter_data.get('rated_voltage', '500kV'))
para.rated_voltage = float(rated_voltage_str.replace('kV', '').replace('±', ''))
para.ng = float(advance_data.get('ng', -1))
para.Ip_a = float(advance_data.get('Ip_a', -1))
para.Ip_b = float(advance_data.get('Ip_b', -1))
para.u_50 = float(advance_data.get('u_50', -1))
para.voltage_n = int(optional_data.get('voltage_n', 3))
para.max_i = float(optional_data.get('max_i', 200))
para.z_0 = float(parameter_data.get('z_0', 300))
para.z_c = float(parameter_data.get('z_c', 251))
# 设置 ac_or_dc 参数
ac_or_dc_value = str(parameter_data.get('ac_or_dc', 'AC'))
para.ac_or_dc = 'DC' if 'DC' in ac_or_dc_value.upper() else 'AC'
# 调用 main.py 的参数显示函数,日志会被 loguru handler 捕获
# parameter_display(para)
logger.info("开始执行 EGM 计算...")
# 根据前端动画启用状态决定是否传递 animation 对象
animation_enabled = params.get('animation_enabled', False)
animation_obj = self.animation if animation_enabled else None
# 调用 main.py 的核心计算函数
result = run_egm(para, animation_obj)
self.add_log("info", "EGM 计算完成")
# 推送结果到前端
self._send_result_to_frontend(result)
# 等待队列中的日志处理完毕
import time
time.sleep(0.1)
while not self._log_queue.empty():
time.sleep(0.05)
# 停止日志队列处理器
self._running = False
# 移除 loguru 处理器
self._remove_loguru_handler()
except Exception as e:
self.add_log("error", f"计算失败: {str(e)}")
import traceback
traceback.print_exc()
# 停止日志队列处理器
self._running = False
# 移除 loguru 处理器
self._remove_loguru_handler()
# 推送错误到前端
self._send_result_to_frontend({
"success": False,
"message": f"计算失败: {str(e)}",
"error": str(e)
})
def _send_result_to_frontend(self, result: Dict[str, Any]):
"""将计算结果推送到前端"""
if self.window:
try:
js_code = f'if(window.receiveResult){{window.receiveResult({json.dumps(result)})}}'
self.window.evaluate_js(js_code)
except Exception as e:
logger.error(f"推送结果到前端失败: {e}")
def dict_to_toml(self, obj: Dict[str, Any], indent: str = '') -> str:
"""
将字典转换为 TOML 格式字符串
Args:
obj: 参数字典
indent: 缩进字符串
Returns:
TOML 格式字符串
"""
result = ''
for key, value in obj.items():
if value is None:
continue
if isinstance(value, list):
result += f'{indent}{key} = [{", ".join(str(v) for v in value)}]\n'
elif isinstance(value, dict):
result += f'\n{indent}[{key}]\n'
result += self.dict_to_toml(value, indent)
elif isinstance(value, str):
result += f'{indent}{key} = "{value}"\n'
elif isinstance(value, bool):
result += f'{indent}{key} = {str(value).lower()}\n'
else:
result += f'{indent}{key} = {value}\n'
return result
def export_config(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
导出配置为 TOML 文件,弹出保存对话框
Args:
params: 参数字典
Returns:
包含保存状态和路径的字典
"""
try:
# 生成默认文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
default_filename = f'egm_config_{timestamp}.toml'
# 打开保存文件对话框
result = self.window.create_file_dialog(
webview.SAVE_DIALOG,
directory='',
save_filename=default_filename,
file_types=('TOML Files (*.toml)', 'All files (*.*)')
)
if result and len(result) > 0:
file_path = result[0]
# 转换为 TOML 格式
toml_content = self.dict_to_toml(params)
# 写入文件
with open(file_path, 'w', encoding='utf-8') as f:
f.write(toml_content)
return {
"success": True,
"message": f"配置已保存到: {file_path}",
"file_path": file_path
}
else:
return {
"success": False,
"message": "用户取消了保存操作"
}
except Exception as e:
logger.error(f"导出配置失败: {str(e)}")
return {
"success": False,
"message": f"保存失败: {str(e)}"
}
def export_log(self, log_text: str) -> Dict[str, Any]:
"""
导出日志为 TXT 文件,弹出保存对话框
Args:
log_text: 日志文本内容
Returns:
包含保存状态和路径的字典
"""
try:
# 生成默认文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
default_filename = f'egm_log_{timestamp}.txt'
# 打开保存文件对话框
result = self.window.create_file_dialog(
webview.SAVE_DIALOG,
directory='',
save_filename=default_filename,
file_types=('Text Files (*.txt)', 'All files (*.*)')
)
if result and len(result) > 0:
file_path = result[0]
# 写入文件
with open(file_path, 'w', encoding='utf-8') as f:
f.write(log_text)
return {
"success": True,
"message": f"日志已保存到: {file_path}",
"file_path": file_path
}
else:
return {
"success": False,
"message": "用户取消了保存操作"
}
except Exception as e:
logger.error(f"导出日志失败: {str(e)}")
return {
"success": False,
"message": f"保存失败: {str(e)}"
}
def import_config(self) -> Dict[str, Any]:
"""
导入配置从 TOML 文件,弹出打开对话框
Returns:
包含解析后的参数和文件路径的字典
"""
try:
# 打开文件选择对话框
result = self.window.create_file_dialog(
webview.OPEN_DIALOG,
directory='',
file_types=('TOML Files (*.toml)', 'All files (*.*)')
)
if result and len(result) > 0:
file_path = result[0]
# 读取并解析 TOML 文件
with open(file_path, 'rb') as f:
toml_data = tomllib.load(f)
return {
"success": True,
"message": f"成功导入配置",
"file_path": file_path,
"params": toml_data
}
else:
return {
"success": False,
"message": "用户取消了选择"
}
except Exception as e:
logger.error(f"导入配置失败: {str(e)}")
return {
"success": False,
"message": f"导入失败: {str(e)}"
}
def get_default_config(self) -> Dict[str, Any]:
"""
获取默认配置
Returns:
默认配置字典
"""
return {
"parameter": {
"rated_voltage": 750,
"h_c_sag": 14.43,
"h_g_sag": 11.67,
"insulator_c_len": 7.02,
"string_c_len": 9.2,
"string_g_len": 0.5,
"h_arm": [150, 130],
"gc_x": [17.9, 17],
"ground_angels": [0],
"altitude": 1000,
"td": 20
},
"advance": {
"ng": -1,
"Ip_a": -1,
"Ip_b": -1
},
"optional": {
"voltage_n": 3,
"max_i": 200
}
}
def start_webview():
"""启动 pywebview 界面"""
# 确定前端 URL
# 在开发环境中使用 Vite 开发服务器
# 在生产环境中使用构建后的文件
# 检查是否在打包环境中运行
if getattr(sys, 'frozen', False):
# 打包环境:强制使用生产模式,禁用调试
dev_mode = False
else:
# 开发环境:通过环境变量控制
dev_mode = os.getenv('EGM_DEV_MODE', 'true').lower() == 'true'
if dev_mode:
# 开发模式:使用 Vite 开发服务器
url = 'http://localhost:5173'
logger.info(f"开发模式:使用 Vite 开发服务器 {url}")
logger.info("请先在 webui 目录中运行: npm install && npm run dev")
else:
# 生产模式:使用构建后的文件
dist_path = project_root / 'webui' / 'dist'
if not dist_path.exists():
logger.error(f"构建目录不存在: {dist_path}")
logger.error("请先运行: cd webui && npm run build")
sys.exit(1)
url = f'file://{dist_path / "index.html"}'
logger.info(f"生产模式:使用构建文件 {url}")
# 创建 API 实例
api = EGMWebApp()
# 创建窗口
window = webview.create_window(
title='EGM 输电线路绕击跳闸率计算',
url=url,
js_api=api,
width=1500,
height=900,
resizable=True,
min_size=(800, 600)
)
# 将窗口对象传递给 API
api.window = window
api.animation.set_window(window)
# 启动
logger.info("启动 EGM Web 界面...")
webview.start(debug=dev_mode)
if __name__ == '__main__':
# 配置日志
logger.remove()
logger.add(sys.stderr, level="INFO")
logger.add("egm_webui.log", rotation="10 MB", retention="7 days")
# 启动界面
start_webview()