feat: 添加 EGM 计算动画可视化功能
在 web 界面中实现 EGM 计算过程的动画展示,包括地线保护弧、导线暴露弧和地面线的动态绘制。重构 main.py 以支持可选的动画参数传递,并新增 Animation.vue 组件和 WebAnimation 类实现前后端交互。
This commit is contained in:
16
main.py
16
main.py
@@ -6,7 +6,6 @@ import tomli
|
||||
from loguru import logger
|
||||
from core import *
|
||||
import timeit
|
||||
from animation import Animation
|
||||
|
||||
|
||||
# 打印参数
|
||||
@@ -62,11 +61,12 @@ def read_parameter(toml_file_path) -> Parameter:
|
||||
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 +104,10 @@ 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
|
||||
if animate:
|
||||
animate.enable(True) # 启用动画
|
||||
# animate.show()
|
||||
for ground_angel in ground_angels:
|
||||
logger.info(f"地面倾角{ground_angel/math.pi*180:.3f}°")
|
||||
@@ -179,13 +180,16 @@ def run_egm(para: Parameter) -> dict:
|
||||
): # 雷电流
|
||||
logger.info(f"尝试计算电流为{i_bar:.2f}")
|
||||
rs = rs_fun(i_bar)
|
||||
if animate:
|
||||
animate.add_rs(rs, rs_x, rs_y)
|
||||
rc = rc_fun(i_bar, u_ph)
|
||||
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)
|
||||
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,6 +223,7 @@ def run_egm(para: Parameter) -> dict:
|
||||
"上面的导地线无法保护下面的导地线,检查设置参数。"
|
||||
)
|
||||
continue
|
||||
if animate:
|
||||
animate.add_expose_area(
|
||||
rc_x,
|
||||
rc_y,
|
||||
@@ -298,6 +303,7 @@ def run_egm(para: Parameter) -> dict:
|
||||
logger.info(f"电流为{i_bar}kA时,暴露弧已经完全被屏蔽")
|
||||
exposed_curve_shielded = True
|
||||
break
|
||||
if animate:
|
||||
animate.pause()
|
||||
# 判断是否导线已经被完全保护
|
||||
if abs(i_max - _max_i) < 1e-5:
|
||||
|
||||
258
webui/src/components/Animation.vue
Normal file
258
webui/src/components/Animation.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<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="animation" />
|
||||
EGM 动画可视化
|
||||
<q-space />
|
||||
<q-toggle
|
||||
v-model="enabled"
|
||||
label="启用动画"
|
||||
color="primary"
|
||||
@update:model-value="onEnableChange"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
class="animation-canvas"
|
||||
/>
|
||||
</q-card-section>
|
||||
</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 enabled = ref(false)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let tick = 0
|
||||
|
||||
// 坐标转换:数据坐标 -> 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 || !enabled.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 || !enabled.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 || !enabled.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 || !enabled.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 || !enabled.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 || !enabled.value) return
|
||||
|
||||
tick += 1
|
||||
// 不立即清除,等待下一次绑图时清除
|
||||
// 这样用户可以看到当前帧
|
||||
}
|
||||
|
||||
// 启用状态改变
|
||||
const onEnableChange = (value: boolean) => {
|
||||
if (value) {
|
||||
initFig()
|
||||
} else {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法给父组件或全局调用
|
||||
const animationApi = {
|
||||
enable: (enable: boolean) => {
|
||||
enabled.value = enable
|
||||
onEnableChange(enable)
|
||||
},
|
||||
initFig,
|
||||
clear,
|
||||
addRgLine,
|
||||
addRs,
|
||||
addRc,
|
||||
addExposeArea,
|
||||
pause
|
||||
}
|
||||
|
||||
// 注册到全局,供后端调用
|
||||
declare global {
|
||||
interface Window {
|
||||
animationApi?: typeof animationApi
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
ctx = canvasRef.value.getContext('2d')
|
||||
if (enabled.value) {
|
||||
initFig()
|
||||
}
|
||||
}
|
||||
|
||||
// 注册全局 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>
|
||||
@@ -351,6 +351,9 @@
|
||||
<!-- 运行日志 -->
|
||||
<LogComponent ref="logRef" />
|
||||
|
||||
<!-- EGM 动画可视化 -->
|
||||
<Animation ref="animationRef" class="q-mt-md" />
|
||||
|
||||
</div>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
@@ -361,6 +364,7 @@
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { AllParameters } from '@/types'
|
||||
import LogComponent from './Log.vue'
|
||||
import Animation from './Animation.vue'
|
||||
|
||||
// 默认参数
|
||||
const defaultParams: AllParameters = {
|
||||
@@ -394,6 +398,7 @@ 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 showIpCoefficients = ref(false)
|
||||
|
||||
126
webview_app.py
126
webview_app.py
@@ -23,6 +23,126 @@ from core import Parameter
|
||||
from main import parameter_display, run_egm
|
||||
|
||||
|
||||
class WebAnimation:
|
||||
"""
|
||||
Web 动画类,将 Python 端的 Animation 调用映射到前端 JavaScript
|
||||
对应 Animation.vue 的功能
|
||||
"""
|
||||
|
||||
def __init__(self, window=None):
|
||||
self._window = window
|
||||
self._disable = True # 默认禁用
|
||||
|
||||
def set_window(self, window):
|
||||
"""设置窗口对象"""
|
||||
self._window = window
|
||||
|
||||
def enable(self, enabled: bool):
|
||||
"""
|
||||
启用/禁用动画
|
||||
对应 animation.py 的 enable 方法和 Animation.vue 的 enable 方法
|
||||
"""
|
||||
self._disable = not enabled
|
||||
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 self._disable or 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 self._disable or 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 self._disable or 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 self._disable or 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 self._disable or 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 self._disable or 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 self._disable or 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 +206,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):
|
||||
"""处理日志队列,在主线程中定时调用"""
|
||||
@@ -220,8 +341,8 @@ class EGMWebApp:
|
||||
|
||||
logger.info("开始执行 EGM 计算...")
|
||||
|
||||
# 调用 main.py 的核心计算函数
|
||||
result = run_egm(para)
|
||||
# 调用 main.py 的核心计算函数,传递 animation 对象
|
||||
result = run_egm(para, self.animation)
|
||||
|
||||
self.add_log("info", "EGM 计算完成")
|
||||
|
||||
@@ -467,6 +588,7 @@ def start_webview():
|
||||
|
||||
# 将窗口对象传递给 API
|
||||
api.window = window
|
||||
api.animation.set_window(window)
|
||||
|
||||
# 启动
|
||||
logger.info("启动 EGM Web 界面...")
|
||||
|
||||
Reference in New Issue
Block a user