Compare commits

...

31 Commits

Author SHA1 Message Date
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
27 changed files with 5702 additions and 52 deletions

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ dist
*.toml
launch.json
settings.json
node_modules
*.log
*.lock
*.pdf

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

219
README.md Normal file
View File

@@ -0,0 +1,219 @@
# EGM - 输电线路绕击跳闸率计算程序
基于电气几何模型Electro-Geometric Model, EGM的架空输电线路雷电防护性能计算工具用于评估输电线路的绕击跳闸率。
## 功能特点
- 支持单回和双回输电线路的绕击跳闸率计算
- 考虑工作电压对雷电击距的影响
- 支持地面倾角参数
- 支持自定义地闪密度和雷电流概率密度曲线
- 支持交流/直流线路计算
- 输出CAD图形DXF格式可视化击距模型
- 提供动画演示模式(可选项)
- **提供图形化界面pywebview**,支持可视化参数配置和计算
## 安装
### 环境要求
- Python >= 3.12
### 安装依赖
```bash
# 使用 uv推荐
uv sync
# 或使用 pip
pip install -r requirements.txt
```
### 依赖包
- ezdxf - DXF文件生成
- loguru - 日志记录
- matplotlib - 数据可视化和动画
- 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.02 # 导线串子绝缘长度 (m)
string_c_len = 9.2 # 导线串长 (m)
string_g_len = 0.5 # 地线串长 (m)
h_arm = [150, 130] # 导、地线挂点垂直距离 (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 # 示例配置文件
├── Makefile # 构建脚本
├── pyproject.toml # 项目配置
├── README.md # 说明文档
├── webui/ # 图形界面前端项目
│ ├── src/
│ │ ├── components/
│ │ │ └── ParameterForm.vue
│ │ ├── types/
│ │ │ └── index.ts
│ │ ├── App.vue
│ │ ├── main.ts
│ │ └── style.css
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── tailwind.config.js
│ ├── postcss.config.js
│ └── index.html
└── CSharp/ # C# 版本实现
```
## 技术支持
程序基于新版大手册公式和 Q/GDW 11452-2015《架空输电线路防雷导则》实现。
## 许可证
请参考项目许可证文件。

View File

@@ -29,8 +29,8 @@ class Animation:
return wrapTheFunction
def disable(self, _disable):
self._disable = _disable
def enable(self, _enable):
self._disable = not _enable
@switch_decorator
def init_fig(self):

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则忽略该项数据。

19
core.py
View File

@@ -18,16 +18,14 @@ 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"
def rg_line_function_factory(_rg, ground_angel): # 返回一个地面捕雷线的直线方程
@@ -187,9 +185,8 @@ 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):
# 海拔修正
altitude = para.altitude
if altitude > 1000:
k_a = math.exp((altitude - 1000) / 8150) # 气隙海拔修正
else:
@@ -218,10 +215,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 +452,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

367
main-batch.py Normal file
View File

@@ -0,0 +1,367 @@
import math
import os.path
import sys
import 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"]
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
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)
i_min = min_i(insulator_c_len, u_ph, para.altitude)
_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.")

102
main.py
View File

@@ -32,7 +32,8 @@ def parameter_display(para_dis: Parameter):
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,6 +51,7 @@ 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
toml_advance = toml_dict["advance"]
para.ng = toml_advance["ng"] # 地闪密度
para.Ip_a = toml_advance["Ip_a"] # 概率密度曲线系数a
@@ -57,21 +59,29 @@ def read_parameter(toml_file_path):
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) -> dict:
"""
执行 EGM 计算的核心函数,可被外部调用。
Args:
para: 参数对象,包含所有计算所需的参数。
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 +101,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)
animate.enable(False)
# animate.show()
for ground_angel in ground_angels:
logger.info(f"地面倾角{ground_angel/math.pi*180:.3f}°")
@@ -107,7 +117,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]
@@ -136,24 +150,27 @@ def egm():
/ math.pi
) # 挂点处保护角
logger.info(f"挂点处保护角{shield_angle_at_avg_height:.3f}°")
logger.debug(f"最低相防护标识{rg_type}")
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
) # 运行相电压
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)
i_min = min_i(insulator_c_len, u_ph, para.altitude)
_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)
@@ -278,7 +295,7 @@ 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()
@@ -353,6 +370,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

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>

3290
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":"^1.8.0"}}

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

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

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

@@ -0,0 +1,7 @@
<template>
<ParameterForm />
</template>
<script setup lang="ts">
import ParameterForm from '@/components/ParameterForm.vue'
</script>

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(true)
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,802 @@
<template>
<q-layout view="lHh lpr lFf">
<q-header elevated class="bg-indigo-600 text-white">
<q-toolbar>
<q-toolbar-title>
<div class="flex items-center gap-2">
<q-icon name="flash_on" size="md" />
<span class="text-xl font-bold">EGM 输电线路绕击跳闸率计算</span>
</div>
</q-toolbar-title>
</q-toolbar>
</q-header>
<q-page-container>
<q-page class="q-pa-md">
<div class="max-w-4xl mx-auto">
<!-- 基本参数 -->
<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="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>
<!-- 地线挂点高度 -->
<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="(h, 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="(x, 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>
<!-- 高级参数 -->
<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>
</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.voltage_n"
type="number"
label="计算时电压分成多少份"
>
<q-tooltip>将电压波形离散化的份数即将交流电压在一个周期内的不同值进行计算</q-tooltip>
</q-input>
</div>
<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" />
</div>
</q-page>
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import type { AllParameters } from '@/types'
import LogComponent from './Log.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
},
advance: {
ng: -1,
Ip_a: -1,
Ip_b: -1
},
optional: {
voltage_n: 3,
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 fileInput = ref<HTMLInputElement | null>(null)
// 雷电流概率密度系数设置开关
const showIpCoefficients = 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 和 voltage_n 字段
watch(
() => params.parameter.rated_voltage,
(newVoltage) => {
const isDC = newVoltage.includes('±')
params.parameter.ac_or_dc = isDC ? 'DC' : 'AC'
// DC 时电压份数为 1AC 时为 3
params.optional.voltage_n = isDC ? 1 : 3
},
{ immediate: true }
)
// 监听雷电流概率密度系数开关
watch(
showIpCoefficients,
(show) => {
if (!show) {
// 关闭时重置为 -1使用标准参数
params.advance.Ip_a = -1
params.advance.Ip_b = -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
}
}
calculating.value = true
result.value = null
error.value = null
try {
// 调用 pywebview 的 Python 函数
if (window.pywebview) {
// 后台线程启动计算,实时日志通过 addLogFromBackend 推送
// 结果通过 receiveResult 回调接收
await window.pywebview.api.calculate(params)
// 不在这里设置 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) {
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 = () => {
fileInput.value?.click()
}
// 处理文件选择
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)
}
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>
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
}
}
// 注册全局日志接收函数,供后端实时调用
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
}
// 实时日志推送
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(() => {
window.addLogFromBackend = undefined
window.receiveResult = undefined
})
</script>
<style scoped>
.result-text {
user-select: text;
cursor: text;
}
</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;
}

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

@@ -0,0 +1,40 @@
// EGM 计算参数类型定义
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)
}
export interface AdvanceParameter {
ng: number // 地闪密度 (次/(km²·a))
Ip_a: number // 雷电流概率密度曲线系数a
Ip_b: number // 雷电流概率密度曲线系数b
}
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
}

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"]}

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

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue({
template: { transformAssetUrls }
}),
quasar()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
host: true
}
})

483
webview_app.py Normal file
View File

@@ -0,0 +1,483 @@
"""
EGM 输电线路绕击跳闸率计算程序 - Pywebview 界面
使用 Vue 3 + Quasar + TypeScript + Tailwind CSS 作为前端
"""
import os
import sys
import json
import math
import threading
import queue
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 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
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 = list(parameter_data.get('gc_x', [17.9, 17]))
para.ground_angels = [
angel / 180 * math.pi
for angel in parameter_data.get('ground_angels', [0])
]
para.h_arm = list(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.voltage_n = int(optional_data.get('voltage_n', 3))
para.max_i = float(optional_data.get('max_i', 200))
# 设置 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 计算...")
# 调用 main.py 的核心计算函数
result = run_egm(para)
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 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 开发服务器
# 在生产环境中使用构建后的文件
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=1200,
height=900,
resizable=True,
min_size=(800, 600)
)
# 将窗口对象传递给 API
api.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()