Compare commits

..

35 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
21 changed files with 1488 additions and 523 deletions

6
.gitignore vendored
View File

@@ -14,4 +14,8 @@ settings.json
node_modules
*.log
*.lock
*.pdf
*.pdf
lightening.ico
metadata.yml
VERSION
生成exe图标.png

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

View File

@@ -18,6 +18,7 @@
### 环境要求
- Python >= 3.12
- Node.js >= 18图形界面开发需要
### 安装依赖
@@ -33,7 +34,6 @@ pip install -r requirements.txt
- ezdxf - DXF文件生成
- loguru - 日志记录
- matplotlib - 数据可视化和动画
- numpy - 数值计算
- tomli - TOML配置文件解析
- pywebview - 图形界面框架
@@ -122,10 +122,10 @@ python webview_app.py
rated_voltage = 750 # 额定电压等级 (kV)
h_c_sag = 14.43 # 导线弧垂 (m)
h_g_sag = 11.67 # 地线弧垂 (m)
insulator_c_len = 7.02 # 导线串子绝缘长度 (m)
insulator_c_len = 7.4 # 导线串子绝缘长度 (m)
string_c_len = 9.2 # 导线串长 (m)
string_g_len = 0.5 # 地线串长 (m)
h_arm = [150, 130] # 导、地线挂点垂直距离 (m),第一个值为地线挂点高度
h_arm = [130, 100] # 导、地线挂点垂直距离 (m),第一个值为地线挂点高度
gc_x = [17.9, 17] # 导、地线水平坐标 (m)
ground_angels = [0] # 地面倾角 (°),向下为正,支持多个角度
altitude = 1000 # 海拔高度 (m)
@@ -141,7 +141,7 @@ Ip_a = -1 # 雷电流概率密度曲线系数a大于0时使用此
Ip_b = -1 # 雷电流概率密度曲线系数b大于0时使用此值
```
注意:当 `ng` > 0 时,不会通过雷暴日计算地闪密度;当 `Ip_a``Ip_b` > 0 时,不会使用默认雷暴日对应的概率密度。
**注意**:当 `ng` > 0 时,不会通过雷暴日计算地闪密度;当 `Ip_a``Ip_b` > 0 时,不会使用默认雷暴日对应的概率密度。
### [optional] - 可选参数
@@ -189,25 +189,28 @@ EGM/
├── animation.py # 动画演示模块
├── webview_app.py # pywebview 图形界面后端
├── article.toml # 示例配置文件
├── default.toml # 默认配置文件
├── Makefile # 构建脚本
├── pyproject.toml # 项目配置
├── README.md # 说明文档
├── webui/ # 图形界面前端项目
│ ├── src/
│ │ ├── components/
│ │ │ ── ParameterForm.vue
│ │ │ ── ParameterForm.vue # 参数表单组件
│ │ │ ├── Animation.vue # 动画可视化组件
│ │ │ └── Log.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# 版本实现
│ │ │ └── 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文件
```
## 技术支持

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 enable(self, _enable):
self._disable = not _enable
@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

14
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
@@ -26,6 +26,9 @@ class Parameter:
Ip_a: float # 概率密度曲线系数a
Ip_b: float # 概率密度曲线系数b
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): # 返回一个地面捕雷线的直线方程
@@ -185,19 +188,20 @@ def solve_circle_line_intersection(
return [_x, _y]
def min_i(string_len, u_ph, altitude: float = 0):
def min_i(string_len, u_ph, altitude: float = 0, z_0: float = 300, z_c: float = 251, u_50: float = None):
# 海拔修正
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

View File

@@ -1,7 +1,7 @@
import math
import os.path
import sys
import tomli
import tomllib as tomli
from loguru import logger
from core import *
import timeit
@@ -49,10 +49,13 @@ def read_parameter(toml_file_path) -> Parameter:
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"]
@@ -152,7 +155,9 @@ def egm():
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)
# 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)

95
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,14 +22,18 @@ 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) -> Parameter:
@@ -52,21 +56,25 @@ def read_parameter(toml_file_path) -> Parameter:
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 run_egm(para: Parameter) -> dict:
def run_egm(para: Parameter, animation=None) -> dict:
"""
执行 EGM 计算的核心函数,可被外部调用。
Args:
para: 参数对象,包含所有计算所需的参数。
animation: 可选的动画对象,用于可视化。需要实现 add_rs, add_rc, add_rg_line, add_expose_area, pause 方法。
Returns:
计算结果字典。
"""
@@ -104,9 +112,9 @@ def run_egm(para: Parameter) -> dict:
ng = func_ng(td, para.ng)
avr_n_sf = 0 # 考虑电压的影响计算的跳闸率
ground_angels = para.ground_angels
# 初始化动画
animate = Animation()
animate.enable(False)
# 动画对象:如果传入了 animation 则使用,否则不启用动画
# 注意:动画的启用由前端用户通过"启用动画"开关控制,后端不主动启用
animate = animation
# animate.show()
for ground_angel in ground_angels:
logger.info(f"地面倾角{ground_angel/math.pi*180:.3f}°")
@@ -137,31 +145,26 @@ def run_egm(para: Parameter) -> dict:
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.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): # 计算不同工作电压下的跳闸率
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 = (
# 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")
@@ -170,7 +173,9 @@ def run_egm(para: Parameter) -> dict:
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, para.altitude)
# 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)
@@ -179,14 +184,17 @@ def run_egm(para: Parameter) -> dict:
): # 雷电流
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
)
@@ -219,12 +227,13 @@ def run_egm(para: Parameter) -> dict:
"上面的导地线无法保护下面的导地线,检查设置参数。"
)
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,
@@ -252,7 +261,8 @@ def run_egm(para: Parameter) -> dict:
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(
(
@@ -298,7 +308,8 @@ def run_egm(para: Parameter) -> dict:
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("无法找到最大电流,可能是杆塔较高。")

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()

130
webui/package-lock.json generated
View File

@@ -21,7 +21,7 @@
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0"
"vue-tsc": "^2.0.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1218,34 +1218,32 @@
}
},
"node_modules/@volar/language-core": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz",
"integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==",
"version": "2.4.15",
"resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
"integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "1.11.1"
"@volar/source-map": "2.4.15"
}
},
"node_modules/@volar/source-map": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz",
"integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==",
"version": "2.4.15",
"resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz",
"integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"muggle-string": "^0.3.1"
}
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz",
"integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==",
"version": "2.4.15",
"resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz",
"integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "1.11.1",
"path-browserify": "^1.0.1"
"@volar/language-core": "2.4.15",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue/compiler-core": {
@@ -1298,22 +1296,32 @@
"@vue/shared": "3.5.29"
}
},
"node_modules/@vue/language-core": {
"version": "1.8.27",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz",
"integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==",
"node_modules/@vue/compiler-vue2": {
"version": "2.7.16",
"resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "~1.11.1",
"@volar/source-map": "~1.11.1",
"@vue/compiler-dom": "^3.3.0",
"@vue/shared": "^3.3.0",
"computeds": "^0.0.1",
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/@vue/language-core": {
"version": "2.2.12",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz",
"integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.15",
"@vue/compiler-dom": "^3.5.0",
"@vue/compiler-vue2": "^2.7.16",
"@vue/shared": "^3.5.0",
"alien-signals": "^1.0.3",
"minimatch": "^9.0.3",
"muggle-string": "^0.3.1",
"path-browserify": "^1.0.1",
"vue-template-compiler": "^2.7.14"
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1"
},
"peerDependencies": {
"typescript": "*"
@@ -1374,6 +1382,13 @@
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"license": "MIT"
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
"integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
"dev": true,
"license": "MIT"
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz",
@@ -1616,13 +1631,6 @@
"node": ">= 6"
}
},
"node_modules/computeds": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz",
"integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
@@ -2033,9 +2041,9 @@
}
},
"node_modules/muggle-string": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz",
"integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==",
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true,
"license": "MIT"
},
@@ -2881,19 +2889,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3235,6 +3230,13 @@
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.29",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.29.tgz",
@@ -3257,33 +3259,21 @@
}
}
},
"node_modules/vue-template-compiler": {
"version": "2.7.16",
"resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
"integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/vue-tsc": {
"version": "1.8.27",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz",
"integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==",
"version": "2.2.12",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/typescript": "~1.11.1",
"@vue/language-core": "1.8.27",
"semver": "^7.5.4"
"@volar/typescript": "2.4.15",
"@vue/language-core": "2.2.12"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": "*"
"typescript": ">=5.0.0"
}
}
}

View File

@@ -1 +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"}}
{"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"}}

View File

@@ -1,7 +1,26 @@
<template>
<ParameterForm />
<div class="app-container">
<ParameterForm />
<div class="version-footer">v{{ appVersion }}</div>
</div>
</template>
<script setup lang="ts">
import ParameterForm from '@/components/ParameterForm.vue'
</script>
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

@@ -39,7 +39,7 @@ interface LogEntry {
const logs = ref<LogEntry[]>([])
const logContainer = ref<HTMLElement | null>(null)
const expanded = ref(true)
const expanded = ref(false) // 默认折叠
const lastTripRates = ref<number[]>([])
const addLog = (level: LogEntry['level'], message: string) => {

View File

@@ -2,180 +2,222 @@
<q-layout view="lHh lpr lFf">
<q-header elevated class="bg-indigo-600 text-white">
<q-toolbar>
<q-toolbar-title>
<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-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>
<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>
<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="(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 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="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 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="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 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>
</div>
</div>
</q-card-section>
</q-card>
</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">
@@ -231,6 +273,26 @@
</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>
@@ -245,15 +307,6 @@
<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"
@@ -304,7 +357,7 @@
/>
</div>
<!-- 隐藏的文件输入 -->
<!-- 隐藏的文件输入开发模式备用 -->
<input
ref="fileInput"
type="file"
@@ -351,6 +404,9 @@
<!-- 运行日志 -->
<LogComponent ref="logRef" />
<!-- EGM 动画可视化 -->
<Animation ref="animationRef" class="q-mt-md" />
</div>
</q-page>
</q-page-container>
@@ -358,9 +414,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
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 = {
@@ -376,15 +434,18 @@ const defaultParams: AllParameters = {
gc_x: [17.9, 17],
ground_angels: [0],
altitude: 1000,
td: 20
td: 20,
z_0: 300,
z_c: 251
},
advance: {
ng: -1,
Ip_a: -1,
Ip_b: -1
Ip_b: -1,
u_50: -1
},
optional: {
voltage_n: 3,
voltage_n: 1,
max_i: 300
}
}
@@ -394,9 +455,14 @@ 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',
@@ -408,14 +474,12 @@ const currentType = computed(() => {
return params.parameter.rated_voltage.includes('±') ? 'DC' : 'AC'
})
// 监听电压等级变化,同步更新 ac_or_dc 和 voltage_n 字段
// 监听电压等级变化,同步更新 ac_or_dc 字段
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 }
)
@@ -432,6 +496,17 @@ watch(
}
)
// 监听50%击穿电压开关
watch(
showU50,
(show) => {
if (!show) {
// 关闭时重置为 -1使用公式计算
params.advance.u_50 = -1
}
}
)
// 雷暴日与地闪密度相互转换公式ng = 0.023 * td^3
// 标志位避免循环更新
let isUpdatingFromWatch = false
@@ -523,6 +598,27 @@ const calculate = async () => {
}
}
// 验证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
@@ -532,7 +628,19 @@ const calculate = async () => {
if (window.pywebview) {
// 后台线程启动计算,实时日志通过 addLogFromBackend 推送
// 结果通过 receiveResult 回调接收
await window.pywebview.api.calculate(params)
// 传递动画启用状态
// 使用 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 {
// 开发模式下的模拟
@@ -684,6 +792,8 @@ const exportConfig = async () => {
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)
@@ -707,12 +817,44 @@ const exportConfig = async () => {
}
}
// 导入配置 - 触发文件选择
const importConfig = () => {
fileInput.value?.click()
// 导入配置 - 调用后端文件对话框
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]
@@ -733,6 +875,8 @@ const handleFileSelect = async (event: Event) => {
Object.assign(params.optional, importedParams.optional)
}
currentFilePath.value = file.name
logRef.value?.addLog('info', `成功导入配置: ${file.name}`)
result.value = null
error.value = null
@@ -751,6 +895,7 @@ declare global {
pywebview?: {
api: {
calculate: (params: AllParameters) => Promise<any>
import_config: () => Promise<any>
export_config: (params: AllParameters) => Promise<any>
export_log: (logText: string) => Promise<any>
}
@@ -760,6 +905,15 @@ declare global {
}
}
// 禁用数字输入框的滚轮调整功能
const preventWheelOnNumberInput = (e: Event) => {
const target = e.target as HTMLInputElement
if (target && target.type === 'number') {
e.preventDefault()
;(target as HTMLElement).blur()
}
}
// 注册全局日志接收函数,供后端实时调用
onMounted(() => {
// 程序启动时,根据雷暴日初始化地闪密度
@@ -767,6 +921,9 @@ onMounted(() => {
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)
@@ -789,6 +946,7 @@ onMounted(() => {
})
onUnmounted(() => {
document.removeEventListener('wheel', preventWheelOnNumberInput)
window.addLogFromBackend = undefined
window.receiveResult = undefined
})
@@ -799,4 +957,15 @@ onUnmounted(() => {
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>

View File

@@ -1,4 +1,5 @@
// EGM 计算参数类型定义
// Version: 1.0.11
export interface Parameter {
// 基本参数
@@ -14,12 +15,15 @@ export interface Parameter {
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 {

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

View File

@@ -2,13 +2,29 @@ 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()
quasar(),
{
name: 'html-version',
transformIndexHtml(html) {
return html.replace(
/<title>EGM 输电线路绕击跳闸率计算( v[\d.]+)?<\/title>/,
`<title>EGM 输电线路绕击跳闸率计算 v${version}</title>`
)
}
}
],
resolve: {
alias: {

View File

@@ -9,6 +9,7 @@ import json
import math
import threading
import queue
import tomllib
from pathlib import Path
from typing import Dict, Any, List
from datetime import datetime
@@ -23,6 +24,127 @@ 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):
@@ -86,6 +208,7 @@ class EGMWebApp:
self._loguru_handler_id = None
self._log_queue: queue.Queue = queue.Queue()
self._running = False
self.animation = WebAnimation() # Web 动画实例
def _process_log_queue(self):
"""处理日志队列,在主线程中定时调用"""
@@ -193,12 +316,13 @@ class EGMWebApp:
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.gc_x = [float(x) for x in parameter_data.get('gc_x', [17.9, 17])]
para.ground_angels = [
angel / 180 * math.pi
float(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.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'))
@@ -207,9 +331,12 @@ class EGMWebApp:
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'))
@@ -220,8 +347,12 @@ class EGMWebApp:
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)
result = run_egm(para, animation_obj)
self.add_log("info", "EGM 计算完成")
@@ -395,6 +526,46 @@ class EGMWebApp:
"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]:
"""
获取默认配置
@@ -434,7 +605,14 @@ def start_webview():
# 确定前端 URL
# 在开发环境中使用 Vite 开发服务器
# 在生产环境中使用构建后的文件
dev_mode = os.getenv('EGM_DEV_MODE', 'true').lower() == 'true'
# 检查是否在打包环境中运行
if getattr(sys, 'frozen', False):
# 打包环境:强制使用生产模式,禁用调试
dev_mode = False
else:
# 开发环境:通过环境变量控制
dev_mode = os.getenv('EGM_DEV_MODE', 'true').lower() == 'true'
if dev_mode:
# 开发模式:使用 Vite 开发服务器
@@ -459,7 +637,7 @@ def start_webview():
title='EGM 输电线路绕击跳闸率计算',
url=url,
js_api=api,
width=1200,
width=1500,
height=900,
resizable=True,
min_size=(800, 600)
@@ -467,6 +645,7 @@ def start_webview():
# 将窗口对象传递给 API
api.window = window
api.animation.set_window(window)
# 启动
logger.info("启动 EGM Web 界面...")