Compare commits

...

13 Commits

Author SHA1 Message Date
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
12 changed files with 799 additions and 210 deletions

View File

@@ -1,16 +1,16 @@
target: 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
gui: build gui: build
uv run python update_version.py uv run python update_version.py
cd webui && npm run build cd webui && npm run build
cd .. cd ..
uv run pyinstaller webview_app.py -n LighteningGUI --noconsole --add-data "webui/dist;webui/dist" -y uv run pyinstaller webview_app.py -n LighteningGUI --noconsole --add-data "webui/dist;webui/dist" -y
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: dist:
mkdir dist mkdir dist

View File

@@ -1 +1 @@
1.0.12 1.0.16

View File

@@ -2,7 +2,7 @@ import math
import ezdxf import ezdxf
import numpy as np import numpy as np
from typing import List from typing import List
from loguru import logger
gCAD = None gCAD = None
gMSP = None gMSP = None
gCount = 1 gCount = 1
@@ -28,6 +28,7 @@ class Parameter:
ac_or_dc: str # 交流或直流标识,"AC" 或 "DC",默认 "AC" ac_or_dc: str # 交流或直流标识,"AC" 或 "DC",默认 "AC"
z_0: float # 雷电波阻抗,默认 300 z_0: float # 雷电波阻抗,默认 300
z_c: float # 导线波阻抗,默认 251 z_c: float # 导线波阻抗,默认 251
u_50: float # 50%击穿电压,-1表示自动计算
def rg_line_function_factory(_rg, ground_angel): # 返回一个地面捕雷线的直线方程 def rg_line_function_factory(_rg, ground_angel): # 返回一个地面捕雷线的直线方程
@@ -187,13 +188,16 @@ def solve_circle_line_intersection(
return [_x, _y] return [_x, _y]
def min_i(string_len, u_ph, altitude: float = 0, z_0: float = 300, z_c: float = 251): 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: if altitude > 1000:
k_a = math.exp((altitude - 1000) / 8150) # 气隙海拔修正 k_a = math.exp((altitude - 1000) / 8150) # 气隙海拔修正
else: else:
k_a = 1 k_a = 1
# 只有在u_50未提供时才使用公式计算
if u_50 is None:
u_50 = 1 / k_a * (530 * string_len + 35) # 50045 上附录的公式,实际应该用负极性电压的公式 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 * (533 * string_len + 132) # 串放电路径 1000m海拔
# u_50 = 1 / k_a * (477 * string_len + 99) # 串放电路径 2000m海拔 # u_50 = 1 / k_a * (477 * string_len + 99) # 串放电路径 2000m海拔
# u_50 = 615 * string_len # 导线对塔身放电 1000m海拔 # u_50 = 615 * string_len # 导线对塔身放电 1000m海拔

BIN
lightening.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -49,10 +49,13 @@ def read_parameter(toml_file_path) -> Parameter:
para.h_arm = toml_parameter["h_arm"] para.h_arm = toml_parameter["h_arm"]
para.altitude = toml_parameter["altitude"] para.altitude = toml_parameter["altitude"]
para.rated_voltage = toml_parameter["rated_voltage"] 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"] toml_advance = toml_dict["advance"]
para.ng = toml_advance["ng"] # 地闪密度 para.ng = toml_advance["ng"] # 地闪密度
para.Ip_a = toml_advance["Ip_a"] # 概率密度曲线系数a para.Ip_a = toml_advance["Ip_a"] # 概率密度曲线系数a
para.Ip_b = toml_advance["Ip_b"] # 概率密度曲线系数b para.Ip_b = toml_advance["Ip_b"] # 概率密度曲线系数b
para.u_50 = toml_advance.get("u_50", -1) # 50%击穿电压,-1表示自动计算
toml_optional = toml_dict["optional"] toml_optional = toml_dict["optional"]
para.voltage_n = toml_optional["voltage_n"] # 工作电压分成多少份来计算 para.voltage_n = toml_optional["voltage_n"] # 工作电压分成多少份来计算
para.max_i = toml_optional["max_i"] para.max_i = toml_optional["max_i"]
@@ -152,7 +155,9 @@ def egm():
i_max = 0 i_max = 0
insulator_c_len = para.insulator_c_len 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 / 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 # 尝试的最小电流 _min_i = i_min # 尝试的最小电流
_max_i = para.max_i # 尝试的最大电流 _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) # cad.draw(i_min, u_ph, rs_x, rs_y, rc_x, rc_y, rg_x, rg_y, rg_type, 2)

21
main.py
View File

@@ -56,10 +56,13 @@ def read_parameter(toml_file_path) -> Parameter:
para.altitude = toml_parameter["altitude"] para.altitude = toml_parameter["altitude"]
para.rated_voltage = toml_parameter["rated_voltage"] para.rated_voltage = toml_parameter["rated_voltage"]
para.ac_or_dc = toml_parameter.get("ac_or_dc", "AC") # 交流或直流标识默认AC 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"] toml_advance = toml_dict["advance"]
para.ng = toml_advance["ng"] # 地闪密度 para.ng = toml_advance["ng"] # 地闪密度
para.Ip_a = toml_advance["Ip_a"] # 概率密度曲线系数a para.Ip_a = toml_advance["Ip_a"] # 概率密度曲线系数a
para.Ip_b = toml_advance["Ip_b"] # 概率密度曲线系数b para.Ip_b = toml_advance["Ip_b"] # 概率密度曲线系数b
para.u_50 = toml_advance.get("u_50", -1) # 50%击穿电压,-1表示自动计算
toml_optional = toml_dict["optional"] toml_optional = toml_dict["optional"]
para.voltage_n = toml_optional["voltage_n"] # 工作电压分成多少份来计算 para.voltage_n = toml_optional["voltage_n"] # 工作电压分成多少份来计算
para.max_i = toml_optional["max_i"] para.max_i = toml_optional["max_i"]
@@ -142,19 +145,13 @@ def run_egm(para: Parameter, animation=None) -> dict:
rg_y = gc_y[phase_conductor_foo + 2] rg_y = gc_y[phase_conductor_foo + 2]
else: else:
rg_type = "g" rg_type = "g"
# TODO 保护角公式可能有问题,后面改 # 使用实际高度(考虑弧垂)计算保护角
shield_angle_at_avg_height = ( shield_angle_at_avg_height = (
math.atan( math.atan2(rc_x - rs_x, rs_y - rc_y)
(rc_x - rs_x)
/ (
(h_arm[0] - string_g_len - h_arm[phase_conductor_foo + 1])
+ string_c_len
)
)
* 180 * 180
/ math.pi / 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表示下导线") logger.debug(f"最低相防护标识{rg_type}g表示地面c表示下导线")
rated_voltage = para.rated_voltage rated_voltage = para.rated_voltage
logger.info(f"交、直流标识{para.ac_or_dc}") logger.info(f"交、直流标识{para.ac_or_dc}")
@@ -175,7 +172,9 @@ def run_egm(para: Parameter, animation=None) -> dict:
insulator_c_len = para.insulator_c_len 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 / 1.732)
# TODO 需要考虑交、直流 # TODO 需要考虑交、直流
i_min = min_i(insulator_c_len, u_ph, para.altitude, para.z_0, para.z_c) # 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 # 尝试的最小电流 _min_i = i_min # 尝试的最小电流
_max_i = para.max_i # 尝试的最大电流 _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) # cad.draw(i_min, u_ph, rs_x, rs_y, rc_x, rc_y, rg_x, rg_y, rg_type, 2)

View File

@@ -1,4 +1,4 @@
version: 1.0.12 version: 1.0.16
company_name: EGM company_name: EGM
file_description: EGM Lightning Protection Calculator file_description: EGM Lightning Protection Calculator
product_name: Lightening product_name: Lightening

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EGM 输电线路绕击跳闸率计算 v1.0.12</title> <title>EGM 输电线路绕击跳闸率计算 v1.0.16</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -0,0 +1,422 @@
<template>
<q-card class="shadow-2">
<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

@@ -2,20 +2,27 @@
<q-layout view="lHh lpr lFf"> <q-layout view="lHh lpr lFf">
<q-header elevated class="bg-indigo-600 text-white"> <q-header elevated class="bg-indigo-600 text-white">
<q-toolbar> <q-toolbar>
<q-toolbar-title> <q-toolbar-title class="q-py-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<q-icon name="flash_on" size="md" /> <q-icon name="flash_on" size="md" />
<span class="text-xl font-bold">EGM 输电线路绕击跳闸率计算</span> <span class="text-xl font-bold">EGM 输电线路绕击跳闸率计算</span>
</div> </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-title>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<q-page-container> <q-page-container>
<q-page class="q-pa-md"> <q-page class="q-pa-md">
<div class="max-w-4xl mx-auto"> <div class="max-w-7xl mx-auto">
<!-- 基本参数 --> <!-- 基本参数 + 杆塔几何结构 并排布局 -->
<q-card class="q-mb-md shadow-2"> <div class="row q-col-gutter-md q-mb-md">
<!-- 左侧基本参数 -->
<div class="col-12 col-lg-6">
<q-card class="shadow-2 full-height">
<q-card-section class="bg-indigo-50"> <q-card-section class="bg-indigo-50">
<div class="text-h6 text-indigo-900 flex items-center gap-2"> <div class="text-h6 text-indigo-900 flex items-center gap-2">
<q-icon name="settings" /> <q-icon name="settings" />
@@ -194,6 +201,21 @@
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div>
<!-- 右侧杆塔几何结构可视化 -->
<div class="col-12 col-lg-6">
<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"
/>
</div>
</div>
<!-- 高级参数 --> <!-- 高级参数 -->
<q-card class="q-mb-md shadow-2"> <q-card class="q-mb-md shadow-2">
@@ -249,6 +271,26 @@
</q-input> </q-input>
</div> </div>
</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-section>
</q-card> </q-card>
@@ -322,7 +364,7 @@
/> />
</div> </div>
<!-- 隐藏的文件输入 --> <!-- 隐藏的文件输入开发模式备用 -->
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
@@ -383,6 +425,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import type { AllParameters } from '@/types' import type { AllParameters } from '@/types'
import LogComponent from './Log.vue' import LogComponent from './Log.vue'
import Animation from './Animation.vue' import Animation from './Animation.vue'
import Geometry from './Geometry.vue'
// 默认参数 // 默认参数
const defaultParams: AllParameters = { const defaultParams: AllParameters = {
@@ -405,7 +448,8 @@ const defaultParams: AllParameters = {
advance: { advance: {
ng: -1, ng: -1,
Ip_a: -1, Ip_a: -1,
Ip_b: -1 Ip_b: -1,
u_50: -1
}, },
optional: { optional: {
voltage_n: 3, voltage_n: 3,
@@ -420,8 +464,12 @@ const error = ref<string | null>(null)
const logRef = ref<InstanceType<typeof LogComponent> | null>(null) const logRef = ref<InstanceType<typeof LogComponent> | null>(null)
const animationRef = ref<InstanceType<typeof Animation> | null>(null) const animationRef = ref<InstanceType<typeof Animation> | null>(null)
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
// 当前打开的文件路径
const currentFilePath = ref<string>('')
// 雷电流概率密度系数设置开关 // 雷电流概率密度系数设置开关
const showIpCoefficients = ref(false) const showIpCoefficients = ref(false)
// 50%击穿电压设置开关
const showU50 = ref(false)
const voltageOptions = [ const voltageOptions = [
'110kV', '220kV', '330kV', '500kV', '750kV','1000kV', '110kV', '220kV', '330kV', '500kV', '750kV','1000kV',
@@ -457,6 +505,17 @@ watch(
} }
) )
// 监听50%击穿电压开关
watch(
showU50,
(show) => {
if (!show) {
// 关闭时重置为 -1使用公式计算
params.advance.u_50 = -1
}
}
)
// 雷暴日与地闪密度相互转换公式ng = 0.023 * td^3 // 雷暴日与地闪密度相互转换公式ng = 0.023 * td^3
// 标志位避免循环更新 // 标志位避免循环更新
let isUpdatingFromWatch = false let isUpdatingFromWatch = false
@@ -548,6 +607,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 calculating.value = true
result.value = null result.value = null
error.value = null error.value = null
@@ -737,12 +817,44 @@ const exportConfig = async () => {
} }
} }
// 导入配置 - 触发文件选择 // 导入配置 - 调用后端文件对话框
const importConfig = () => { 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() fileInput.value?.click()
}
} catch (e: any) {
error.value = e.message || '导入失败'
logRef.value?.addLog('error', e.message || '导入失败')
}
} }
// 处理文件选择 // 处理文件选择(开发模式备用)
const handleFileSelect = async (event: Event) => { const handleFileSelect = async (event: Event) => {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
@@ -763,6 +875,8 @@ const handleFileSelect = async (event: Event) => {
Object.assign(params.optional, importedParams.optional) Object.assign(params.optional, importedParams.optional)
} }
currentFilePath.value = file.name
logRef.value?.addLog('info', `成功导入配置: ${file.name}`) logRef.value?.addLog('info', `成功导入配置: ${file.name}`)
result.value = null result.value = null
error.value = null error.value = null
@@ -781,6 +895,7 @@ declare global {
pywebview?: { pywebview?: {
api: { api: {
calculate: (params: AllParameters) => Promise<any> calculate: (params: AllParameters) => Promise<any>
import_config: () => Promise<any>
export_config: (params: AllParameters) => Promise<any> export_config: (params: AllParameters) => Promise<any>
export_log: (logText: string) => Promise<any> export_log: (logText: string) => Promise<any>
} }

View File

@@ -23,6 +23,7 @@ export interface AdvanceParameter {
ng: number // 地闪密度 (次/(km²·a)) ng: number // 地闪密度 (次/(km²·a))
Ip_a: number // 雷电流概率密度曲线系数a Ip_a: number // 雷电流概率密度曲线系数a
Ip_b: number // 雷电流概率密度曲线系数b Ip_b: number // 雷电流概率密度曲线系数b
u_50: number // 50%击穿电压 (kV)-1表示自动计算
} }
export interface OptionalParameter { export interface OptionalParameter {

View File

@@ -9,6 +9,7 @@ import json
import math import math
import threading import threading
import queue import queue
import tomllib
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import datetime
@@ -315,12 +316,13 @@ class EGMWebApp:
para.insulator_c_len = float(parameter_data.get('insulator_c_len', 7.02)) 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_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.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 = [ para.ground_angels = [
angel / 180 * math.pi float(angel) / 180 * math.pi
for angel in parameter_data.get('ground_angels', [0]) 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)) para.altitude = int(parameter_data.get('altitude', 1000))
# 解析电压等级字符串,如 "500kV" -> 500 # 解析电压等级字符串,如 "500kV" -> 500
rated_voltage_str = str(parameter_data.get('rated_voltage', '500kV')) rated_voltage_str = str(parameter_data.get('rated_voltage', '500kV'))
@@ -329,6 +331,7 @@ class EGMWebApp:
para.ng = float(advance_data.get('ng', -1)) para.ng = float(advance_data.get('ng', -1))
para.Ip_a = float(advance_data.get('Ip_a', -1)) para.Ip_a = float(advance_data.get('Ip_a', -1))
para.Ip_b = float(advance_data.get('Ip_b', -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.voltage_n = int(optional_data.get('voltage_n', 3))
para.max_i = float(optional_data.get('max_i', 200)) para.max_i = float(optional_data.get('max_i', 200))
@@ -523,6 +526,46 @@ class EGMWebApp:
"message": f"保存失败: {str(e)}" "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]: def get_default_config(self) -> Dict[str, Any]:
""" """
获取默认配置 获取默认配置
@@ -594,7 +637,7 @@ def start_webview():
title='EGM 输电线路绕击跳闸率计算', title='EGM 输电线路绕击跳闸率计算',
url=url, url=url,
js_api=api, js_api=api,
width=1200, width=1500,
height=900, height=900,
resizable=True, resizable=True,
min_size=(800, 600) min_size=(800, 600)