From 7af90e495d9547f1eecead9ac34ba5f7a8f91921 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 17 Jul 2026 16:24:04 +0800 Subject: [PATCH] feat(burst-detection): update analysis parameters --- .../BurstDetection/AnalysisParameters.test.ts | 57 ++ .../BurstDetection/AnalysisParameters.tsx | 587 ++++++----------- .../olmap/BurstDetection/DetectionResults.tsx | 596 ++++++------------ .../olmap/BurstDetection/SchemeQuery.tsx | 45 +- src/components/olmap/BurstDetection/types.ts | 30 + 5 files changed, 521 insertions(+), 794 deletions(-) create mode 100644 src/components/olmap/BurstDetection/AnalysisParameters.test.ts diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.test.ts b/src/components/olmap/BurstDetection/AnalysisParameters.test.ts new file mode 100644 index 0000000..46be13f --- /dev/null +++ b/src/components/olmap/BurstDetection/AnalysisParameters.test.ts @@ -0,0 +1,57 @@ +import dayjs from "dayjs"; +import { + buildBurstDetectionRequest, + createBurstDetectionAnalysisParametersState, + parseScadaFrequencyMinutes, + resolvePressureSamplingInterval, +} from "./AnalysisParameters"; + +describe("burst detection request", () => { + it("requests the latest complete monitoring time by default", () => { + const state = createBurstDetectionAnalysisParametersState(); + state.schemeName = " latest-case "; + + expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({ + network: "fengyang", + scheme_name: "latest-case", + sampling_interval_minutes: 15, + }); + expect(state.detectionMode).toBe("latest"); + expect(state.targetTime?.minute() % 15).toBe(0); + }); + + it("sends one target time for historical replay", () => { + const targetTime = dayjs("2026-06-20T13:30:00+08:00"); + + expect( + buildBurstDetectionRequest( + { + schemeName: "history-case", + detectionMode: "historical", + targetTime, + samplingIntervalMinutes: 30, + samplingIntervalSource: "manual", + }, + "fengyang", + ), + ).toEqual({ + network: "fengyang", + scheme_name: "history-case", + sampling_interval_minutes: 30, + target_time: targetTime.toISOString(), + }); + }); + + it("uses the dominant pressure SCADA frequency as the editable default", () => { + expect(parseScadaFrequencyMinutes("0:15:00")).toBe(15); + expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60); + expect( + resolvePressureSamplingInterval([ + { type: "pressure", transmission_frequency: "0:15:00" }, + { type: "pressure", transmission_frequency: "0:15:00" }, + { type: "pressure", transmission_frequency: "0:30:00" }, + { type: "pipe_flow", transmission_frequency: "1:00:00" }, + ]), + ).toBe(15); + }); +}); diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index 4a11577..9ff1dc5 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -1,19 +1,14 @@ "use client"; -import React, { useMemo, useState, useCallback } from "react"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import RefreshIcon from "@mui/icons-material/Refresh"; +import React, { useEffect, useMemo, useState } from "react"; 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"; @@ -23,7 +18,7 @@ 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 { NETWORK_NAME } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { BurstDetectionResult } from "./types"; @@ -33,186 +28,149 @@ interface Props { onStateChange?: (state: BurstDetectionAnalysisParametersState) => void; } -export interface SchemeItem { - scheme_id: number; - scheme_name: string; - scheme_type: string; - create_time: string; - scheme_start_time: string; - scheme_detail?: { - modify_total_duration: number; - }; -} - export interface BurstDetectionAnalysisParametersState { schemeName: string; - dataSource: "monitoring" | "simulation"; - schemes: SchemeItem[]; - selectedSchemeId: number | ""; - scadaStart: Dayjs | null; - scadaEnd: Dayjs | null; - mu: number; - pointsPerDay: number; - nEstimators: number; - contaminationInput: string; - advancedOpen: boolean; + detectionMode: "latest" | "historical"; + targetTime: Dayjs | null; + samplingIntervalMinutes: number; + samplingIntervalSource: "metadata" | "manual"; } +interface ScadaInfoItem { + type?: string; + transmission_frequency?: string | number | null; +} + +const currentQuarterHour = () => { + const now = dayjs().second(0).millisecond(0); + return now.minute(Math.floor(now.minute() / 15) * 15); +}; + export const createBurstDetectionAnalysisParametersState = (): BurstDetectionAnalysisParametersState => ({ schemeName: `Burst_Detection_${Date.now()}`, - dataSource: "monitoring", - schemes: [], - selectedSchemeId: "", - scadaStart: dayjs().subtract(3, "day"), - scadaEnd: dayjs(), - mu: 100, - pointsPerDay: 96, - nEstimators: 50, - contaminationInput: "auto", - advancedOpen: false, + detectionMode: "latest", + targetTime: currentQuarterHour(), + samplingIntervalMinutes: 15, + samplingIntervalSource: "metadata", }); +export const parseScadaFrequencyMinutes = ( + value: string | number | null | undefined, +): number | null => { + if (typeof value === "number") { + return Number.isInteger(value) && value > 0 ? value : null; + } + if (!value) return null; + const normalized = value.trim(); + const dayMatch = normalized.match(/^(\d+)\s+days?,\s*(.+)$/i); + const days = dayMatch ? Number(dayMatch[1]) : 0; + const timePart = dayMatch ? dayMatch[2] : normalized; + const parts = timePart.split(":").map(Number); + if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) { + return null; + } + const minutes = days * 1440 + parts[0] * 60 + parts[1] + parts[2] / 60; + return Number.isInteger(minutes) && minutes > 0 ? minutes : null; +}; + +export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => { + const counts = new Map(); + items + .filter((item) => item.type?.toLowerCase() === "pressure") + .forEach((item) => { + const minutes = parseScadaFrequencyMinutes(item.transmission_frequency); + if (minutes && 1440 % minutes === 0) { + counts.set(minutes, (counts.get(minutes) ?? 0) + 1); + } + }); + return [...counts.entries()].sort( + ([minutesA, countA], [minutesB, countB]) => + countB - countA || minutesA - minutesB, + )[0]?.[0] ?? 15; +}; + +export const buildBurstDetectionRequest = ( + parameters: BurstDetectionAnalysisParametersState, + network: string, +) => ({ + network, + scheme_name: parameters.schemeName.trim(), + sampling_interval_minutes: parameters.samplingIntervalMinutes, + ...(parameters.detectionMode === "historical" && parameters.targetTime + ? { target_time: parameters.targetTime.toISOString() } + : {}), +}); + const AnalysisParameters: React.FC = ({ onResult, state, onStateChange, }) => { const { open } = useNotification(); - const [parametersState, setParametersState, setFormField] = useControllableObjectState( - state, - onStateChange, - createBurstDetectionAnalysisParametersState(), - ); + const [parametersState, setParametersState, setFormField] = + useControllableObjectState( + state, + onStateChange, + createBurstDetectionAnalysisParametersState(), + ); const { schemeName, - dataSource, - schemes, - selectedSchemeId, - scadaStart, - scadaEnd, - mu, - pointsPerDay, - nEstimators, - contaminationInput, - advancedOpen, + detectionMode, + targetTime, + samplingIntervalMinutes, + samplingIntervalSource, } = parametersState; - const [schemeLoading, setSchemeLoading] = useState(false); const [running, setRunning] = useState(false); - const isSimulationMode = dataSource === "simulation"; + const [frequencyLoading, setFrequencyLoading] = useState(false); - 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"); - - setParametersState((previous) => ({ - ...previous, - scadaStart: start, - scadaEnd: end, - })); - }, [setParametersState]); - - 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/schemes`, { - params: { network: NETWORK_NAME }, - }); - const burstSchemes = (response.data as SchemeItem[]).filter( - (scheme) => scheme.scheme_type === "burst_analysis", - ).sort( - (a, b) => - dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), + useEffect(() => { + if (samplingIntervalSource !== "metadata") return; + let active = true; + setFrequencyLoading(true); + api + .get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } }) + .then((response) => { + if (!active) return; + const interval = resolvePressureSamplingInterval( + response.data as ScadaInfoItem[], ); + setParametersState((previous) => + previous.samplingIntervalSource === "metadata" + ? { ...previous, samplingIntervalMinutes: interval } + : previous, + ); + }) + .catch(() => { + // Keep the 15-minute fallback when SCADA metadata is unavailable. + }) + .finally(() => { + if (active) setFrequencyLoading(false); + }); + return () => { + active = false; + }; + }, [samplingIntervalSource, setParametersState]); - setFormField("schemes", burstSchemes); + const samplingIntervalValid = + Number.isInteger(samplingIntervalMinutes) && + samplingIntervalMinutes > 0 && + 1440 % samplingIntervalMinutes === 0; - if (selectedSchemeId) { - const matchedScheme = burstSchemes.find( - (scheme) => scheme.scheme_id === selectedSchemeId, - ); - if (matchedScheme) { - applySchemeTimeRange(matchedScheme); - } else { - setFormField("selectedSchemeId", ""); - } - } - - 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, setFormField], + const isValid = useMemo( + () => + schemeName.trim().length > 0 && + samplingIntervalValid && + (detectionMode === "latest" || Boolean(targetTime?.isValid())), + [detectionMode, samplingIntervalValid, schemeName, targetTime], ); - const handleDataSourceChange = (value: "monitoring" | "simulation") => { - setFormField("dataSource", value); - if (value === "simulation") { - void fetchSchemes(); - } - }; - - const handleSchemeSelect = (schemeId: number) => { - setFormField("selectedSchemeId", 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) { + if (!isValid) { open?.({ type: "error", message: "参数不完整", - description: "请检查时间范围(至少2天)和高级参数是否填写正确。", + description: "请输入方案名称,并检查历史目标时间。", }); return; } @@ -222,50 +180,23 @@ const AnalysisParameters: React.FC = ({ key: "burst-detection-analysis-progress", type: "progress", message: "正在执行爆管侦测", - description: "正在读取数据并计算异常分数。", + description: "正在读取目标时刻及前 14 天同刻基线。", 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, - }, - }, - }); - + const response = await api.post( + "/api/v1/burst-detection/detect/", + buildBurstDetectionRequest(parametersState, NETWORK_NAME), + ); + onResult(response.data as BurstDetectionResult); open?.({ key: "burst-detection-analysis-success", type: "success", message: "爆管侦测完成", - description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, + description: response.data.summary?.burst_detected + ? "目标时刻存在异常信号,请优先复核相关测点。" + : "目标时刻未发现爆管异常。", }); } catch (error: any) { open?.({ @@ -280,7 +211,7 @@ const AnalysisParameters: React.FC = ({ }; return ( - + @@ -297,211 +228,89 @@ const AnalysisParameters: React.FC = ({ - 数据来源 + 侦测方式 - {isSimulationMode && ( - - - 选择爆管分析方案 - - - - - - void fetchSchemes({ force: true, notify: true })} - disabled={schemeLoading} - aria-label="刷新爆管分析方案" - sx={{ - border: "1px solid", - borderColor: "divider", - borderRadius: 1, - }} - > - {schemeLoading ? ( - - ) : ( - - )} - - - - )} - - - - - - 侦测开始时间 - - setFormField("scadaStart", value)} - maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined} - disabled={isSimulationMode} - format="YYYY-MM-DD HH:mm" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - - - - 侦测结束时间 - - setFormField("scadaEnd", value)} - minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined} - disabled={isSimulationMode} - format="YYYY-MM-DD HH:mm" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - - - - - - 当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。 - - - - setFormField("advancedOpen", !advancedOpen)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - setFormField("advancedOpen", !advancedOpen); - } - }} - sx={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - px: 1.25, - py: 0.75, - cursor: "pointer", - backgroundColor: "transparent", - "&:hover": { backgroundColor: "action.hover" }, - }} + {detectionMode === "historical" ? ( + - - 高级参数 - - - - - - - setFormField("mu", Number(event.target.value))} - size="small" - fullWidth - inputProps={{ min: 1 }} - /> - setFormField("pointsPerDay", Number(event.target.value))} - size="small" - fullWidth - inputProps={{ min: 1 }} - /> - setFormField("nEstimators", Number(event.target.value))} - size="small" - fullWidth - inputProps={{ min: 1 }} - /> - setFormField("contaminationInput", event.target.value)} - size="small" - fullWidth - helperText="填写 auto 或 0~0.5 之间的小数。" - error={contaminationValue === null} - /> - + + + 目标时刻 + + setFormField("targetTime", value)} + maxDateTime={dayjs()} + minutesStep={15} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> - - + + ) : null} + + { + setParametersState((previous) => ({ + ...previous, + samplingIntervalMinutes: Number(event.target.value), + samplingIntervalSource: "manual", + })); + }} + size="small" + fullWidth + error={!samplingIntervalValid} + inputProps={{ min: 1, max: 1440, step: 1 }} + helperText={ + samplingIntervalValid + ? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点` + : "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。" + } + /> + + {detectionMode === "latest" ? ( + + 系统自动读取目标时刻及前 14 天同一时刻数据。每天使用截至该时刻的 + 24 小时压力序列提取扰动特征,仅判定目标时刻是否异常。 + + ) : null} + + 当前口径:{samplingIntervalMinutes || "-"} 分钟采样、 + {samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} 点/天、14 + 个参考日;缺失数据的测点不会插值,将从本次分析中排除。 + - + @@ -509,11 +318,13 @@ const AnalysisParameters: React.FC = ({ variant="contained" fullWidth disabled={!isValid || running} - onClick={handleRun} - className="bg-blue-600 hover:bg-blue-700" - sx={{ textTransform: "none", fontWeight: 500 }} + onClick={() => void handleRun()} > - {running ? : "开始侦测"} + {running + ? "侦测中..." + : detectionMode === "latest" + ? "侦测最新数据" + : "回放目标时刻"} diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx index af2a583..6c183c5 100644 --- a/src/components/olmap/BurstDetection/DetectionResults.tsx +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -5,23 +5,23 @@ 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 { + CheckCircleOutline as CheckCircleIcon, + ErrorOutline as ErrorOutlineIcon, 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 { GeoJSON } from "ol/format"; 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 { useMap } from "@components/olmap/core/MapComponent"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { BurstDetectionResult, BurstDetectionRow } from "./types"; export interface BurstDetectionResultsState { @@ -29,9 +29,7 @@ export interface BurstDetectionResultsState { } export const createBurstDetectionResultsState = - (): BurstDetectionResultsState => ({ - selectedDay: null, - }); + (): BurstDetectionResultsState => ({ selectedDay: null }); interface Props { result: BurstDetectionResult | null; @@ -46,54 +44,33 @@ interface MetricCardProps { 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 toneStyles: Record = { + blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900", + orange: "border-orange-200 from-orange-50 to-orange-100 text-orange-900", + purple: "border-purple-200 from-purple-50 to-purple-100 text-purple-900", + green: "border-green-200 from-green-50 to-green-100 text-green-900", }; -const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { - const style = toneStyles[tone]; - return ( - - - {label} +const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => ( + + + {label} + + + {value} + + {hint ? ( + + {hint} - - {value} - - {hint ? ( - - {hint} - - ) : null} - - ); -}; + ) : null} + +); + +const formatDateTime = (value?: string) => + value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"; const EmptyState = () => ( @@ -104,42 +81,27 @@ const EmptyState = () => ( 等待侦测结果 - 提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。 + 提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。 ); -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, - state, - onStateChange, -}) => { +const DetectionResults: React.FC = ({ result, state, onStateChange }) => { const map = useMap(); const highlightLayerRef = useRef | null>(null); const [highlightFeatures, setHighlightFeatures] = useState([]); - const [internalResultsState, setInternalResultsState] = - useState(createBurstDetectionResultsState); - const resultsState = state ?? internalResultsState; - const selectedDay = resultsState.selectedDay; - const setSelectedDay = (value: number | null) => { - const nextState = { ...resultsState, selectedDay: value }; - if (state === undefined) { - setInternalResultsState(nextState); - } + const [internalState, setInternalState] = useState( + createBurstDetectionResultsState, + ); + const resultsState = state ?? internalState; + const setSelectedDay = (selectedDay: number | null) => { + const nextState = { selectedDay }; + if (state === undefined) setInternalState(nextState); onStateChange?.(nextState); }; useEffect(() => { if (!map) return; - const layer = new VectorLayer({ source: new VectorSource(), style: new Style({ @@ -157,10 +119,8 @@ const DetectionResults: React.FC = ({ queryable: false, }, }); - map.addLayer(layer); highlightLayerRef.current = layer; - return () => { highlightLayerRef.current = null; map.removeLayer(layer); @@ -174,58 +134,67 @@ const DetectionResults: React.FC = ({ 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, + const sortedRows = useMemo( + () => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day), [result], ); - const activeSelectedDay = selectedDay ?? defaultSelectedDay; + const timestampForRow = (row: BurstDetectionRow) => { + if (row.Timestamp) return row.Timestamp; + const start = dayjs(result?.scada_window?.start); + return start.isValid() ? start.add(row.Day, "day").toISOString() : undefined; + }; - 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))], + const scoreThreshold = result?.summary.score_threshold ?? 0; + const scoreSeries = sortedRows.map((row) => ({ + day: row.Day, + value: [ + timestampForRow(row) + ? dayjs(timestampForRow(row)).format("MM-DD HH:mm") + : `第 ${row.Day} 天`, + Number(row.Score.toFixed(4)), + ], itemStyle: { - color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981", + color: + row.Role === "target" + ? row.IsBurst + ? "#ef4444" + : "#2563eb" + : "#94a3b8", }, - })) ?? [], - [result], - ); + symbolSize: row.Role === "target" ? 11 : 7, + })); const rankingSeries = useMemo( () => - [...(result?.summary?.latest_sensor_rankings ?? [])] - .sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value) + [...(result?.summary.latest_sensor_rankings ?? [])] + .sort( + (a, b) => + (a.standardized_deviation ?? a.latest_high_frequency_value) - + (b.standardized_deviation ?? b.latest_high_frequency_value), + ) .map((item) => ({ name: item.sensor_node, - value: Number(item.latest_high_frequency_value.toFixed(4)), + value: Number( + (item.standardized_deviation ?? item.latest_high_frequency_value).toFixed(3), + ), })), [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 format = new GeoJSON(); + const geojsonFeatures = features.map((feature) => + format.writeFeatureObject(feature), + ); + // @ts-ignore turf accepts OpenLayers GeoJSON feature objects const extent = bbox(featureCollection(geojsonFeatures)); map.getView().fit(extent, { maxZoom: 18, @@ -234,60 +203,50 @@ const DetectionResults: React.FC = ({ }); }; - if (!result) { - return ; - } + 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 targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1); const isBurstDetected = result.summary.burst_detected; + const targetRank = result.summary.target_rank; + const excludedCount = result.data_quality?.excluded_sensors.length ?? 0; 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]}`; + formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => { + const data = params[0]?.data; + return data + ? `${data.value[0]}
${data.day === 15 ? "目标时刻" : "参考日"}
异常分数:${data.value[1]}` + : "-"; }, }, - grid: { top: 30, left: 40, right: 20, bottom: 35 }, + grid: { top: 30, left: 48, right: 20, bottom: 48 }, xAxis: { type: "category", - name: "侦测日", - data: result.rows.map((row) => row.Day), - axisLabel: { fontSize: 10 }, - }, - yAxis: { - type: "value", - name: "异常分数", - axisLabel: { fontSize: 10 }, + name: "同刻日期", + boundaryGap: false, + data: scoreSeries.map((item) => item.value[0]), + axisLabel: { fontSize: 10, interval: 2, rotate: 25 }, }, + yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } }, series: [ { type: "line", - smooth: true, - symbolSize: 8, data: scoreSeries, - lineStyle: { color: "#2563eb", width: 2 }, + lineStyle: { color: "#94a3b8", width: 2 }, markLine: { symbol: "none", - lineStyle: { type: "dashed", color: "#94a3b8" }, - data: [{ yAxis: 0 }], + lineStyle: { type: "dashed", color: "#ef4444" }, + data: [{ yAxis: scoreThreshold, name: "报警阈值" }], }, }, ], }; const rankingOption = { - tooltip: { - trigger: "axis", - axisPointer: { type: "shadow" }, - }, - grid: { top: 20, left: 70, right: 20, bottom: 20 }, - xAxis: { type: "value", axisLabel: { fontSize: 10 } }, + tooltip: { trigger: "axis", axisPointer: { type: "shadow" } }, + grid: { top: 12, left: 82, right: 20, bottom: 25 }, + xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } }, yAxis: { type: "category", data: rankingSeries.map((item) => item.name), @@ -298,9 +257,7 @@ const DetectionResults: React.FC = ({ type: "bar", data: rankingSeries.map((item) => ({ value: item.value, - itemStyle: { - color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981", - }, + itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" }, })), barWidth: 14, }, @@ -309,323 +266,176 @@ const DetectionResults: React.FC = ({ const columns: GridColDef[] = [ { - field: "Day", - headerName: "侦测日", - width: 96, - valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"), + field: "Timestamp", + headerName: "同刻日期", + minWidth: 145, + flex: 1, + valueGetter: (_value, row) => formatDateTime(timestampForRow(row)), + }, + { + field: "Role", + headerName: "角色", + width: 90, + valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"), }, { field: "Score", headerName: "异常分数", - width: 120, - valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"), + width: 110, + 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 ; - }, + headerName: "目标判定", + width: 110, + renderCell: ({ value, row }) => + row.Role === "target" || row.Day === result.day_count ? ( + + ) : ( + + 参考 + + ), }, ]; - - const rows = result.rows.map((row) => ({ id: row.Day, ...row })); + const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row })); return ( - {/* Status Banner */} - {isBurstDetected ? ( - - ) : ( - - )} + {isBurstDetected ? : } - {isBurstDetected - ? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)` - : "未侦测到爆管异常"} + {isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"} - {isBurstDetected - ? "建议检查异常日期的压力波动情况" - : "当前时间窗口内数据特征平稳,符合历史模式"} + 目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常 - {/* 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"} + label="目标异常分数" + value={targetRow ? targetRow.Score.toFixed(4) : "-"} + hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`} + tone={isBurstDetected ? "orange" : "green"} /> - {/* Score Trend Chart */} - + - - - 异常分数趋势 + + + 15 天同刻异常分数 - + - + { - const day = params?.data?.value?.[0]; - if (typeof day === "number") { - setSelectedDay(day); - } - }, + click: (params: { data?: { day?: number } }) => + setSelectedDay(params.data?.day ?? null), }} /> - {/* Selected Day Interpretation */} - {/* - - - 选中日解读 - - {selectedRow ? ( - - ) : null} - - {selectedRow ? ( - - - - - - 异常分数:{selectedRow.Score.toFixed(4)} + {rankingSeries.length > 0 ? ( + + + + 目标测点压力偏离 - - 模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1)" : "正常日(Prediction = 1)"} - - - 解读建议: - {selectedRow.Score <= -0.6 - ? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。" - : selectedRow.Score <= -0.2 - ? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。" - : "未见明显异常,可作为基线日参考。"} + + 负值越小,压降相对历史越明显 - ) : ( - - 请在趋势图或表格中选择一天查看详细解释。 - - )} - */} + + + + + ) : null} - {/* Latest Sensor Rankings */} - {/* - - - 最新测点高频特征排名 - - - 仅展示最新一天 - - - - - - - {result.summary.latest_sensor_rankings.slice(0, 5).map((item) => ( - - ))} - - */} - - {/* Results Table */} - - - - - 结果表格 - - - + + + + 同刻对照明细 + - + setSelectedDay(Number(params.row.Day))} + getRowClassName={(params) => + params.row.Day === resultsState.selectedDay ? "bg-blue-50" : "" + } + sx={{ border: "none" }} /> diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx index 6a44e7a..3c696c0 100644 --- a/src/components/olmap/BurstDetection/SchemeQuery.tsx +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -115,6 +115,12 @@ const SchemeQuery: React.FC = ({ username: payload?.username ?? scheme.username, create_time: payload?.create_time ?? scheme.create_time, algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params, + requested_target_time: payload?.requested_target_time, + target_time: payload?.target_time, + reference_window: payload?.reference_window, + sampling_interval_minutes: payload?.sampling_interval_minutes, + daily_scores: payload?.daily_scores, + data_quality: payload?.data_quality, }; }; @@ -245,6 +251,9 @@ const SchemeQuery: React.FC = ({ const mostAnomalousDay = payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-"; const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0; + const targetTime = payload?.target_time ?? payload?.summary?.target_time; + const targetScore = payload?.summary?.target_score ?? summary?.target_score; + const targetRank = payload?.summary?.target_rank ?? summary?.target_rank; return ( @@ -293,30 +302,36 @@ const SchemeQuery: React.FC = ({ - 异常天数 + {targetTime ? "目标时刻" : "异常天数"} - {anomalyDayCount} + {targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount} - 最异常日 + {targetTime ? "目标分数" : "最异常日"} - {isBurst - ? typeof mostAnomalousDay === "number" - ? `第 ${mostAnomalousDay} 天` - : mostAnomalousDay - : "无"} + {targetTime + ? typeof targetScore === "number" + ? targetScore.toFixed(4) + : "-" + : isBurst + ? typeof mostAnomalousDay === "number" + ? `第 ${mostAnomalousDay} 天` + : mostAnomalousDay + : "无"} - 测点数 + {targetTime ? "异常排名" : "测点数"} - {sensorCount} + {targetTime && targetRank + ? `${targetRank} / ${payload?.day_count ?? 15}` + : sensorCount} @@ -336,6 +351,8 @@ const SchemeQuery: React.FC = ({ if (ds === "monitoring") return "监测数据"; if (os === "simulation_scheme_timerange") return "模拟数据"; if (os === "backend_timerange") return "监测数据"; + if (os === "latest_monitoring") return "最新监测数据"; + if (os === "historical_monitoring") return "历史监测回放"; return os || "-"; })()} @@ -354,14 +371,16 @@ const SchemeQuery: React.FC = ({ - 算法参数: + 侦测口径: - 频域截断系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} - ,每日采样点数: + 频域系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} + ,每日采样: {scheme.scheme_detail?.algorithm_params?.points_per_day ?? payload?.algorithm_params?.points_per_day ?? "-"} + 点,阈值: + {payload?.summary?.score_threshold ?? "-"} diff --git a/src/components/olmap/BurstDetection/types.ts b/src/components/olmap/BurstDetection/types.ts index 235edac..fa88561 100644 --- a/src/components/olmap/BurstDetection/types.ts +++ b/src/components/olmap/BurstDetection/types.ts @@ -3,11 +3,16 @@ export interface BurstDetectionRow { Score: number; Prediction: number; IsBurst: boolean; + Timestamp?: string; + Role?: "reference" | "target"; } export interface BurstDetectionSensorRanking { sensor_node: string; latest_high_frequency_value: number; + historical_mean?: number; + historical_std?: number; + standardized_deviation?: number; } export interface BurstDetectionSummary { @@ -17,6 +22,11 @@ export interface BurstDetectionSummary { anomaly_days: number[]; anomaly_day_count: number; latest_sensor_rankings: BurstDetectionSensorRanking[]; + target_score?: number; + score_threshold?: number; + target_rank?: number; + target_time?: string; + reference_day_count?: number; } export interface BurstDetectionAlgorithmParams { @@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams { contamination?: number | "auto"; random_state?: number; }; + score_threshold?: number; } export interface BurstDetectionResult { @@ -51,6 +62,25 @@ export interface BurstDetectionResult { type?: string; }; algorithm_params?: BurstDetectionAlgorithmParams; + requested_target_time?: string | null; + target_time?: string; + reference_window?: { + start: string; + end: string; + day_count: number; + }; + sampling_interval_minutes?: number; + daily_scores?: Array<{ + timestamp: string; + role: "reference" | "target"; + score: number; + raw_prediction: number; + }>; + data_quality?: { + included_sensors: string[]; + excluded_sensors: Array<{ sensor_node: string; reason: string }>; + minimum_required_sensors: number; + }; } export interface BurstDetectionSchemeDetail {