From e2ea1853f19b44ea402a8bf9706d69ee9448617a Mon Sep 17 00:00:00 2001 From: JIANG Date: Wed, 11 Mar 2026 16:40:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=88=86=E7=AE=A1=E4=BE=A6?= =?UTF-8?q?=E6=B5=8B=E9=9D=A2=E6=9D=BF=E5=8F=8A=E7=9B=B8=E5=85=B3=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../burst-detection/loading.tsx | 5 + .../burst-detection/page.tsx | 16 + src/app/_refine_context.tsx | 12 +- .../BurstDetection/AnalysisParameters.tsx | 471 ++++++++++++++ .../BurstDetection/BurstDetectionPanel.tsx | 153 +++++ .../olmap/BurstDetection/DetectionResults.tsx | 610 ++++++++++++++++++ .../olmap/BurstDetection/SchemeQuery.tsx | 350 ++++++++++ src/components/olmap/BurstDetection/types.ts | 77 +++ 8 files changed, 1692 insertions(+), 2 deletions(-) create mode 100644 src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx create mode 100644 src/app/(main)/hydraulic-simulation/burst-detection/page.tsx create mode 100644 src/components/olmap/BurstDetection/AnalysisParameters.tsx create mode 100644 src/components/olmap/BurstDetection/BurstDetectionPanel.tsx create mode 100644 src/components/olmap/BurstDetection/DetectionResults.tsx create mode 100644 src/components/olmap/BurstDetection/SchemeQuery.tsx create mode 100644 src/components/olmap/BurstDetection/types.ts diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx b/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx new file mode 100644 index 0000000..2c57921 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx @@ -0,0 +1,5 @@ +import { MapSkeleton } from "@components/loading/MapSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx b/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx new file mode 100644 index 0000000..df2ddb9 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel"; + +export default function Home() { + return ( +
+ + + + +
+ ); +} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index ecd54d9..b2f81f5 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -18,13 +18,12 @@ import { ProjectProvider } from "@/contexts/ProjectContext"; import { useAuthStore } from "@/store/authStore"; import { LiaNetworkWiredSolid } from "react-icons/lia"; -import { TbDatabaseEdit, TbLocationPin } from "react-icons/tb"; +import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; import { AiOutlinePartition } from "react-icons/ai"; import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { - Analytics as AnalyticsIcon, MyLocation as MyLocationIcon, Search as SearchIcon, } from "@mui/icons-material"; @@ -193,6 +192,15 @@ const App = (props: React.PropsWithChildren) => { label: "爆管定位", }, }, + { + name: "爆管侦测", + list: "/hydraulic-simulation/burst-detection", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "爆管侦测", + }, + }, { name: "DMA 漏损识别", list: "/hydraulic-simulation/dma-leak-detection", diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx new file mode 100644 index 0000000..96725b4 --- /dev/null +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -0,0 +1,471 @@ +"use client"; + +import React, { useMemo, useState, useCallback } from "react"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + Box, + Button, + CircularProgress, + Collapse, + FormControl, + MenuItem, + Select, + TextField, + Typography, + IconButton, +} from "@mui/material"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import { useNotification } from "@refinedev/core"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +import { api } from "@/lib/api"; +import { NETWORK_NAME, config } from "@config/config"; +import { BurstDetectionResult } from "./types"; + +interface Props { + onResult: (result: BurstDetectionResult) => void; +} + +interface SchemeItem { + scheme_id: number; + scheme_name: string; + scheme_type: string; + create_time: string; + scheme_start_time: string; + scheme_detail?: { + modify_total_duration: number; + }; +} + +const AnalysisParameters: React.FC = ({ onResult }) => { + const { open } = useNotification(); + const [schemeName, setSchemeName] = useState(`Burst_Detection_${Date.now()}`); + const [dataSource, setDataSource] = useState<"monitoring" | "simulation">("monitoring"); + const [schemes, setSchemes] = useState([]); + const [selectedSchemeId, setSelectedSchemeId] = useState(""); + const [schemeLoading, setSchemeLoading] = useState(false); + const [scadaStart, setScadaStart] = useState(dayjs().subtract(3, "day")); + const [scadaEnd, setScadaEnd] = useState(dayjs()); + const [mu, setMu] = useState(100); + const [pointsPerDay, setPointsPerDay] = useState(96); + const [nEstimators, setNEstimators] = useState(50); + const [contaminationInput, setContaminationInput] = useState("auto"); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [running, setRunning] = useState(false); + const isSimulationMode = dataSource === "simulation"; + + const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { + const start = dayjs(scheme.scheme_start_time); + const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; + const end = start.add(durationSeconds, "second"); + + setScadaStart(start); + setScadaEnd(end); + }, []); + + const fetchSchemes = useCallback( + async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { + if (schemeLoading || (!force && schemes.length > 0)) return; + + setSchemeLoading(true); + try { + const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, { + params: { network: NETWORK_NAME }, + }); + const burstSchemes = (response.data as SchemeItem[]).filter( + (scheme) => scheme.scheme_type === "burst_analysis", + ); + + setSchemes(burstSchemes); + + if (selectedSchemeId) { + const matchedScheme = burstSchemes.find( + (scheme) => scheme.scheme_id === selectedSchemeId, + ); + if (matchedScheme) { + applySchemeTimeRange(matchedScheme); + } else { + setSelectedSchemeId(""); + } + } + + if (notify) { + open?.({ + type: "success", + message: "方案列表已刷新", + description: `当前可选爆管分析方案 ${burstSchemes.length} 个`, + }); + } + } catch (error: any) { + open?.({ + type: "error", + message: "刷新方案失败", + description: + error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表", + }); + } finally { + setSchemeLoading(false); + } + }, + [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId], + ); + + const handleDataSourceChange = (value: "monitoring" | "simulation") => { + setDataSource(value); + if (value === "simulation") { + void fetchSchemes(); + } + }; + + const handleSchemeSelect = (schemeId: number) => { + setSelectedSchemeId(schemeId); + const scheme = schemes.find((item) => item.scheme_id === schemeId); + if (scheme) { + applySchemeTimeRange(scheme); + } + }; + + const timeWindowValid = useMemo(() => { + if (!scadaStart || !scadaEnd) return false; + return scadaEnd.diff(scadaStart, "day", true) >= 2; + }, [scadaEnd, scadaStart]); + + const contaminationValue = useMemo(() => { + const normalized = contaminationInput.trim().toLowerCase(); + if (!normalized || normalized === "auto") { + return "auto" as const; + } + const parsed = Number(normalized); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) { + return null; + } + return parsed; + }, [contaminationInput]); + + const isValid = + Boolean(scadaStart && scadaEnd) && + timeWindowValid && + Number.isFinite(mu) && + mu > 0 && + Number.isFinite(pointsPerDay) && + pointsPerDay > 0 && + Number.isFinite(nEstimators) && + nEstimators > 0 && + contaminationValue !== null && + (dataSource !== "simulation" || Boolean(selectedSchemeId)); + + const handleRun = async () => { + if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) { + open?.({ + type: "error", + message: "参数不完整", + description: "请检查时间范围(至少2天)和高级参数是否填写正确。", + }); + return; + } + + setRunning(true); + open?.({ + key: "burst-detection-analysis", + type: "progress", + message: "正在执行爆管侦测", + description: "正在读取数据并计算异常分数。", + undoableTimeout: 3, + }); + + try { + const selectedScheme = + dataSource === "simulation" + ? schemes.find((item) => item.scheme_id === selectedSchemeId) + : undefined; + + const response = await api.post("/api/v1/burst-detection/detect/", { + network: NETWORK_NAME, + data_source: dataSource, + scheme_name: schemeName.trim() || undefined, + scada_start: scadaStart.toISOString(), + scada_end: scadaEnd.toISOString(), + mu, + points_per_day: pointsPerDay, + iforest_params: { + n_estimators: nEstimators, + contamination: contaminationValue, + }, + simulation_scheme_name: selectedScheme?.scheme_name, + simulation_scheme_type: selectedScheme?.scheme_type, + }); + + onResult({ + ...(response.data as BurstDetectionResult), + scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name, + algorithm_params: { + mu, + points_per_day: pointsPerDay, + iforest_params: { + n_estimators: nEstimators, + contamination: contaminationValue, + }, + }, + }); + + open?.({ + key: "burst-detection-analysis", + type: "success", + message: "爆管侦测完成", + description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, + }); + } catch (error: any) { + open?.({ + key: "burst-detection-analysis", + type: "error", + message: "侦测失败", + description: error?.response?.data?.detail ?? error?.message ?? "请求失败", + }); + } finally { + setRunning(false); + } + }; + + return ( + + + + + 方案名称 + + setSchemeName(event.target.value)} + placeholder="请输入方案名称" + fullWidth + size="small" + /> + + + + + 数据来源 + + + + + + + {isSimulationMode && ( + + + 选择爆管分析方案 + + + + + + void fetchSchemes({ force: true, notify: true })} + disabled={schemeLoading} + aria-label="刷新爆管分析方案" + sx={{ + border: "1px solid", + borderColor: "divider", + borderRadius: 1, + }} + > + {schemeLoading ? ( + + ) : ( + + )} + + + + )} + + + + + + 侦测开始时间 + + + + + + 侦测结束时间 + + + + + + + + 当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。 + + + + setAdvancedOpen((prev) => !prev)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + setAdvancedOpen((prev) => !prev); + } + }} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + px: 1.25, + py: 0.75, + cursor: "pointer", + backgroundColor: "transparent", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + + 高级参数 + + + + + + + setMu(Number(event.target.value))} + size="small" + fullWidth + inputProps={{ min: 1 }} + /> + setPointsPerDay(Number(event.target.value))} + size="small" + fullWidth + inputProps={{ min: 1 }} + /> + setNEstimators(Number(event.target.value))} + size="small" + fullWidth + inputProps={{ min: 1 }} + /> + setContaminationInput(event.target.value)} + size="small" + fullWidth + helperText="填写 auto 或 0~0.5 之间的小数。" + error={contaminationValue === null} + /> + + + + + + + + + + + + ); +}; + +export default AnalysisParameters; diff --git a/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx new file mode 100644 index 0000000..d85182b --- /dev/null +++ b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx @@ -0,0 +1,153 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { Box, Drawer, IconButton, Tab, Tabs, Tooltip, Typography } from "@mui/material"; +import { + Analytics as AnalyticsIcon, + ChevronLeft, + ChevronRight, + FormatListBulleted, + Search as SearchIcon, +} from "@mui/icons-material"; +import AnalysisParameters from "./AnalysisParameters"; +import DetectionResults from "./DetectionResults"; +import SchemeQuery from "./SchemeQuery"; +import { BurstDetectionResult } from "./types"; + +const TabPanel = ({ + value, + index, + children, +}: { + value: number; + index: number; + children: React.ReactNode; +}) => ( + +); + +const BurstDetectionPanel: React.FC = () => { + const [open, setOpen] = useState(true); + const [tab, setTab] = useState(0); + const [result, setResult] = useState(null); + + const drawerWidth = 450; + const panelTitle = "爆管侦测"; + + const handleResult = useCallback((payload: BurstDetectionResult) => { + setResult(payload); + setTab(2); + }, []); + + return ( + <> + {!open && ( + setOpen(true)} + sx={{ zIndex: 1300 }} + > + + + + {panelTitle} + + + + + )} + + + + + + + + {panelTitle} + + + + setOpen(false)} sx={{ color: "primary.contrastText" }}> + + + + + + + setTab(value)} + variant="fullWidth" + sx={{ + minHeight: 48, + "& .MuiTab-root": { + minHeight: 48, + textTransform: "none", + fontSize: "0.875rem", + fontWeight: 500, + transition: "all 0.2s", + }, + "& .Mui-selected": { + color: "#257DD4", + }, + "& .MuiTabs-indicator": { + backgroundColor: "#257DD4", + }, + }} + > + } iconPosition="start" label="侦测参数" /> + } iconPosition="start" label="方案查询" /> + } iconPosition="start" label="侦测结果" /> + + + + + + + + + + + + + + + + ); +}; + +export default BurstDetectionPanel; diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx new file mode 100644 index 0000000..6b9d914 --- /dev/null +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -0,0 +1,610 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Button, Chip, Tooltip, Typography } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { zhCN } from "@mui/x-data-grid/locales"; +import { + FormatListBulleted, + InfoOutlined as InfoOutlinedIcon, + Room as RoomIcon, + ShowChart as ShowChartIcon, + CheckCircleOutline as CheckCircleIcon, + ErrorOutline as ErrorOutlineIcon, +} from "@mui/icons-material"; +import ReactECharts from "echarts-for-react"; +import dayjs from "dayjs"; +import { useMap } from "@components/olmap/core/MapComponent"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { GeoJSON } from "ol/format"; +import Feature from "ol/Feature"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Circle, Fill, Stroke, Style } from "ol/style"; +import { bbox, featureCollection } from "@turf/turf"; +import { BurstDetectionResult, BurstDetectionRow } from "./types"; + +interface Props { + result: BurstDetectionResult | null; +} + +interface MetricCardProps { + label: string; + value: string; + hint?: string; + tone: "blue" | "orange" | "purple" | "green"; +} + +const toneStyles: Record< + MetricCardProps["tone"], + { bg: string; border: string; text: string; darkText: string } +> = { + blue: { + bg: "from-blue-50 to-blue-100", + border: "border-blue-200", + text: "text-blue-700", + darkText: "text-blue-900", + }, + orange: { + bg: "from-orange-50 to-orange-100", + border: "border-orange-200", + text: "text-orange-700", + darkText: "text-orange-900", + }, + purple: { + bg: "from-purple-50 to-purple-100", + border: "border-purple-200", + text: "text-purple-700", + darkText: "text-purple-900", + }, + green: { + bg: "from-green-50 to-green-100", + border: "border-green-200", + text: "text-green-700", + darkText: "text-green-900", + }, +}; + +const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { + const style = toneStyles[tone]; + return ( + + + {label} + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + ); +}; + +const EmptyState = () => ( + + + + + + 等待侦测结果 + + + 提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。 + + +); + +const getScoreLevel = (score: number) => { + if (score <= -0.6) return { label: "高风险", color: "error" as const }; + if (score <= -0.2) return { label: "需关注", color: "warning" as const }; + return { label: "正常", color: "success" as const }; +}; + +const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"); + +const DetectionResults: React.FC = ({ result }) => { + const map = useMap(); + const highlightLayerRef = useRef | null>(null); + const [highlightFeatures, setHighlightFeatures] = useState([]); + const [selectedDay, setSelectedDay] = useState(null); + + useEffect(() => { + if (!map) return; + + const layer = new VectorLayer({ + source: new VectorSource(), + style: new Style({ + stroke: new Stroke({ color: "#ef4444", width: 4 }), + image: new Circle({ + radius: 7, + fill: new Fill({ color: "#ef4444" }), + stroke: new Stroke({ color: "#fff", width: 2 }), + }), + zIndex: 999, + }), + properties: { + name: "爆管侦测高亮", + value: "burst_detection_highlight", + }, + }); + + map.addLayer(layer); + highlightLayerRef.current = layer; + + return () => { + highlightLayerRef.current = null; + map.removeLayer(layer); + }; + }, [map]); + + useEffect(() => { + const source = highlightLayerRef.current?.getSource(); + if (!source) return; + source.clear(); + highlightFeatures.forEach((feature) => source.addFeature(feature)); + }, [highlightFeatures]); + + const defaultSelectedDay = useMemo( + () => + result?.summary?.most_anomalous_day ?? + result?.summary?.latest_day?.Day ?? + result?.rows[0]?.Day ?? + null, + [result], + ); + + const activeSelectedDay = selectedDay ?? defaultSelectedDay; + + const selectedRow = useMemo(() => { + if (!result || activeSelectedDay === null) return null; + return result.rows.find((row) => row.Day === activeSelectedDay) ?? null; + }, [activeSelectedDay, result]); + + const scoreSeries = useMemo( + () => + result?.rows.map((row) => ({ + value: [row.Day, Number(row.Score.toFixed(4))], + itemStyle: { + color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981", + }, + })) ?? [], + [result], + ); + + const rankingSeries = useMemo( + () => + [...(result?.summary?.latest_sensor_rankings ?? [])] + .sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value) + .map((item) => ({ + name: item.sensor_node, + value: Number(item.latest_high_frequency_value.toFixed(4)), + })), + [result], + ); + + const locateSensors = async (sensorIds: string[]) => { + if (!map || sensorIds.length === 0) return; + + let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat"); + if (features.length === 0) { + features = await queryFeaturesByIds(sensorIds, "geo_junctions"); + } + if (features.length === 0) return; + + setHighlightFeatures(features); + + const geojsonFormat = new GeoJSON(); + const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); + // @ts-ignore turf typing with ol geojson objects + const extent = bbox(featureCollection(geojsonFeatures)); + map.getView().fit(extent, { + maxZoom: 18, + duration: 1000, + padding: [100, 100, 100, 100], + }); + }; + + if (!result) { + return ; + } + + const latestDay = result.summary?.latest_day; + const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0); + const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null; + const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0); + const isBurstDetected = result.summary.burst_detected; + + const chartOption = { + tooltip: { + trigger: "axis", + formatter: (params: Array<{ data: { value: [number, number] } }>) => { + const point = params[0]?.data?.value; + if (!point) return "-"; + return `侦测日第 ${point[0]} 天
异常分数:${point[1]}`; + }, + }, + grid: { top: 30, left: 40, right: 20, bottom: 35 }, + xAxis: { + type: "category", + name: "侦测日", + data: result.rows.map((row) => row.Day), + axisLabel: { fontSize: 10 }, + }, + yAxis: { + type: "value", + name: "异常分数", + axisLabel: { fontSize: 10 }, + }, + series: [ + { + type: "line", + smooth: true, + symbolSize: 8, + data: scoreSeries, + lineStyle: { color: "#2563eb", width: 2 }, + markLine: { + symbol: "none", + lineStyle: { type: "dashed", color: "#94a3b8" }, + data: [{ yAxis: 0 }], + }, + }, + ], + }; + + const rankingOption = { + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + }, + grid: { top: 20, left: 70, right: 20, bottom: 20 }, + xAxis: { type: "value", axisLabel: { fontSize: 10 } }, + yAxis: { + type: "category", + data: rankingSeries.map((item) => item.name), + axisLabel: { fontSize: 10 }, + }, + series: [ + { + type: "bar", + data: rankingSeries.map((item) => ({ + value: item.value, + itemStyle: { + color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981", + }, + })), + barWidth: 14, + }, + ], + }; + + const columns: GridColDef[] = [ + { + field: "Day", + headerName: "侦测日", + width: 96, + valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"), + }, + { + field: "Score", + headerName: "异常分数", + width: 120, + valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"), + }, + { + field: "IsBurst", + headerName: "判定结果", + width: 120, + renderCell: ({ value }) => { + const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const }; + return ; + }, + }, + ]; + + const rows = result.rows.map((row) => ({ id: row.Day, ...row })); + + return ( + + + {/* Status Banner */} + + {isBurstDetected ? ( + + ) : ( + + )} + + + {isBurstDetected + ? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)` + : "未侦测到爆管异常"} + + + {isBurstDetected + ? "建议检查异常日期的压力波动情况" + : "当前时间窗口内数据特征平稳,符合历史模式"} + + + + + {/* Header */} + + + + + {result.scheme_name || "爆管侦测结果"} + + + + {result.username ? ( + + ) : null} + + + + + {/* Configuration Summary */} + + + + 时间窗口: + + {formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)} + + + + + 数据来源: + + {(() => { + const ds = result.data_source; + const os = result.observed_source; + if (ds === "simulation") return "模拟数据"; + if (ds === "monitoring") return "监测数据"; + if (os === "simulation_scheme_timerange") return "模拟数据"; + if (os === "backend_timerange") return "监测数据"; + return os || "-"; + })()} + + + + + {/* Metrics Grid */} + + 0 ? "orange" : "green"} + /> + + + + + + + {/* Score Trend Chart */} + + + + + + 异常分数趋势 + + + + + + + + { + const day = params?.data?.value?.[0]; + if (typeof day === "number") { + setSelectedDay(day); + } + }, + }} + /> + + + + {/* Selected Day Interpretation */} + + + + 选中日解读 + + {selectedRow ? ( + + ) : null} + + {selectedRow ? ( + + + + + + 异常分数:{selectedRow.Score.toFixed(4)} + + + 模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1)" : "正常日(Prediction = 1)"} + + + 解读建议: + {selectedRow.Score <= -0.6 + ? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。" + : selectedRow.Score <= -0.2 + ? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。" + : "未见明显异常,可作为基线日参考。"} + + + ) : ( + + 请在趋势图或表格中选择一天查看详细解释。 + + )} + + + {/* Latest Sensor Rankings */} + + + + 最新测点高频特征排名 + + + 仅展示最新一天 + + + + + + + {result.summary.latest_sensor_rankings.slice(0, 5).map((item) => ( + + ))} + + + + {/* Results Table */} + + + + + + 结果表格 + + + + + + setSelectedDay(Number(params.row.Day))} + /> + + + + ); +}; + +export default DetectionResults; diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx new file mode 100644 index 0000000..a00eaec --- /dev/null +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -0,0 +1,350 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + Collapse, + FormControlLabel, + IconButton, + Tooltip, + Typography, +} from "@mui/material"; +import { InfoOutlined as InfoIcon } from "@mui/icons-material"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +import { useNotification } from "@refinedev/core"; +import { api } from "@/lib/api"; +import { NETWORK_NAME } from "@config/config"; +import { + BurstDetectionResult, + BurstDetectionSchemeDetail, + BurstDetectionSchemeRecord, +} from "./types"; + +interface Props { + onViewResult: (result: BurstDetectionResult) => void; +} + +const SchemeQuery: React.FC = ({ onViewResult }) => { + const { open } = useNotification(); + const [queryAll, setQueryAll] = useState(true); + const [queryDate, setQueryDate] = useState(dayjs()); + const [schemes, setSchemes] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState(null); + + const buildDisplayResult = ( + scheme: Pick, + detail?: BurstDetectionSchemeDetail, + ): BurstDetectionResult | null => { + const payload = detail?.result_payload; + const summary = detail?.result_summary; + const fallbackLatestDay = summary?.latest_day; + + if (!payload && !summary) return null; + + return { + network: payload?.network ?? detail?.network ?? NETWORK_NAME, + sensor_nodes: payload?.sensor_nodes ?? detail?.sensor_nodes ?? [], + observed_source: payload?.observed_source ?? detail?.observed_source ?? "stored_scheme", + sample_count: payload?.sample_count ?? 0, + points_per_day: payload?.points_per_day ?? detail?.algorithm_params?.points_per_day ?? 1440, + day_count: payload?.day_count ?? payload?.rows?.length ?? 0, + rows: payload?.rows ?? (fallbackLatestDay ? [fallbackLatestDay] : []), + summary: + payload?.summary ?? + (summary + ? summary + : { + burst_detected: false, + latest_day: fallbackLatestDay ?? { Day: 0, Score: 0, Prediction: 1, IsBurst: false }, + most_anomalous_day: 0, + anomaly_days: [], + anomaly_day_count: 0, + latest_sensor_rankings: [], + }), + scada_window: payload?.scada_window ?? detail?.scada_window, + scheme_name: payload?.scheme_name ?? scheme.scheme_name, + username: payload?.username ?? scheme.username, + create_time: payload?.create_time ?? scheme.create_time, + algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params, + }; + }; + + const handleQuery = async () => { + setLoading(true); + try { + const params: Record = { network: NETWORK_NAME }; + if (!queryAll && queryDate) { + params.query_date = queryDate.startOf("day").toISOString(); + } + + const response = await api.get("/api/v1/burst-detection/schemes/", { params }); + setSchemes(response.data); + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${response.data.length} 条侦测记录。`, + }); + } catch (error: any) { + open?.({ + type: "error", + message: "查询失败", + description: error?.response?.data?.detail ?? "无法获取侦测方案列表", + }); + } finally { + setLoading(false); + } + }; + + const handleViewSchemeResult = async (schemeName: string) => { + try { + const response = await api.get( + `/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`, + { params: { network: NETWORK_NAME } }, + ); + const schemeRecord = response.data as BurstDetectionSchemeRecord & { + result_payload?: BurstDetectionResult; + }; + const normalizedResult = + schemeRecord.result_payload ?? + buildDisplayResult( + { + scheme_name: schemeRecord.scheme_name, + username: schemeRecord.username, + create_time: schemeRecord.create_time, + }, + schemeRecord.scheme_detail, + ); + + if (!normalizedResult) { + throw new Error("方案详情缺少侦测结果数据"); + } + + onViewResult(normalizedResult); + open?.({ + type: "success", + message: "方案加载成功", + description: `已加载方案:${schemeName}`, + }); + } catch (error: any) { + open?.({ + type: "error", + message: "查看详情失败", + description: error?.response?.data?.detail ?? error?.message ?? "无法获取方案详情", + }); + } + }; + + return ( + + + + + setQueryAll(event.target.checked)} + /> + } + label={查询全部} + className="m-0" + /> + + + + + + + + + + {schemes.length === 0 ? ( + + 暂无侦测方案 + + 运行一次展示版侦测后,可在这里回看历史结果。 + + + ) : ( + + + 共 {schemes.length} 条记录 + + {schemes.map((scheme) => { + const summary = scheme.scheme_detail?.result_summary; + const payload = scheme.scheme_detail?.result_payload; + const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false; + const anomalyDayCount = + payload?.summary?.anomaly_day_count ?? summary?.anomaly_day_count ?? 0; + const mostAnomalousDay = + payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-"; + const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0; + + return ( + + + + + + + {scheme.scheme_name} + + + + + 创建时间:{dayjs(scheme.create_time).format("YYYY-MM-DD HH:mm")} + + + + + + setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id) + } + color="primary" + className="p-1" + > + + + + + + + + + + 异常天数 + + + {anomalyDayCount} + + + + + 最异常日 + + + {isBurst + ? typeof mostAnomalousDay === "number" + ? `第 ${mostAnomalousDay} 天` + : mostAnomalousDay + : "无"} + + + + + 测点数 + + + {sensorCount} + + + + + + + + + + 数据来源: + + + {(() => { + const ds = payload?.data_source; + const os = payload?.observed_source ?? scheme.scheme_detail?.observed_source; + if (ds === "simulation") return "模拟数据"; + if (ds === "monitoring") return "监测数据"; + if (os === "simulation_scheme_timerange") return "模拟数据"; + if (os === "backend_timerange") return "监测数据"; + return os || "-"; + })()} + + + + + 时间窗口: + + + {payload?.scada_window?.start + ? `${dayjs(payload.scada_window.start).format("MM-DD HH:mm")} ~ ${dayjs( + payload.scada_window.end, + ).format("MM-DD HH:mm")}` + : "-"} + + + + + 算法参数: + + + 频域截断系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} + ,每日采样点数: + {scheme.scheme_detail?.algorithm_params?.points_per_day ?? + payload?.algorithm_params?.points_per_day ?? + "-"} + + + + + + + + + + + ); + })} + + )} + + + ); +}; + +export default SchemeQuery; diff --git a/src/components/olmap/BurstDetection/types.ts b/src/components/olmap/BurstDetection/types.ts new file mode 100644 index 0000000..235edac --- /dev/null +++ b/src/components/olmap/BurstDetection/types.ts @@ -0,0 +1,77 @@ +export interface BurstDetectionRow { + Day: number; + Score: number; + Prediction: number; + IsBurst: boolean; +} + +export interface BurstDetectionSensorRanking { + sensor_node: string; + latest_high_frequency_value: number; +} + +export interface BurstDetectionSummary { + burst_detected: boolean; + latest_day: BurstDetectionRow; + most_anomalous_day: number; + anomaly_days: number[]; + anomaly_day_count: number; + latest_sensor_rankings: BurstDetectionSensorRanking[]; +} + +export interface BurstDetectionAlgorithmParams { + mu?: number; + points_per_day?: number; + iforest_params?: { + n_estimators?: number; + contamination?: number | "auto"; + random_state?: number; + }; +} + +export interface BurstDetectionResult { + network: string; + sensor_nodes: string[]; + observed_source: string; + sample_count: number; + points_per_day: number; + day_count: number; + rows: BurstDetectionRow[]; + summary: BurstDetectionSummary; + scada_window?: { + start?: string; + end?: string; + }; + scheme_name?: string; + username?: string; + create_time?: string; + data_source?: "monitoring" | "simulation"; + simulation_scheme?: { + name?: string; + type?: string; + }; + algorithm_params?: BurstDetectionAlgorithmParams; +} + +export interface BurstDetectionSchemeDetail { + network?: string; + sensor_nodes?: string[]; + observed_source?: string; + scada_window?: { + start?: string; + end?: string; + }; + algorithm_params?: BurstDetectionAlgorithmParams; + result_summary?: BurstDetectionSummary; + result_payload?: BurstDetectionResult; +} + +export interface BurstDetectionSchemeRecord { + scheme_id: number; + scheme_name: string; + scheme_type?: string; + create_time: string; + scheme_start_time?: string; + username?: string; + scheme_detail?: BurstDetectionSchemeDetail; +}