[migrate]:[FL-18][迁移故障复现工具]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-07 17:35:43 +08:00
parent 6244534582
commit 8a2af9135f
13 changed files with 1234 additions and 1 deletions
+16
View File
@@ -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 双轨兼容。
+2
View File
@@ -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)
+34
View File
@@ -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)
+41
View File
@@ -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",
+12
View File
@@ -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",
+14 -1
View File
@@ -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()
+48
View File
@@ -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/部署环境再确认一次全量构建。
+385
View File
@@ -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>
</>
);
}
+35
View File
@@ -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";