[migrate]:[FL-18][迁移故障复现工具]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1115,3 +1115,19 @@
|
||||
- `save ""`
|
||||
- `stop-writes-on-bgsave-error no`
|
||||
- 目的:规避 RDB 快照失败触发 `MISCONF` 后 Redis 全局拒写,保障 Celery broker/result backend 可持续写入。
|
||||
|
||||
## 故障复现工具口径(2026-06-07)
|
||||
|
||||
- 新增独立能力入口 `/admin/fault-recurrence`,定位为源端 `FuXian` 的无状态迁移版工具,不挂靠现有 `/admin/fl-analysis` 任务模型。
|
||||
- 后端接口统一走 `POST /api/v1/fault-recurrence/analyze`,输入为 `multipart/form-data`:
|
||||
- `file`
|
||||
- `curve_no`
|
||||
- `stroke_mode`
|
||||
- `withstand_level_ka`
|
||||
- 输入文件兼容两类口径:
|
||||
- 源端 `<TGanTa>/<XianLu>` 分段文本
|
||||
- 含 `波头时间/μs`、`波尾时间/μs`、`反击耐雷水平kA`、`绕击耐雷水平kA` 列的普通 CSV/TXT
|
||||
- 计算逻辑保持源端 `FuXian.LeiDianFuXian2()`:
|
||||
- 先按 `2.6/50` 基准点判断是否 `No need!`
|
||||
- 再按同波头分组做线性插值与概率密度比较,输出最可能的波头/波尾组合
|
||||
- 菜单编码为 `admin.fault_recurrence`,默认绑定 `line.read` 口径,并加入 modern seed 与 legacy synthetic menu 双轨兼容。
|
||||
|
||||
@@ -5,6 +5,7 @@ from .v1.admin_files import router as admin_files_router
|
||||
from .v1.atp_models import router as atp_models_router
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.elevation import router as elevation_router
|
||||
from .v1.fault_recurrence import router as fault_recurrence_router
|
||||
from .v1.fl_analysis import router as fl_analysis_router
|
||||
from .v1.flower_monitor import router as flower_monitor_router
|
||||
from .v1.lightning import router as lightning_router
|
||||
@@ -27,6 +28,7 @@ v1_router.include_router(atp_models_router)
|
||||
v1_router.include_router(task_monitor_router)
|
||||
v1_router.include_router(system_params_router)
|
||||
v1_router.include_router(elevation_router)
|
||||
v1_router.include_router(fault_recurrence_router)
|
||||
v1_router.include_router(fl_analysis_router)
|
||||
v1_router.include_router(flower_monitor_router)
|
||||
v1_router.include_router(lightning_router)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from ...core.dependencies import CurrentUser, require_any_permission
|
||||
from ...schemas.fault_recurrence import (
|
||||
FaultRecurrenceAnalyzeResponse,
|
||||
FaultRecurrenceStrokeMode,
|
||||
)
|
||||
from ...services.fault_recurrence_service import build_fault_recurrence_report
|
||||
|
||||
|
||||
router = APIRouter(prefix="/fault-recurrence", tags=["fault-recurrence"])
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=FaultRecurrenceAnalyzeResponse)
|
||||
def analyze_fault_recurrence(
|
||||
file: UploadFile = File(...),
|
||||
curve_no: int = Form(..., ge=1, le=3),
|
||||
stroke_mode: FaultRecurrenceStrokeMode = Form(...),
|
||||
withstand_level_ka: float = Form(..., gt=0),
|
||||
_: CurrentUser = Depends(require_any_permission("line.read", "line.manage", "tower.read", "tower.manage")),
|
||||
) -> FaultRecurrenceAnalyzeResponse:
|
||||
try:
|
||||
payload = build_fault_recurrence_report(
|
||||
file.file.read(),
|
||||
file_name=file.filename or "fault-recurrence.txt",
|
||||
curve_no=curve_no,
|
||||
stroke_mode=stroke_mode,
|
||||
withstand_level_ka=withstand_level_ka,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
return FaultRecurrenceAnalyzeResponse.model_validate(payload)
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
FaultRecurrenceStrokeMode = Literal["counterstroke", "shielding"]
|
||||
FaultRecurrenceResultStatus = Literal["matched", "no_need"]
|
||||
|
||||
|
||||
class FaultRecurrenceDataPoint(BaseModel):
|
||||
head_time_us: float
|
||||
tail_time_us: float
|
||||
counterstroke_withstand_ka: float
|
||||
shielding_withstand_ka: float
|
||||
|
||||
|
||||
class FaultRecurrenceResult(BaseModel):
|
||||
status: FaultRecurrenceResultStatus
|
||||
message: str
|
||||
head_time_us: float | None = None
|
||||
tail_time_us: float | None = None
|
||||
probability_density: float | None = None
|
||||
|
||||
|
||||
class FaultRecurrenceAnalyzeResponse(BaseModel):
|
||||
curve_no: int
|
||||
curve_label: str
|
||||
stroke_mode: FaultRecurrenceStrokeMode
|
||||
stroke_label: str
|
||||
withstand_level_ka: float
|
||||
source_file_name: str
|
||||
source_mode: str
|
||||
point_count: int
|
||||
reference_counterstroke_ka: float
|
||||
reference_shielding_ka: float
|
||||
reference_point_found: bool
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
data_points: list[FaultRecurrenceDataPoint] = Field(default_factory=list)
|
||||
result: FaultRecurrenceResult
|
||||
@@ -0,0 +1,520 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
FaultRecurrenceStrokeMode = Literal["counterstroke", "shielding"]
|
||||
|
||||
EPSILON = 1e-4
|
||||
SUPPORTED_ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "gbk")
|
||||
LEGACY_SECTION_START_MARKERS = {"<TGanTa>", "<XianLu>"}
|
||||
LEGACY_SECTION_END_MARKERS = {"</TGanTa>", "</XianLu>"}
|
||||
HEADER_FIELDS = {
|
||||
"head_time_us": "波头时间/μs",
|
||||
"tail_time_us": "波尾时间/μs",
|
||||
"counterstroke_withstand_ka": "反击耐雷水平kA",
|
||||
"shielding_withstand_ka": "绕击耐雷水平kA",
|
||||
}
|
||||
POSITIONAL_FIELD_INDEXES = {
|
||||
"head_time_us": 60,
|
||||
"tail_time_us": 61,
|
||||
"counterstroke_withstand_ka": 63,
|
||||
"shielding_withstand_ka": 65,
|
||||
}
|
||||
CURVE_LABELS = {
|
||||
1: "Heidler",
|
||||
2: "双斜角",
|
||||
3: "双指数",
|
||||
}
|
||||
STROKE_LABELS = {
|
||||
"counterstroke": "反击",
|
||||
"shielding": "绕击",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FaultRecurrencePoint:
|
||||
head_time_us: float
|
||||
tail_time_us: float
|
||||
counterstroke_withstand_ka: float
|
||||
shielding_withstand_ka: float
|
||||
|
||||
|
||||
KDE_DATA_POINTS: tuple[tuple[float, float], ...] = (
|
||||
(2.5446625, 71.94915),
|
||||
(3.878075, 45.71697),
|
||||
(5.2919, 36.68912),
|
||||
(2.2, 35.016475),
|
||||
(5.2623375, 53.46354),
|
||||
(0.5975, 69.55),
|
||||
(8.6125, 40.043055),
|
||||
(1.0364225, 59.31549),
|
||||
(2.2287375, 68.80835),
|
||||
(6.3, 88.8407),
|
||||
(6.3791875, 45.053385),
|
||||
(38.3205, 75.1023),
|
||||
(2.770925, 86.41685),
|
||||
(0.78666, 43.69612),
|
||||
(1.9475375, 64.02136),
|
||||
(6.43745, 47.88732),
|
||||
(2.1827875, 64.6113),
|
||||
(5.878075, 62.836475),
|
||||
(0.94157125, 45.493045),
|
||||
(8.7521625, 45.177535),
|
||||
(1.381825, 67.2178),
|
||||
(5.39025, 73.2563),
|
||||
(3.5, 31.135),
|
||||
(11.3375, 14.495),
|
||||
(4.1772125, 100.8358),
|
||||
(41.0 / 160.0, 79.7992),
|
||||
(3.625, 92.95),
|
||||
(6.9187875, 57.616195),
|
||||
(0.904215, 55.79496),
|
||||
(2.925, 86.0249),
|
||||
(2.1625, 67.3465),
|
||||
(0.055375, 71.3609),
|
||||
(3.52085, 55.69681),
|
||||
(1.55, 77.29865),
|
||||
(9.1396125, 36.816325),
|
||||
(1.811025, 86.48445),
|
||||
(9.5971875, 46.126925),
|
||||
(1.9967875, 62.300745),
|
||||
(1.7409625, 40.6081),
|
||||
(9.5375, 49.27),
|
||||
(6.258875, 83.8786),
|
||||
(0.80435625, 83.265),
|
||||
(13.94925, 128.91645),
|
||||
(2.077225, 79.781),
|
||||
(10.8873375, 34.65475),
|
||||
(11.65, 33.865),
|
||||
(5.142125, 46.79155),
|
||||
(1.832, 87.789),
|
||||
(1.29575, 63.037325),
|
||||
(1.9125, 57.2),
|
||||
(2.4490625, 44.61132),
|
||||
(3.968625, 69.64555),
|
||||
(3.0873375, 44.872295),
|
||||
(3.4136625, 67.834),
|
||||
(2.452875, 75.61905),
|
||||
(2.3944875, 77.82255),
|
||||
(2.085875, 78.06955),
|
||||
(3.0885625, 78.0),
|
||||
(3.5739625, 66.1076),
|
||||
(2.4911625, 80.22755),
|
||||
(7.2521875, 56.4239),
|
||||
(0.95733125, 85.36125),
|
||||
(4.85, 72.54),
|
||||
(3.3823375, 50.460345),
|
||||
(1.6570625, 77.883),
|
||||
(5.9302875, 83.36445),
|
||||
(19.0, 82.68),
|
||||
(2.2402125, 83.1415),
|
||||
(2.28455, 77.97725),
|
||||
(1.645225, 86.0925),
|
||||
(12.625, 33.8),
|
||||
(2.5478, 62.411375),
|
||||
(6.5173125, 57.049395),
|
||||
(10.671875, 37.177335),
|
||||
(2.9472375, 55.92483),
|
||||
(2.9691, 50.966825),
|
||||
(9.0238125, 17.03364),
|
||||
(5.9496, 32.13704),
|
||||
(1.12263, 54.11705),
|
||||
(2.6329625, 66.84535),
|
||||
(2.23485, 64.15552),
|
||||
(1.3868625, 73.6411),
|
||||
(5.175, 76.284),
|
||||
(4.9095375, 31.97792),
|
||||
(5.8178125, 68.0498),
|
||||
(2.4029375, 18.073835),
|
||||
(3.200375, 56.467645),
|
||||
(11.185225, 84.01835),
|
||||
(5.362175, 37.23395),
|
||||
(4.804875, 53.678495),
|
||||
(6.351875, 65.56745),
|
||||
(3.094075, 78.03315),
|
||||
(1.520025, 74.08245),
|
||||
(2.7534, 79.118),
|
||||
(3.2919125, 65.1573),
|
||||
(3.2234875, 76.882),
|
||||
(3.87235, 76.0526),
|
||||
)
|
||||
|
||||
|
||||
def build_fault_recurrence_report(
|
||||
file_bytes: bytes,
|
||||
*,
|
||||
file_name: str,
|
||||
curve_no: int,
|
||||
stroke_mode: FaultRecurrenceStrokeMode,
|
||||
withstand_level_ka: float,
|
||||
) -> dict[str, object]:
|
||||
if curve_no not in CURVE_LABELS:
|
||||
raise ValueError("波形数值越界,有效范围为 1-3")
|
||||
if stroke_mode not in STROKE_LABELS:
|
||||
raise ValueError("类型数值越界,仅支持 counterstroke/shielding")
|
||||
if withstand_level_ka <= 0:
|
||||
raise ValueError("耐雷水平必须大于 0")
|
||||
|
||||
text = _decode_text(file_bytes)
|
||||
points, warnings, source_mode = parse_fault_recurrence_points(text)
|
||||
analysis = analyze_fault_recurrence_points(
|
||||
points,
|
||||
stroke_mode=stroke_mode,
|
||||
withstand_level_ka=withstand_level_ka,
|
||||
)
|
||||
warnings.extend(analysis.pop("warnings"))
|
||||
|
||||
return {
|
||||
"curve_no": curve_no,
|
||||
"curve_label": CURVE_LABELS[curve_no],
|
||||
"stroke_mode": stroke_mode,
|
||||
"stroke_label": STROKE_LABELS[stroke_mode],
|
||||
"withstand_level_ka": withstand_level_ka,
|
||||
"source_file_name": file_name,
|
||||
"source_mode": source_mode,
|
||||
"point_count": len(points),
|
||||
"reference_counterstroke_ka": analysis.pop("reference_counterstroke_ka"),
|
||||
"reference_shielding_ka": analysis.pop("reference_shielding_ka"),
|
||||
"reference_point_found": analysis.pop("reference_point_found"),
|
||||
"warnings": warnings,
|
||||
"data_points": [asdict(point) for point in points],
|
||||
"result": analysis,
|
||||
}
|
||||
|
||||
|
||||
def parse_fault_recurrence_points(text: str) -> tuple[list[FaultRecurrencePoint], list[str], str]:
|
||||
candidate_rows, source_mode = _extract_candidate_rows(text)
|
||||
header_map: dict[str, int] | None = None
|
||||
points: list[FaultRecurrencePoint] = []
|
||||
|
||||
for raw_line in candidate_rows:
|
||||
columns = _parse_csv_row(raw_line)
|
||||
if not columns:
|
||||
continue
|
||||
if _looks_like_header(columns):
|
||||
header_map = {value.strip(): index for index, value in enumerate(columns)}
|
||||
continue
|
||||
|
||||
point = _extract_point(columns, header_map)
|
||||
if point is not None:
|
||||
points.append(point)
|
||||
|
||||
if not points:
|
||||
raise ValueError("未从上传文件解析到有效基础数据,请确认文件包含波头/波尾与耐雷水平列。")
|
||||
|
||||
points.sort(key=lambda item: (item.head_time_us, item.tail_time_us))
|
||||
return points, [], source_mode
|
||||
|
||||
|
||||
def analyze_fault_recurrence_points(
|
||||
points: list[FaultRecurrencePoint],
|
||||
*,
|
||||
stroke_mode: FaultRecurrenceStrokeMode,
|
||||
withstand_level_ka: float,
|
||||
) -> dict[str, object]:
|
||||
reference_point, reference_found = _search_reference_point(points)
|
||||
reference_counterstroke = reference_point.counterstroke_withstand_ka
|
||||
reference_shielding = reference_point.shielding_withstand_ka
|
||||
selected_reference = _selected_withstand_value(reference_point, stroke_mode)
|
||||
warnings: list[str] = []
|
||||
|
||||
if not reference_found:
|
||||
warnings.append("未发现波头 2.6μs / 波尾 50μs 的基准点,按源端逻辑回退为首条基础数据。")
|
||||
|
||||
if withstand_level_ka > selected_reference:
|
||||
return {
|
||||
"status": "no_need",
|
||||
"message": "No need!",
|
||||
"head_time_us": None,
|
||||
"tail_time_us": None,
|
||||
"probability_density": None,
|
||||
"reference_counterstroke_ka": reference_counterstroke,
|
||||
"reference_shielding_ka": reference_shielding,
|
||||
"reference_point_found": reference_found,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
best_candidate: tuple[float, float, float] | None = None
|
||||
groups = _group_points_by_head(points)
|
||||
|
||||
for group_index, group in enumerate(groups):
|
||||
previous_point: FaultRecurrencePoint | None = None
|
||||
previous_value: float | None = None
|
||||
|
||||
for point_index, point in enumerate(group):
|
||||
current_value = _selected_withstand_value(point, stroke_mode)
|
||||
|
||||
if previous_point is None:
|
||||
best_candidate = _consider_candidate(
|
||||
best_candidate,
|
||||
point.head_time_us,
|
||||
point.tail_time_us,
|
||||
)
|
||||
previous_point = point
|
||||
previous_value = current_value
|
||||
continue
|
||||
|
||||
interpolated = _interpolate_candidate(
|
||||
previous_point,
|
||||
point,
|
||||
previous_value,
|
||||
current_value,
|
||||
withstand_level_ka,
|
||||
)
|
||||
if interpolated is not None:
|
||||
best_candidate = _consider_candidate(
|
||||
best_candidate,
|
||||
interpolated[0],
|
||||
interpolated[1],
|
||||
)
|
||||
elif _is_close(withstand_level_ka, current_value):
|
||||
best_candidate = _consider_candidate(
|
||||
best_candidate,
|
||||
point.head_time_us,
|
||||
point.tail_time_us,
|
||||
)
|
||||
|
||||
if (
|
||||
group_index == len(groups) - 1
|
||||
and point_index == len(group) - 1
|
||||
and stroke_mode == "shielding"
|
||||
and withstand_level_ka < current_value
|
||||
):
|
||||
best_candidate = _consider_candidate(
|
||||
best_candidate,
|
||||
point.head_time_us,
|
||||
point.tail_time_us,
|
||||
)
|
||||
|
||||
previous_point = point
|
||||
previous_value = current_value
|
||||
|
||||
if best_candidate is None:
|
||||
best_candidate = _consider_candidate(
|
||||
None,
|
||||
points[0].head_time_us,
|
||||
points[0].tail_time_us,
|
||||
)
|
||||
|
||||
head_time_us, tail_time_us, probability_density = best_candidate
|
||||
return {
|
||||
"status": "matched",
|
||||
"message": f"head = {_format_float(head_time_us)}, tail = {_format_float(tail_time_us)}, pro = {_format_float(probability_density)}",
|
||||
"head_time_us": head_time_us,
|
||||
"tail_time_us": tail_time_us,
|
||||
"probability_density": probability_density,
|
||||
"reference_counterstroke_ka": reference_counterstroke,
|
||||
"reference_shielding_ka": reference_shielding,
|
||||
"reference_point_found": reference_found,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _decode_text(file_bytes: bytes) -> str:
|
||||
last_error: UnicodeDecodeError | None = None
|
||||
for encoding in SUPPORTED_ENCODINGS:
|
||||
try:
|
||||
return file_bytes.decode(encoding)
|
||||
except UnicodeDecodeError as error:
|
||||
last_error = error
|
||||
raise ValueError("文件编码不受支持,请使用 UTF-8、GBK 或 GB18030 文本文件。") from last_error
|
||||
|
||||
|
||||
def _extract_candidate_rows(text: str) -> tuple[list[str], str]:
|
||||
rows = text.splitlines()
|
||||
has_legacy_markers = any(row.strip() in LEGACY_SECTION_START_MARKERS for row in rows)
|
||||
if not has_legacy_markers:
|
||||
return [row for row in rows if row.strip()], "plain-csv"
|
||||
|
||||
collected: list[str] = []
|
||||
inside_legacy_section = False
|
||||
|
||||
for raw_row in rows:
|
||||
stripped = raw_row.strip()
|
||||
if stripped in LEGACY_SECTION_START_MARKERS:
|
||||
inside_legacy_section = True
|
||||
continue
|
||||
if stripped in LEGACY_SECTION_END_MARKERS:
|
||||
inside_legacy_section = False
|
||||
continue
|
||||
if inside_legacy_section and stripped:
|
||||
collected.append(raw_row)
|
||||
|
||||
return collected, "legacy-sections"
|
||||
|
||||
|
||||
def _parse_csv_row(raw_line: str) -> list[str]:
|
||||
try:
|
||||
return [value.strip() for value in next(csv.reader([raw_line], skipinitialspace=False))]
|
||||
except csv.Error:
|
||||
return [value.strip() for value in raw_line.split(",")]
|
||||
|
||||
|
||||
def _looks_like_header(columns: list[str]) -> bool:
|
||||
normalized = {value.strip() for value in columns if value.strip()}
|
||||
if "杆塔模型" in normalized:
|
||||
return True
|
||||
required_headers = set(HEADER_FIELDS.values())
|
||||
return len(required_headers.intersection(normalized)) >= 2
|
||||
|
||||
|
||||
def _extract_point(columns: list[str], header_map: dict[str, int] | None) -> FaultRecurrencePoint | None:
|
||||
if header_map:
|
||||
head = _parse_positive_float(_read_header_value(columns, header_map, HEADER_FIELDS["head_time_us"]))
|
||||
tail = _parse_positive_float(_read_header_value(columns, header_map, HEADER_FIELDS["tail_time_us"]))
|
||||
counterstroke = _parse_positive_float(
|
||||
_read_header_value(columns, header_map, HEADER_FIELDS["counterstroke_withstand_ka"])
|
||||
)
|
||||
shielding = _parse_positive_float(
|
||||
_read_header_value(columns, header_map, HEADER_FIELDS["shielding_withstand_ka"])
|
||||
)
|
||||
else:
|
||||
head = _parse_positive_float(_read_positional_value(columns, POSITIONAL_FIELD_INDEXES["head_time_us"]))
|
||||
tail = _parse_positive_float(_read_positional_value(columns, POSITIONAL_FIELD_INDEXES["tail_time_us"]))
|
||||
counterstroke = _parse_positive_float(
|
||||
_read_positional_value(columns, POSITIONAL_FIELD_INDEXES["counterstroke_withstand_ka"])
|
||||
)
|
||||
shielding = _parse_positive_float(
|
||||
_read_positional_value(columns, POSITIONAL_FIELD_INDEXES["shielding_withstand_ka"])
|
||||
)
|
||||
|
||||
if head is None or tail is None or counterstroke is None or shielding is None:
|
||||
return None
|
||||
|
||||
return FaultRecurrencePoint(
|
||||
head_time_us=head,
|
||||
tail_time_us=tail,
|
||||
counterstroke_withstand_ka=counterstroke,
|
||||
shielding_withstand_ka=shielding,
|
||||
)
|
||||
|
||||
|
||||
def _read_header_value(columns: list[str], header_map: dict[str, int], field_name: str) -> str | None:
|
||||
index = header_map.get(field_name)
|
||||
if index is None or index >= len(columns):
|
||||
return None
|
||||
return columns[index]
|
||||
|
||||
|
||||
def _read_positional_value(columns: list[str], index: int) -> str | None:
|
||||
if index >= len(columns):
|
||||
return None
|
||||
return columns[index]
|
||||
|
||||
|
||||
def _parse_positive_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
try:
|
||||
parsed = float(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed <= 0:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _search_reference_point(points: list[FaultRecurrencePoint]) -> tuple[FaultRecurrencePoint, bool]:
|
||||
for point in points:
|
||||
if _is_close(point.head_time_us, 2.6) and _is_close(point.tail_time_us, 50.0):
|
||||
return point, True
|
||||
return points[0], False
|
||||
|
||||
|
||||
def _group_points_by_head(points: list[FaultRecurrencePoint]) -> list[list[FaultRecurrencePoint]]:
|
||||
groups: list[list[FaultRecurrencePoint]] = []
|
||||
current_group: list[FaultRecurrencePoint] = []
|
||||
current_head: float | None = None
|
||||
|
||||
for point in points:
|
||||
if current_head is None or _is_close(point.head_time_us, current_head):
|
||||
current_group.append(point)
|
||||
current_head = point.head_time_us
|
||||
continue
|
||||
groups.append(current_group)
|
||||
current_group = [point]
|
||||
current_head = point.head_time_us
|
||||
|
||||
if current_group:
|
||||
groups.append(current_group)
|
||||
return groups
|
||||
|
||||
|
||||
def _selected_withstand_value(point: FaultRecurrencePoint, stroke_mode: FaultRecurrenceStrokeMode) -> float:
|
||||
if stroke_mode == "shielding":
|
||||
return point.shielding_withstand_ka
|
||||
return point.counterstroke_withstand_ka
|
||||
|
||||
|
||||
def _interpolate_candidate(
|
||||
previous_point: FaultRecurrencePoint,
|
||||
current_point: FaultRecurrencePoint,
|
||||
previous_value: float,
|
||||
current_value: float,
|
||||
target_value: float,
|
||||
) -> tuple[float, float] | None:
|
||||
if _is_close(target_value, current_value):
|
||||
return current_point.head_time_us, current_point.tail_time_us
|
||||
if _is_close(previous_value, current_value):
|
||||
return None
|
||||
if (previous_value - target_value) * (current_value - target_value) > 0:
|
||||
return None
|
||||
|
||||
if _is_close(previous_point.head_time_us, current_point.head_time_us):
|
||||
ratio = (target_value - previous_value) / (current_value - previous_value)
|
||||
tail_time_us = previous_point.tail_time_us + (
|
||||
(current_point.tail_time_us - previous_point.tail_time_us) * ratio
|
||||
)
|
||||
return previous_point.head_time_us, tail_time_us
|
||||
|
||||
if _is_close(previous_point.tail_time_us, current_point.tail_time_us):
|
||||
ratio = (target_value - previous_value) / (current_value - previous_value)
|
||||
head_time_us = previous_point.head_time_us + (
|
||||
(current_point.head_time_us - previous_point.head_time_us) * ratio
|
||||
)
|
||||
return head_time_us, previous_point.tail_time_us
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _consider_candidate(
|
||||
current_best: tuple[float, float, float] | None,
|
||||
head_time_us: float,
|
||||
tail_time_us: float,
|
||||
) -> tuple[float, float, float]:
|
||||
probability_density = probability_density_for_point(head_time_us, tail_time_us)
|
||||
if current_best is None or probability_density > current_best[2]:
|
||||
return head_time_us, tail_time_us, probability_density
|
||||
return current_best
|
||||
|
||||
|
||||
def probability_density_for_point(head_time_us: float, tail_time_us: float) -> float:
|
||||
bandwidth_head = 1.14148986513825
|
||||
bandwidth_tail = 9.65598579731003
|
||||
kernel_sum = 0.0
|
||||
|
||||
for sample_head, sample_tail in KDE_DATA_POINTS:
|
||||
exponent = -0.5 * (
|
||||
math.pow((head_time_us - sample_head) / bandwidth_head, 2.0)
|
||||
+ math.pow((tail_time_us - sample_tail) / bandwidth_tail, 2.0)
|
||||
)
|
||||
kernel_sum += math.exp(exponent)
|
||||
|
||||
denominator = math.pi * 194.0 * bandwidth_head * bandwidth_tail * 0.999874014593309
|
||||
return kernel_sum / denominator
|
||||
|
||||
|
||||
def _is_close(left: float, right: float) -> bool:
|
||||
return abs(left - right) < EPSILON
|
||||
|
||||
|
||||
def _format_float(value: float) -> str:
|
||||
return format(value, ".12g")
|
||||
@@ -74,6 +74,7 @@ PROTECTED_MENU_CODES = {
|
||||
"admin.filedetector",
|
||||
"admin.baidu_pan",
|
||||
"admin.power_lines",
|
||||
"admin.fault_recurrence",
|
||||
"admin.lightning_currents",
|
||||
"admin.lightning_distribution",
|
||||
"admin.workers",
|
||||
|
||||
@@ -98,6 +98,7 @@ MENU_CODE_PERMISSION_MAP: dict[str, set[str]] = {
|
||||
"admin.workers": {"celery.read", "celery.manage"},
|
||||
"admin.task_monitor": {"celery.read", "celery.manage"},
|
||||
"admin.atp_models": {"atp.read", "atp.manage", "atp.run"},
|
||||
"admin.fault_recurrence": {"line.read", "line.manage", "tower.read", "tower.manage"},
|
||||
"admin.lightning_currents": {"lightning.read", "lightning.manage"},
|
||||
"admin.lightning_distribution": {"lightning.read", "lightning.manage"},
|
||||
"admin.wine_runner": {"wine.read", "wine.manage"},
|
||||
@@ -170,6 +171,17 @@ SYNTHETIC_LEGACY_MENU_ROWS: list[dict[str, Any]] = [
|
||||
"seq": 55,
|
||||
"state": "ENABLED",
|
||||
},
|
||||
{
|
||||
"menu_id": "admin.fault_recurrence",
|
||||
"menu_name": "admin.fault_recurrence",
|
||||
"menu_label": "故障复现",
|
||||
"menu_type": "MENU",
|
||||
"parent_id": None,
|
||||
"url": "/admin/fault-recurrence",
|
||||
"menu_icon": "Experiment",
|
||||
"seq": 50,
|
||||
"state": "ENABLED",
|
||||
},
|
||||
{
|
||||
"menu_id": "admin.wine_runner",
|
||||
"menu_name": "admin.wine_runner",
|
||||
|
||||
@@ -166,6 +166,19 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
"cacheable": False,
|
||||
"permission_code": "line.read",
|
||||
},
|
||||
{
|
||||
"code": "admin.fault_recurrence",
|
||||
"name": "故障复现",
|
||||
"path": "/admin/fault-recurrence",
|
||||
"icon": "Experiment",
|
||||
"parent_code": None,
|
||||
"type": "menu",
|
||||
"sort_order": 50,
|
||||
"status": "enabled",
|
||||
"visible": True,
|
||||
"cacheable": False,
|
||||
"permission_code": "line.read",
|
||||
},
|
||||
{
|
||||
"code": "admin.lightning_currents",
|
||||
"name": "雷电幅值统计",
|
||||
@@ -299,7 +312,7 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
]
|
||||
|
||||
ROLE_MENU_BINDINGS: dict[str, list[str]] = {
|
||||
"admin": ["admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.fl_analysis", "admin.lightning_currents", "admin.lightning_distribution", "admin.workers", "admin.task_monitor", "admin.atp_models", "admin.tower_models", "admin.files", "admin.elevation", "admin.syslog", "admin.wine_runner"],
|
||||
"admin": ["admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.fl_analysis", "admin.fault_recurrence", "admin.lightning_currents", "admin.lightning_distribution", "admin.workers", "admin.task_monitor", "admin.atp_models", "admin.tower_models", "admin.files", "admin.elevation", "admin.syslog", "admin.wine_runner"],
|
||||
"user": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ROUTER_FILE = ROOT / "app" / "api" / "router.py"
|
||||
SEED_FILE = ROOT / "app" / "services" / "seed_service.py"
|
||||
AUTHZ_FILE = ROOT / "app" / "services" / "legacy_authz_service.py"
|
||||
|
||||
|
||||
def _load_module(path: Path) -> ast.Module:
|
||||
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
|
||||
|
||||
class FaultRecurrenceContractTest(unittest.TestCase):
|
||||
def test_router_registers_fault_recurrence_api(self) -> None:
|
||||
source = ROUTER_FILE.read_text(encoding="utf-8")
|
||||
self.assertIn("from .v1.fault_recurrence import router as fault_recurrence_router", source)
|
||||
self.assertIn("v1_router.include_router(fault_recurrence_router)", source)
|
||||
|
||||
def test_seed_defaults_include_fault_recurrence_menu(self) -> None:
|
||||
source = SEED_FILE.read_text(encoding="utf-8")
|
||||
self.assertIn('"code": "admin.fault_recurrence"', source)
|
||||
self.assertIn('"path": "/admin/fault-recurrence"', source)
|
||||
self.assertIn('"admin.fault_recurrence"', source)
|
||||
|
||||
def test_legacy_authz_maps_fault_recurrence_permissions(self) -> None:
|
||||
source = AUTHZ_FILE.read_text(encoding="utf-8")
|
||||
self.assertIn('"admin.fault_recurrence"', source)
|
||||
self.assertIn('"/admin/fault-recurrence"', source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.services.fault_recurrence_service import (
|
||||
analyze_fault_recurrence_points,
|
||||
build_fault_recurrence_report,
|
||||
parse_fault_recurrence_points,
|
||||
)
|
||||
|
||||
|
||||
class FaultRecurrenceServiceTest(unittest.TestCase):
|
||||
def test_parse_legacy_section_file_sorts_points(self) -> None:
|
||||
payload = """
|
||||
<TGanTa>
|
||||
任务编号,杆塔模型,波头时间/μs,波尾时间/μs,反击耐雷水平kA,绕击耐雷水平kA
|
||||
1,Model-A,3.1,55,18,16
|
||||
2,Model-B,2.6,50,12,10
|
||||
</TGanTa>
|
||||
"""
|
||||
points, warnings, source_mode = parse_fault_recurrence_points(payload)
|
||||
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(source_mode, "legacy-sections")
|
||||
self.assertEqual(len(points), 2)
|
||||
self.assertEqual(points[0].head_time_us, 2.6)
|
||||
self.assertEqual(points[0].tail_time_us, 50.0)
|
||||
self.assertEqual(points[1].head_time_us, 3.1)
|
||||
|
||||
def test_report_returns_no_need_when_withstand_exceeds_reference_point(self) -> None:
|
||||
payload = """
|
||||
杆塔模型,波头时间/μs,波尾时间/μs,反击耐雷水平kA,绕击耐雷水平kA
|
||||
Model-A,2.6,50,10,9
|
||||
Model-B,3.1,55,18,16
|
||||
"""
|
||||
report = build_fault_recurrence_report(
|
||||
payload.encode("utf-8"),
|
||||
file_name="fault.csv",
|
||||
curve_no=1,
|
||||
stroke_mode="counterstroke",
|
||||
withstand_level_ka=12.0,
|
||||
)
|
||||
|
||||
self.assertEqual(report["source_mode"], "plain-csv")
|
||||
self.assertEqual(report["reference_counterstroke_ka"], 10.0)
|
||||
self.assertEqual(report["result"]["status"], "no_need")
|
||||
self.assertEqual(report["result"]["message"], "No need!")
|
||||
|
||||
def test_analysis_interpolates_tail_on_same_head_group(self) -> None:
|
||||
payload = """
|
||||
杆塔模型,波头时间/μs,波尾时间/μs,反击耐雷水平kA,绕击耐雷水平kA
|
||||
Model-A,2.6,40,8,7
|
||||
Model-B,2.6,50,12,11
|
||||
Model-C,2.6,60,16,15
|
||||
"""
|
||||
report = build_fault_recurrence_report(
|
||||
payload.encode("utf-8"),
|
||||
file_name="fault.csv",
|
||||
curve_no=2,
|
||||
stroke_mode="counterstroke",
|
||||
withstand_level_ka=10.0,
|
||||
)
|
||||
|
||||
result = report["result"]
|
||||
self.assertEqual(result["status"], "matched")
|
||||
self.assertAlmostEqual(result["head_time_us"], 2.6, places=6)
|
||||
self.assertAlmostEqual(result["tail_time_us"], 45.0, places=6)
|
||||
self.assertGreater(result["probability_density"], 0.0)
|
||||
|
||||
def test_analysis_warns_when_reference_point_is_missing(self) -> None:
|
||||
points, _, _ = parse_fault_recurrence_points(
|
||||
"""
|
||||
杆塔模型,波头时间/μs,波尾时间/μs,反击耐雷水平kA,绕击耐雷水平kA
|
||||
Model-A,3.0,40,20,18
|
||||
Model-B,3.0,50,24,22
|
||||
"""
|
||||
)
|
||||
result = analyze_fault_recurrence_points(
|
||||
points,
|
||||
stroke_mode="shielding",
|
||||
withstand_level_ka=19.0,
|
||||
)
|
||||
|
||||
self.assertFalse(result["reference_point_found"])
|
||||
self.assertTrue(result["warnings"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -73,3 +73,51 @@
|
||||
|
||||
- 风险与关注点:
|
||||
- 本次仅调整前端展示文案,不涉及页面交互、数据请求或接口契约。
|
||||
|
||||
## Work Log - 迁移故障复现工具(2026-06-07)
|
||||
|
||||
- 背景:
|
||||
- Issue `FL-18` 要求把源端 `/home/ck/fl-knowledge/LP/Form7_GuZhangFuXian.cs` 与 `LP/FuXian.cs` 的“故障复现”能力迁入 `fquiz`。
|
||||
- 源端工具是一个独立分析器:上传基础数据文件后,按波形、类型和耐雷水平做复现计算,并返回最可能的波头/波尾组合。
|
||||
- 目标仓库现有 `fl-analysis` 聚焦任务化风险评估,不适合硬塞入同一任务模型;因此本次采用独立无状态工具页 + 上传接口的最小闭环接入。
|
||||
|
||||
- 本次改动:
|
||||
- 后端
|
||||
- `api/app/services/fault_recurrence_service.py`
|
||||
- 纯 Python 迁移 `FuXian` 核心逻辑:文件解码、源端分段文本/普通 CSV 双格式解析、2.6/50 基准点判断、按同波头分组插值、KDE 概率密度比选。
|
||||
- `api/app/schemas/fault_recurrence.py`
|
||||
- 新增故障复现结果 schema,统一前端契约。
|
||||
- `api/app/api/v1/fault_recurrence.py`
|
||||
- 新增 `POST /api/v1/fault-recurrence/analyze`,接收 `multipart/form-data` 上传并返回结构化结果。
|
||||
- `api/app/api/router.py`
|
||||
- 注册故障复现路由。
|
||||
- `api/app/services/seed_service.py`
|
||||
- 新增默认菜单 `admin.fault_recurrence`,路径 `/admin/fault-recurrence`,并加入 admin 默认菜单绑定。
|
||||
- `api/app/services/legacy_authz_service.py`
|
||||
- 补 legacy 权限映射与 synthetic menu,兼容旧菜单表场景。
|
||||
- `api/app/services/legacy_admin_rbac_service.py`
|
||||
- 将 `admin.fault_recurrence` 加入受保护菜单集合。
|
||||
- 前端
|
||||
- `web/src/app/admin/fault-recurrence/page.tsx`
|
||||
- 新增独立 admin 工具页,支持选择基础数据文件、配置波形/类型/耐雷水平、执行复现、展示结构化结果与基础数据表格预览。
|
||||
- `web/src/types/auth.ts`
|
||||
- 新增故障复现前端类型定义。
|
||||
- 测试
|
||||
- `api/tests/test_fault_recurrence_service.py`
|
||||
- 新增纯 Python 服务测试,覆盖分段文本解析、`No need!` 分支、插值结果与缺基准点 warning。
|
||||
- `api/tests/test_fault_recurrence_contract.py`
|
||||
- 新增 AST 契约测试,覆盖 router 注册与菜单/权限落点。
|
||||
|
||||
- 验证:
|
||||
- 基线:
|
||||
- `python3 -m unittest tests.test_line_contract` 通过。
|
||||
- 迁移后:
|
||||
- `python3 -m unittest tests.test_fault_recurrence_service tests.test_fault_recurrence_contract` 通过。
|
||||
- `python3 -m unittest tests.test_line_contract` 通过。
|
||||
- `npm --workspace web run lint -- src/app/admin/fault-recurrence/page.tsx` 通过。
|
||||
- `npm --workspace web exec tsc --noEmit` 通过。
|
||||
- `npm --workspace web run build` 未完成,进程被环境以 `code 137` 杀掉;构建日志未给出新增代码级报错,更像资源限制。
|
||||
|
||||
- 风险与关注点:
|
||||
- 当前工具仍是“上传文件即时计算”模式,未与现有线路台账做持久化绑定;若后续需要从已导入线路直接发起故障复现,需要再评估与 `line/tower profile` 的联动入口。
|
||||
- 前端完整 `next build` 在当前执行环境被 `SIGKILL/137` 中断,虽然 lint 与 `tsc --noEmit` 已通过,但仍需在资源更充足的 CI/部署环境再确认一次全量构建。
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Form,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type {
|
||||
FaultRecurrenceAnalyzeResponse,
|
||||
FaultRecurrenceDataPoint,
|
||||
FaultRecurrenceStrokeMode,
|
||||
} from "@/types/auth";
|
||||
|
||||
|
||||
type FaultRecurrenceFormValues = {
|
||||
curve_no: number;
|
||||
stroke_mode: FaultRecurrenceStrokeMode;
|
||||
withstand_level_ka: number;
|
||||
};
|
||||
|
||||
|
||||
const CURVE_OPTIONS = [
|
||||
{ value: 1, label: "Heidler" },
|
||||
{ value: 2, label: "双斜角" },
|
||||
{ value: 3, label: "双指数" },
|
||||
] as const;
|
||||
|
||||
const STROKE_OPTIONS = [
|
||||
{ value: "counterstroke", label: "反击" },
|
||||
{ value: "shielding", label: "绕击" },
|
||||
] as const;
|
||||
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 6): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return "-";
|
||||
}
|
||||
return value.toFixed(digits).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
|
||||
function formatSourceMode(sourceMode: string): string {
|
||||
if (sourceMode === "legacy-sections") {
|
||||
return "源端分段文本";
|
||||
}
|
||||
if (sourceMode === "plain-csv") {
|
||||
return "普通 CSV/TXT";
|
||||
}
|
||||
return sourceMode || "-";
|
||||
}
|
||||
|
||||
|
||||
function resultTagColor(status: FaultRecurrenceAnalyzeResponse["result"]["status"]): string {
|
||||
if (status === "no_need") {
|
||||
return "green";
|
||||
}
|
||||
return "blue";
|
||||
}
|
||||
|
||||
|
||||
export default function AdminFaultRecurrencePage() {
|
||||
const { user, initializing, hasPermission, fetchWithAuth } = useAuth();
|
||||
const [form] = Form.useForm<FaultRecurrenceFormValues>();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<FaultRecurrenceAnalyzeResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const canRead = hasPermission("line.read")
|
||||
|| hasPermission("line.manage")
|
||||
|| hasPermission("tower.read")
|
||||
|| hasPermission("tower.manage");
|
||||
|
||||
const analyzeMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!canRead) {
|
||||
throw new Error("缺少 line/tower 相关读取权限");
|
||||
}
|
||||
|
||||
const values = await form.validateFields();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("curve_no", String(values.curve_no));
|
||||
formData.append("stroke_mode", values.stroke_mode);
|
||||
formData.append("withstand_level_ka", String(values.withstand_level_ka));
|
||||
|
||||
const response = await fetchWithAuth("/api/v1/fault-recurrence/analyze", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as FaultRecurrenceAnalyzeResponse;
|
||||
},
|
||||
onSuccess: (payload) => {
|
||||
setResult(payload);
|
||||
setError("");
|
||||
messageApi.success("故障复现计算完成");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(mutationError instanceof Error ? mutationError.message : "故障复现计算失败");
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnsType<FaultRecurrenceDataPoint> = [
|
||||
{
|
||||
title: "波头时间 / μs",
|
||||
dataIndex: "head_time_us",
|
||||
render: (value: number) => formatNumber(value, 4),
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: "波尾时间 / μs",
|
||||
dataIndex: "tail_time_us",
|
||||
render: (value: number) => formatNumber(value, 4),
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: "反击耐雷水平 / kA",
|
||||
dataIndex: "counterstroke_withstand_ka",
|
||||
render: (value: number) => formatNumber(value, 4),
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "绕击耐雷水平 / kA",
|
||||
dataIndex: "shielding_withstand_ka",
|
||||
render: (value: number) => formatNumber(value, 4),
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!selectedFile) {
|
||||
setError("请先选择基础数据文件");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
await analyzeMutation.mutateAsync(selectedFile);
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
setSelectedFile(nextFile);
|
||||
setError("");
|
||||
if (nextFile) {
|
||||
setResult(null);
|
||||
}
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setError("");
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<Card className="surface-card">
|
||||
<div className="flex min-h-64 items-center justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !canRead) {
|
||||
return (
|
||||
<Card className="surface-card">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="暂无访问权限"
|
||||
description="故障复现工具依赖线路/杆塔读取权限,请联系管理员授权 line.read 或 tower.read。"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Space direction="vertical" size={16} className="w-full">
|
||||
<Card title="故障复现" className="surface-card">
|
||||
<Space direction="vertical" size={16} className="w-full">
|
||||
<Typography.Paragraph type="secondary" className="!mb-0">
|
||||
迁移自源端 `FuXian` 工具:上传基础数据文件后,按波形、类型和耐雷水平计算最可能的波头/波尾组合。
|
||||
</Typography.Paragraph>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="输入口径"
|
||||
description="支持源端 <TGanTa>/<XianLu> 分段文本,以及包含“波头时间/μs、波尾时间/μs、反击耐雷水平kA、绕击耐雷水平kA”列的普通 CSV/TXT。"
|
||||
/>
|
||||
<Form<FaultRecurrenceFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
curve_no: 1,
|
||||
stroke_mode: "counterstroke",
|
||||
withstand_level_ka: 10,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<Form.Item<FaultRecurrenceFormValues>
|
||||
label="雷电流波形"
|
||||
name="curve_no"
|
||||
rules={[{ required: true, message: "请选择雷电流波形" }]}
|
||||
className="mb-0 min-w-[160px]"
|
||||
>
|
||||
<Select options={CURVE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item<FaultRecurrenceFormValues>
|
||||
label="类型"
|
||||
name="stroke_mode"
|
||||
rules={[{ required: true, message: "请选择类型" }]}
|
||||
className="mb-0 min-w-[160px]"
|
||||
>
|
||||
<Select options={STROKE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item<FaultRecurrenceFormValues>
|
||||
label="耐雷水平 / kA"
|
||||
name="withstand_level_ka"
|
||||
rules={[{ required: true, message: "请输入耐雷水平" }]}
|
||||
className="mb-0 min-w-[180px]"
|
||||
>
|
||||
<InputNumber min={0.0001} precision={4} className="w-full" />
|
||||
</Form.Item>
|
||||
<div className="flex min-w-[260px] flex-col gap-2">
|
||||
<Typography.Text strong>基础数据文件</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
选择文件
|
||||
</Button>
|
||||
<Typography.Text type={selectedFile ? undefined : "secondary"}>
|
||||
{selectedFile?.name || "未选择文件"}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={analyzeMutation.isPending}
|
||||
disabled={!selectedFile}
|
||||
onClick={() => {
|
||||
void handleAnalyze();
|
||||
}}
|
||||
>
|
||||
开始复现
|
||||
</Button>
|
||||
<Button onClick={handleReset}>
|
||||
清空结果
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.csv,text/plain,text/csv"
|
||||
hidden
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="计算失败"
|
||||
description={error}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card title="复现结果" className="surface-card">
|
||||
{!result ? (
|
||||
<Empty description="选择基础数据文件并执行一次复现计算后,将在这里展示结果。" />
|
||||
) : (
|
||||
<Space direction="vertical" size={16} className="w-full">
|
||||
<Descriptions bordered column={{ xs: 1, sm: 1, md: 2, lg: 3 }}>
|
||||
<Descriptions.Item label="结果状态">
|
||||
<Tag color={resultTagColor(result.result.status)}>
|
||||
{result.result.status === "no_need" ? "No need" : "已匹配"}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="波形">
|
||||
{result.curve_label}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{result.stroke_label}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="耐雷水平 / kA">
|
||||
{formatNumber(result.withstand_level_ka, 4)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="输入文件">
|
||||
{result.source_file_name}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="输入格式">
|
||||
{formatSourceMode(result.source_mode)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="基准点(反击) / kA">
|
||||
{formatNumber(result.reference_counterstroke_ka, 4)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="基准点(绕击) / kA">
|
||||
{formatNumber(result.reference_shielding_ka, 4)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="有效数据条数">
|
||||
{result.point_count}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="匹配波头 / μs">
|
||||
{formatNumber(result.result.head_time_us, 6)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="匹配波尾 / μs">
|
||||
{formatNumber(result.result.tail_time_us, 6)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="概率密度">
|
||||
{formatNumber(result.result.probability_density, 12)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Alert
|
||||
type={result.result.status === "no_need" ? "success" : "info"}
|
||||
showIcon
|
||||
message={result.result.message}
|
||||
/>
|
||||
{!result.reference_point_found ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="未命中 2.6/50 基准点"
|
||||
description="按源端逻辑,当前结果已回退为首条基础数据作为基准。"
|
||||
/>
|
||||
) : null}
|
||||
{result.warnings.length > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="解析提醒"
|
||||
description={
|
||||
<ul className="mb-0 list-disc pl-5">
|
||||
{result.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{result ? (
|
||||
<Card title={`基础数据预览 (${result.point_count} 条)`} className="surface-card">
|
||||
<Table<FaultRecurrenceDataPoint>
|
||||
rowKey={(record, index) => `${record.head_time_us}-${record.tail_time_us}-${index ?? 0}`}
|
||||
columns={columns}
|
||||
dataSource={result.data_points}
|
||||
pagination={{ pageSize: 10, hideOnSinglePage: true }}
|
||||
scroll={{ x: 720 }}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -689,6 +689,41 @@ export type FlAnalysisTowerResultListResponse = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type FaultRecurrenceStrokeMode = "counterstroke" | "shielding";
|
||||
export type FaultRecurrenceResultStatus = "matched" | "no_need";
|
||||
|
||||
export type FaultRecurrenceDataPoint = {
|
||||
head_time_us: number;
|
||||
tail_time_us: number;
|
||||
counterstroke_withstand_ka: number;
|
||||
shielding_withstand_ka: number;
|
||||
};
|
||||
|
||||
export type FaultRecurrenceResult = {
|
||||
status: FaultRecurrenceResultStatus;
|
||||
message: string;
|
||||
head_time_us: number | null;
|
||||
tail_time_us: number | null;
|
||||
probability_density: number | null;
|
||||
};
|
||||
|
||||
export type FaultRecurrenceAnalyzeResponse = {
|
||||
curve_no: number;
|
||||
curve_label: string;
|
||||
stroke_mode: FaultRecurrenceStrokeMode;
|
||||
stroke_label: string;
|
||||
withstand_level_ka: number;
|
||||
source_file_name: string;
|
||||
source_mode: string;
|
||||
point_count: number;
|
||||
reference_counterstroke_ka: number;
|
||||
reference_shielding_ka: number;
|
||||
reference_point_found: boolean;
|
||||
warnings: string[];
|
||||
data_points: FaultRecurrenceDataPoint[];
|
||||
result: FaultRecurrenceResult;
|
||||
};
|
||||
|
||||
export type AtpModelStatus = "enabled" | "disabled";
|
||||
export type AtpModelSourceType = "atpdraw" | "atp" | "manual";
|
||||
export type AtpModelVersionStatus = "draft" | "released" | "archived";
|
||||
|
||||
Reference in New Issue
Block a user