diff --git a/MEMORY.md b/MEMORY.md index 3a19dbd..7faab40 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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` +- 输入文件兼容两类口径: + - 源端 `/` 分段文本 + - 含 `波头时间/μs`、`波尾时间/μs`、`反击耐雷水平kA`、`绕击耐雷水平kA` 列的普通 CSV/TXT +- 计算逻辑保持源端 `FuXian.LeiDianFuXian2()`: + - 先按 `2.6/50` 基准点判断是否 `No need!` + - 再按同波头分组做线性插值与概率密度比较,输出最可能的波头/波尾组合 +- 菜单编码为 `admin.fault_recurrence`,默认绑定 `line.read` 口径,并加入 modern seed 与 legacy synthetic menu 双轨兼容。 diff --git a/api/app/api/router.py b/api/app/api/router.py index 1f58a56..715b384 100644 --- a/api/app/api/router.py +++ b/api/app/api/router.py @@ -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) diff --git a/api/app/api/v1/fault_recurrence.py b/api/app/api/v1/fault_recurrence.py new file mode 100644 index 0000000..3a784af --- /dev/null +++ b/api/app/api/v1/fault_recurrence.py @@ -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) diff --git a/api/app/schemas/fault_recurrence.py b/api/app/schemas/fault_recurrence.py new file mode 100644 index 0000000..fab3c42 --- /dev/null +++ b/api/app/schemas/fault_recurrence.py @@ -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 diff --git a/api/app/services/fault_recurrence_service.py b/api/app/services/fault_recurrence_service.py new file mode 100644 index 0000000..023060e --- /dev/null +++ b/api/app/services/fault_recurrence_service.py @@ -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 = {"", ""} +LEGACY_SECTION_END_MARKERS = {"", ""} +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") diff --git a/api/app/services/legacy_admin_rbac_service.py b/api/app/services/legacy_admin_rbac_service.py index 80ffa11..5d7c7e3 100644 --- a/api/app/services/legacy_admin_rbac_service.py +++ b/api/app/services/legacy_admin_rbac_service.py @@ -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", diff --git a/api/app/services/legacy_authz_service.py b/api/app/services/legacy_authz_service.py index f100853..94adde4 100644 --- a/api/app/services/legacy_authz_service.py +++ b/api/app/services/legacy_authz_service.py @@ -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", diff --git a/api/app/services/seed_service.py b/api/app/services/seed_service.py index 696022a..38fceba 100644 --- a/api/app/services/seed_service.py +++ b/api/app/services/seed_service.py @@ -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": [], } diff --git a/api/tests/test_fault_recurrence_contract.py b/api/tests/test_fault_recurrence_contract.py new file mode 100644 index 0000000..bae4b26 --- /dev/null +++ b/api/tests/test_fault_recurrence_contract.py @@ -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() diff --git a/api/tests/test_fault_recurrence_service.py b/api/tests/test_fault_recurrence_service.py new file mode 100644 index 0000000..4504701 --- /dev/null +++ b/api/tests/test_fault_recurrence_service.py @@ -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 = """ + +任务编号,杆塔模型,波头时间/μs,波尾时间/μs,反击耐雷水平kA,绕击耐雷水平kA +1,Model-A,3.1,55,18,16 +2,Model-B,2.6,50,12,10 + +""" + 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() diff --git a/memory/2026-06-07.md b/memory/2026-06-07.md index e11d880..f63a6f8 100644 --- a/memory/2026-06-07.md +++ b/memory/2026-06-07.md @@ -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/部署环境再确认一次全量构建。 diff --git a/web/src/app/admin/fault-recurrence/page.tsx b/web/src/app/admin/fault-recurrence/page.tsx new file mode 100644 index 0000000..b97825b --- /dev/null +++ b/web/src/app/admin/fault-recurrence/page.tsx @@ -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(); + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [result, setResult] = useState(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 = [ + { + 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) => { + 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 ( + +
+ +
+
+ ); + } + + if (!user || !canRead) { + return ( + + + + ); + } + + return ( + <> + {contextHolder} + + + + + 迁移自源端 `FuXian` 工具:上传基础数据文件后,按波形、类型和耐雷水平计算最可能的波头/波尾组合。 + + + + form={form} + layout="vertical" + initialValues={{ + curve_no: 1, + stroke_mode: "counterstroke", + withstand_level_ka: 10, + }} + > +
+ + label="雷电流波形" + name="curve_no" + rules={[{ required: true, message: "请选择雷电流波形" }]} + className="mb-0 min-w-[160px]" + > + + + + label="耐雷水平 / kA" + name="withstand_level_ka" + rules={[{ required: true, message: "请输入耐雷水平" }]} + className="mb-0 min-w-[180px]" + > + + +
+ 基础数据文件 + + + + {selectedFile?.name || "未选择文件"} + + +
+ + + + +
+ + +
+
+ + {error ? ( + + ) : null} + + + {!result ? ( + + ) : ( + + + + + {result.result.status === "no_need" ? "No need" : "已匹配"} + + + + {result.curve_label} + + + {result.stroke_label} + + + {formatNumber(result.withstand_level_ka, 4)} + + + {result.source_file_name} + + + {formatSourceMode(result.source_mode)} + + + {formatNumber(result.reference_counterstroke_ka, 4)} + + + {formatNumber(result.reference_shielding_ka, 4)} + + + {result.point_count} + + + {formatNumber(result.result.head_time_us, 6)} + + + {formatNumber(result.result.tail_time_us, 6)} + + + {formatNumber(result.result.probability_density, 12)} + + + + {!result.reference_point_found ? ( + + ) : null} + {result.warnings.length > 0 ? ( + + {result.warnings.map((warning) => ( +
  • {warning}
  • + ))} + + } + /> + ) : null} +
    + )} +
    + + {result ? ( + + + 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 }} + /> + + ) : null} +
    + + ); +} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 096b467..a3265de 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -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";