feat(burst-detection): update analysis parameters

This commit is contained in:
2026-07-17 16:24:04 +08:00
parent a52c04204d
commit 7af90e495d
5 changed files with 521 additions and 794 deletions
@@ -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);
});
});
@@ -1,19 +1,14 @@
"use client"; "use client";
import React, { useMemo, useState, useCallback } from "react"; import React, { useEffect, useMemo, useState } from "react";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import RefreshIcon from "@mui/icons-material/Refresh";
import { import {
Box, Box,
Button, Button,
CircularProgress,
Collapse,
FormControl, FormControl,
MenuItem, MenuItem,
Select, Select,
TextField, TextField,
Typography, Typography,
IconButton,
} from "@mui/material"; } from "@mui/material";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
@@ -23,7 +18,7 @@ import { useNotification } from "@refinedev/core";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { api } from "@/lib/api"; 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 { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { BurstDetectionResult } from "./types"; import { BurstDetectionResult } from "./types";
@@ -33,186 +28,149 @@ interface Props {
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void; 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 { export interface BurstDetectionAnalysisParametersState {
schemeName: string; schemeName: string;
dataSource: "monitoring" | "simulation"; detectionMode: "latest" | "historical";
schemes: SchemeItem[]; targetTime: Dayjs | null;
selectedSchemeId: number | ""; samplingIntervalMinutes: number;
scadaStart: Dayjs | null; samplingIntervalSource: "metadata" | "manual";
scadaEnd: Dayjs | null;
mu: number;
pointsPerDay: number;
nEstimators: number;
contaminationInput: string;
advancedOpen: boolean;
} }
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 = export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({ (): BurstDetectionAnalysisParametersState => ({
schemeName: `Burst_Detection_${Date.now()}`, schemeName: `Burst_Detection_${Date.now()}`,
dataSource: "monitoring", detectionMode: "latest",
schemes: [], targetTime: currentQuarterHour(),
selectedSchemeId: "", samplingIntervalMinutes: 15,
scadaStart: dayjs().subtract(3, "day"), samplingIntervalSource: "metadata",
scadaEnd: dayjs(),
mu: 100,
pointsPerDay: 96,
nEstimators: 50,
contaminationInput: "auto",
advancedOpen: false,
}); });
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<number, number>();
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<Props> = ({ const AnalysisParameters: React.FC<Props> = ({
onResult, onResult,
state, state,
onStateChange, onStateChange,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const [parametersState, setParametersState, setFormField] = useControllableObjectState( const [parametersState, setParametersState, setFormField] =
state, useControllableObjectState(
onStateChange, state,
createBurstDetectionAnalysisParametersState(), onStateChange,
); createBurstDetectionAnalysisParametersState(),
);
const { const {
schemeName, schemeName,
dataSource, detectionMode,
schemes, targetTime,
selectedSchemeId, samplingIntervalMinutes,
scadaStart, samplingIntervalSource,
scadaEnd,
mu,
pointsPerDay,
nEstimators,
contaminationInput,
advancedOpen,
} = parametersState; } = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const isSimulationMode = dataSource === "simulation"; const [frequencyLoading, setFrequencyLoading] = useState(false);
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { useEffect(() => {
const start = dayjs(scheme.scheme_start_time); if (samplingIntervalSource !== "metadata") return;
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; let active = true;
const end = start.add(durationSeconds, "second"); setFrequencyLoading(true);
api
setParametersState((previous) => ({ .get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
...previous, .then((response) => {
scadaStart: start, if (!active) return;
scadaEnd: end, const interval = resolvePressureSamplingInterval(
})); response.data as ScadaInfoItem[],
}, [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(),
); );
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 isValid = useMemo(
const matchedScheme = burstSchemes.find( () =>
(scheme) => scheme.scheme_id === selectedSchemeId, schemeName.trim().length > 0 &&
); samplingIntervalValid &&
if (matchedScheme) { (detectionMode === "latest" || Boolean(targetTime?.isValid())),
applySchemeTimeRange(matchedScheme); [detectionMode, samplingIntervalValid, schemeName, targetTime],
} 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 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 () => { const handleRun = async () => {
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) { if (!isValid) {
open?.({ open?.({
type: "error", type: "error",
message: "参数不完整", message: "参数不完整",
description: "请检查时间范围(至少2天)和高级参数是否填写正确。", description: "请输入方案名称,并检查历史目标时间。",
}); });
return; return;
} }
@@ -222,50 +180,23 @@ const AnalysisParameters: React.FC<Props> = ({
key: "burst-detection-analysis-progress", key: "burst-detection-analysis-progress",
type: "progress", type: "progress",
message: "正在执行爆管侦测", message: "正在执行爆管侦测",
description: "正在读取数据并计算异常分数。", description: "正在读取目标时刻及前 14 天同刻基线。",
undoableTimeout: 3, undoableTimeout: 3,
}); });
try { try {
const selectedScheme = const response = await api.post(
dataSource === "simulation" "/api/v1/burst-detection/detect/",
? schemes.find((item) => item.scheme_id === selectedSchemeId) buildBurstDetectionRequest(parametersState, NETWORK_NAME),
: undefined; );
onResult(response.data as BurstDetectionResult);
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?.({ open?.({
key: "burst-detection-analysis-success", key: "burst-detection-analysis-success",
type: "success", type: "success",
message: "爆管侦测完成", message: "爆管侦测完成",
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, description: response.data.summary?.burst_detected
? "目标时刻存在异常信号,请优先复核相关测点。"
: "目标时刻未发现爆管异常。",
}); });
} catch (error: any) { } catch (error: any) {
open?.({ open?.({
@@ -280,7 +211,7 @@ const AnalysisParameters: React.FC<Props> = ({
}; };
return ( return (
<Box className="flex flex-col flex-1 min-h-0"> <Box className="flex min-h-0 flex-1 flex-col">
<Box className="flex flex-col gap-3"> <Box className="flex flex-col gap-3">
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
@@ -297,211 +228,89 @@ const AnalysisParameters: React.FC<Props> = ({
<Box> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium"> <Typography variant="subtitle2" className="mb-1 font-medium">
</Typography> </Typography>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<Select <Select
value={dataSource} value={detectionMode}
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")} onChange={(event) =>
setFormField(
"detectionMode",
event.target.value as "latest" | "historical",
)
}
> >
<MenuItem value="monitoring"></MenuItem> <MenuItem value="latest"></MenuItem>
<MenuItem value="simulation"></MenuItem> <MenuItem value="historical"></MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Box> </Box>
{isSimulationMode && ( {detectionMode === "historical" ? (
<Box> <LocalizationProvider
<Typography variant="subtitle2" className="mb-1 font-medium"> dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
</Typography> localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FormControl fullWidth size="small">
<Select
value={selectedSchemeId}
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
disabled={schemeLoading}
displayEmpty
>
<MenuItem value="" disabled>
</MenuItem>
{schemes.map((scheme) => (
<MenuItem key={scheme.scheme_id} value={scheme.scheme_id}>
{scheme.scheme_name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton
size="small"
color="primary"
onClick={() => void fetchSchemes({ force: true, notify: true })}
disabled={schemeLoading}
aria-label="刷新爆管分析方案"
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
}}
>
{schemeLoading ? (
<CircularProgress size={18} color="inherit" />
) : (
<RefreshIcon fontSize="small" />
)}
</IconButton>
</Box>
</Box>
)}
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
>
<Box className="grid grid-cols-2 gap-2">
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<DateTimePicker
value={scadaStart}
onChange={(value) => setFormField("scadaStart", value)}
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined}
disabled={isSimulationMode}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<DateTimePicker
value={scadaEnd}
onChange={(value) => setFormField("scadaEnd", value)}
minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined}
disabled={isSimulationMode}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
</Box>
</LocalizationProvider>
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
</Box>
<Box
sx={{
border: "1px solid",
borderColor: "grey.200",
borderRadius: 1,
overflow: "hidden",
}}
>
<Box
role="button"
tabIndex={0}
onClick={() => 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" },
}}
> >
<Typography variant="body2" color="text.secondary"> <Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<ExpandMoreIcon </Typography>
sx={{ <DateTimePicker
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)", value={targetTime}
transition: "transform 0.2s ease", onChange={(value) => setFormField("targetTime", value)}
}} maxDateTime={dayjs()}
/> minutesStep={15}
</Box> format="YYYY-MM-DD HH:mm"
<Collapse in={advancedOpen} timeout="auto" unmountOnExit> slotProps={{ textField: { size: "small", fullWidth: true } }}
<Box />
sx={{
px: 1.25,
pt: 1.25,
pb: 1.25,
backgroundColor: "transparent",
}}
>
<Box className="flex flex-col gap-3">
<TextField
type="number"
label="频域截断系数"
value={mu}
onChange={(event) => setFormField("mu", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="每日采样点数"
value={pointsPerDay}
onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
type="number"
label="孤立森林树数量"
value={nEstimators}
onChange={(event) => setFormField("nEstimators", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
/>
<TextField
label="异常比例"
value={contaminationInput}
onChange={(event) => setFormField("contaminationInput", event.target.value)}
size="small"
fullWidth
helperText="填写 auto 或 0~0.5 之间的小数。"
error={contaminationValue === null}
/>
</Box>
</Box> </Box>
</Collapse> </LocalizationProvider>
</Box> ) : null}
<TextField
type="number"
label="采样间隔(分钟)"
value={samplingIntervalMinutes}
onChange={(event) => {
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" ? (
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
14 使
24
</Box>
) : null}
<Typography variant="caption" color="text.secondary">
{samplingIntervalMinutes || "-"}
{samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} /14
</Typography>
</Box> </Box>
<Box className="mt-auto pt-3 flex gap-2"> <Box className="mt-auto flex gap-2 pt-3">
<Button <Button
variant="outlined" variant="outlined"
fullWidth fullWidth
disabled={running} disabled={running}
sx={{ textTransform: "none", fontWeight: 500 }} onClick={() =>
onClick={() => { setParametersState(createBurstDetectionAnalysisParametersState())
setParametersState((previous) => ({ }
...previous,
schemeName: `Burst_Detection_${Date.now()}`,
scadaStart: dayjs().subtract(3, "day"),
scadaEnd: dayjs(),
mu: 100,
pointsPerDay: 96,
nEstimators: 50,
contaminationInput: "auto",
}));
}}
> >
</Button> </Button>
@@ -509,11 +318,13 @@ const AnalysisParameters: React.FC<Props> = ({
variant="contained" variant="contained"
fullWidth fullWidth
disabled={!isValid || running} disabled={!isValid || running}
onClick={handleRun} onClick={() => void handleRun()}
className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }}
> >
{running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"} {running
? "侦测中..."
: detectionMode === "latest"
? "侦测最新数据"
: "回放目标时刻"}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -5,23 +5,23 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales"; import { zhCN } from "@mui/x-data-grid/locales";
import { import {
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
FormatListBulleted, FormatListBulleted,
InfoOutlined as InfoOutlinedIcon, InfoOutlined as InfoOutlinedIcon,
Room as RoomIcon, Room as RoomIcon,
ShowChart as ShowChartIcon, ShowChart as ShowChartIcon,
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorOutlineIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import dayjs from "dayjs"; 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 Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector"; import VectorSource from "ol/source/Vector";
import { Circle, Fill, Stroke, Style } from "ol/style"; import { Circle, Fill, Stroke, Style } from "ol/style";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types"; import { BurstDetectionResult, BurstDetectionRow } from "./types";
export interface BurstDetectionResultsState { export interface BurstDetectionResultsState {
@@ -29,9 +29,7 @@ export interface BurstDetectionResultsState {
} }
export const createBurstDetectionResultsState = export const createBurstDetectionResultsState =
(): BurstDetectionResultsState => ({ (): BurstDetectionResultsState => ({ selectedDay: null });
selectedDay: null,
});
interface Props { interface Props {
result: BurstDetectionResult | null; result: BurstDetectionResult | null;
@@ -46,54 +44,33 @@ interface MetricCardProps {
tone: "blue" | "orange" | "purple" | "green"; tone: "blue" | "orange" | "purple" | "green";
} }
const toneStyles: Record< const toneStyles: Record<MetricCardProps["tone"], string> = {
MetricCardProps["tone"], blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900",
{ bg: string; border: string; text: string; darkText: string } 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",
blue: { green: "border-green-200 from-green-50 to-green-100 text-green-900",
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 MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
const style = toneStyles[tone]; <Box
return ( className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
<Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}> >
<Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}> <Typography variant="caption" className="mb-1 block font-semibold">
{label} {label}
</Typography>
<Typography variant="body2" className="font-bold">
{value}
</Typography>
{hint ? (
<Typography variant="caption" className="mt-0.5 block opacity-75">
{hint}
</Typography> </Typography>
<Typography variant="body2" className={`font-bold ${style.darkText}`}> ) : null}
{value} </Box>
</Typography> );
{hint ? (
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}> const formatDateTime = (value?: string) =>
{hint} value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
</Typography>
) : null}
</Box>
);
};
const EmptyState = () => ( const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> <Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
@@ -104,42 +81,27 @@ const EmptyState = () => (
</Typography> </Typography>
<Typography variant="body2" className="max-w-xs text-gray-500"> <Typography variant="body2" className="max-w-xs text-gray-500">
14
</Typography> </Typography>
</Box> </Box>
); );
const getScoreLevel = (score: number) => { const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
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<Props> = ({
result,
state,
onStateChange,
}) => {
const map = useMap(); const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [internalResultsState, setInternalResultsState] = const [internalState, setInternalState] = useState<BurstDetectionResultsState>(
useState<BurstDetectionResultsState>(createBurstDetectionResultsState); createBurstDetectionResultsState,
const resultsState = state ?? internalResultsState; );
const selectedDay = resultsState.selectedDay; const resultsState = state ?? internalState;
const setSelectedDay = (value: number | null) => { const setSelectedDay = (selectedDay: number | null) => {
const nextState = { ...resultsState, selectedDay: value }; const nextState = { selectedDay };
if (state === undefined) { if (state === undefined) setInternalState(nextState);
setInternalResultsState(nextState);
}
onStateChange?.(nextState); onStateChange?.(nextState);
}; };
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
const layer = new VectorLayer({ const layer = new VectorLayer({
source: new VectorSource(), source: new VectorSource(),
style: new Style({ style: new Style({
@@ -157,10 +119,8 @@ const DetectionResults: React.FC<Props> = ({
queryable: false, queryable: false,
}, },
}); });
map.addLayer(layer); map.addLayer(layer);
highlightLayerRef.current = layer; highlightLayerRef.current = layer;
return () => { return () => {
highlightLayerRef.current = null; highlightLayerRef.current = null;
map.removeLayer(layer); map.removeLayer(layer);
@@ -174,58 +134,67 @@ const DetectionResults: React.FC<Props> = ({
highlightFeatures.forEach((feature) => source.addFeature(feature)); highlightFeatures.forEach((feature) => source.addFeature(feature));
}, [highlightFeatures]); }, [highlightFeatures]);
const defaultSelectedDay = useMemo( const sortedRows = useMemo(
() => () => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day),
result?.summary?.most_anomalous_day ??
result?.summary?.latest_day?.Day ??
result?.rows[0]?.Day ??
null,
[result], [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<BurstDetectionRow | null>(() => { const scoreThreshold = result?.summary.score_threshold ?? 0;
if (!result || activeSelectedDay === null) return null; const scoreSeries = sortedRows.map((row) => ({
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null; day: row.Day,
}, [activeSelectedDay, result]); value: [
timestampForRow(row)
const scoreSeries = useMemo( ? dayjs(timestampForRow(row)).format("MM-DD HH:mm")
() => : `${row.Day}`,
result?.rows.map((row) => ({ Number(row.Score.toFixed(4)),
value: [row.Day, Number(row.Score.toFixed(4))], ],
itemStyle: { itemStyle: {
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981", color:
row.Role === "target"
? row.IsBurst
? "#ef4444"
: "#2563eb"
: "#94a3b8",
}, },
})) ?? [], symbolSize: row.Role === "target" ? 11 : 7,
[result], }));
);
const rankingSeries = useMemo( const rankingSeries = useMemo(
() => () =>
[...(result?.summary?.latest_sensor_rankings ?? [])] [...(result?.summary.latest_sensor_rankings ?? [])]
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value) .sort(
(a, b) =>
(a.standardized_deviation ?? a.latest_high_frequency_value) -
(b.standardized_deviation ?? b.latest_high_frequency_value),
)
.map((item) => ({ .map((item) => ({
name: item.sensor_node, 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], [result],
); );
const locateSensors = async (sensorIds: string[]) => { const locateSensors = async (sensorIds: string[]) => {
if (!map || sensorIds.length === 0) return; if (!map || sensorIds.length === 0) return;
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat"); let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
if (features.length === 0) { if (features.length === 0) {
features = await queryFeaturesByIds(sensorIds, "geo_junctions"); features = await queryFeaturesByIds(sensorIds, "geo_junctions");
} }
if (features.length === 0) return; if (features.length === 0) return;
setHighlightFeatures(features); setHighlightFeatures(features);
const format = new GeoJSON();
const geojsonFormat = new GeoJSON(); const geojsonFeatures = features.map((feature) =>
const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); format.writeFeatureObject(feature),
// @ts-ignore turf typing with ol geojson objects );
// @ts-ignore turf accepts OpenLayers GeoJSON feature objects
const extent = bbox(featureCollection(geojsonFeatures)); const extent = bbox(featureCollection(geojsonFeatures));
map.getView().fit(extent, { map.getView().fit(extent, {
maxZoom: 18, maxZoom: 18,
@@ -234,60 +203,50 @@ const DetectionResults: React.FC<Props> = ({
}); });
}; };
if (!result) { if (!result) return <EmptyState />;
return <EmptyState />;
}
const latestDay = result.summary?.latest_day; const targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1);
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 isBurstDetected = result.summary.burst_detected;
const targetRank = result.summary.target_rank;
const excludedCount = result.data_quality?.excluded_sensors.length ?? 0;
const chartOption = { const chartOption = {
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
formatter: (params: Array<{ data: { value: [number, number] } }>) => { formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
const point = params[0]?.data?.value; const data = params[0]?.data;
if (!point) return "-"; return data
return `侦测日第 ${point[0]}<br/>异常分数:${point[1]}`; ? `${data.value[0]}<br/>${data.day === 15 ? "目标时刻" : "参考日"}<br/>异常分数:${data.value[1]}`
: "-";
}, },
}, },
grid: { top: 30, left: 40, right: 20, bottom: 35 }, grid: { top: 30, left: 48, right: 20, bottom: 48 },
xAxis: { xAxis: {
type: "category", type: "category",
name: "侦测日", name: "同刻日期",
data: result.rows.map((row) => row.Day), boundaryGap: false,
axisLabel: { fontSize: 10 }, data: scoreSeries.map((item) => item.value[0]),
}, axisLabel: { fontSize: 10, interval: 2, rotate: 25 },
yAxis: {
type: "value",
name: "异常分数",
axisLabel: { fontSize: 10 },
}, },
yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } },
series: [ series: [
{ {
type: "line", type: "line",
smooth: true,
symbolSize: 8,
data: scoreSeries, data: scoreSeries,
lineStyle: { color: "#2563eb", width: 2 }, lineStyle: { color: "#94a3b8", width: 2 },
markLine: { markLine: {
symbol: "none", symbol: "none",
lineStyle: { type: "dashed", color: "#94a3b8" }, lineStyle: { type: "dashed", color: "#ef4444" },
data: [{ yAxis: 0 }], data: [{ yAxis: scoreThreshold, name: "报警阈值" }],
}, },
}, },
], ],
}; };
const rankingOption = { const rankingOption = {
tooltip: { tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
trigger: "axis", grid: { top: 12, left: 82, right: 20, bottom: 25 },
axisPointer: { type: "shadow" }, xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } },
},
grid: { top: 20, left: 70, right: 20, bottom: 20 },
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
yAxis: { yAxis: {
type: "category", type: "category",
data: rankingSeries.map((item) => item.name), data: rankingSeries.map((item) => item.name),
@@ -298,9 +257,7 @@ const DetectionResults: React.FC<Props> = ({
type: "bar", type: "bar",
data: rankingSeries.map((item) => ({ data: rankingSeries.map((item) => ({
value: item.value, value: item.value,
itemStyle: { itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" },
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
},
})), })),
barWidth: 14, barWidth: 14,
}, },
@@ -309,323 +266,176 @@ const DetectionResults: React.FC<Props> = ({
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "Day", field: "Timestamp",
headerName: "侦测日", headerName: "同刻日期",
width: 96, minWidth: 145,
valueFormatter: (value?: number) => (typeof value === "number" ? `${value}` : "-"), flex: 1,
valueGetter: (_value, row) => formatDateTime(timestampForRow(row)),
},
{
field: "Role",
headerName: "角色",
width: 90,
valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"),
}, },
{ {
field: "Score", field: "Score",
headerName: "异常分数", headerName: "异常分数",
width: 120, width: 110,
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"), valueFormatter: (value?: number) =>
typeof value === "number" ? value.toFixed(4) : "-",
}, },
{ {
field: "IsBurst", field: "IsBurst",
headerName: "判定结果", headerName: "目标判定",
width: 120, width: 110,
renderCell: ({ value }) => { renderCell: ({ value, row }) =>
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const }; row.Role === "target" || row.Day === result.day_count ? (
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />; <Chip
}, size="small"
label={value ? "爆管异常" : "正常"}
color={value ? "error" : "success"}
variant="outlined"
/>
) : (
<Typography variant="caption" color="text.secondary">
</Typography>
),
}, },
]; ];
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
return ( return (
<Box className="h-full overflow-auto p-1"> <Box className="h-full overflow-auto p-1">
<Box className="mb-4 space-y-3"> <Box className="mb-4 space-y-3">
{/* Status Banner */}
<Box <Box
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
? "bg-red-50 border-red-100 text-red-900" isBurstDetected
: "bg-green-50 border-green-100 text-green-900" ? "border-red-100 bg-red-50 text-red-900"
}`} : "border-green-100 bg-green-50 text-green-900"
}`}
> >
{isBurstDetected ? ( {isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
<ErrorOutlineIcon className="text-red-600" />
) : (
<CheckCircleIcon className="text-green-600" />
)}
<Box className="flex-1"> <Box className="flex-1">
<Typography variant="subtitle2" className="font-bold"> <Typography variant="subtitle2" className="font-bold">
{isBurstDetected {isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
: "未侦测到爆管异常"}
</Typography> </Typography>
<Typography variant="caption" className="opacity-80"> <Typography variant="caption" className="opacity-80">
{isBurstDetected {formatDateTime(result.target_time ?? result.summary.target_time)}
? "建议检查异常日期的压力波动情况"
: "当前时间窗口内数据特征平稳,符合历史模式"}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
{/* Header */} <Box className="flex items-center justify-between gap-2 px-1">
<Box className="flex items-center justify-between px-1"> <Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
<Box className="flex items-center gap-2">
<Box className="h-4 w-1 rounded-full bg-blue-600" /> </Typography>
<Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}> <Button
{result.scheme_name || "爆管侦测结果"} size="small"
</Typography> variant="outlined"
</Box> startIcon={<RoomIcon />}
<Box className="flex items-center gap-2"> onClick={() =>
{result.username ? ( void locateSensors(
<Chip result.summary.latest_sensor_rankings
label={result.username} .slice(0, 5)
size="small" .map((item) => item.sensor_node),
sx={{ )
height: 24, }
backgroundColor: "#f3f4f6", sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
color: "#4b5563", >
border: "none",
fontWeight: 500, </Button>
}}
/>
) : null}
<Button
size="small"
variant="outlined"
startIcon={<RoomIcon />}
onClick={() =>
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5))
}
sx={{
height: 24,
minWidth: 0,
padding: "0 8px",
borderColor: "#bfdbfe",
color: "#2563eb",
fontSize: "0.75rem",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
>
</Button>
</Box>
</Box> </Box>
{/* Configuration Summary */}
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
<span className="font-medium text-gray-700"></span>
<span className="font-mono text-gray-600">
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
</span>
</Box>
<Box className="flex items-center gap-1.5">
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
<span className="font-medium text-gray-700"></span>
<span className="text-gray-600">
{(() => {
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 || "-";
})()}
</span>
</Box>
</Box>
{/* Metrics Grid */}
<Box className="grid grid-cols-2 gap-3"> <Box className="grid grid-cols-2 gap-3">
<MetricCard <MetricCard
label="异常数" label="目标异常数"
value={`${result.summary.anomaly_day_count} / ${result.day_count}`} value={targetRow ? targetRow.Score.toFixed(4) : "-"}
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`} hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"} tone={isBurstDetected ? "orange" : "green"}
/> />
<MetricCard <MetricCard
label="最异常日" label="目标异常排名"
value={ value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
result.summary.burst_detected && result.summary.most_anomalous_day hint="在目标日与 14 个参考日中排序"
? `${result.summary.most_anomalous_day}`
: "无"
}
hint={
result.summary.burst_detected && mostAnomalousRow
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
: "-"
}
tone="purple" tone="purple"
/> />
<MetricCard <MetricCard
label="最新状态" label="参考区间"
value={latestLevel.label} value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
hint={latestDay ? `${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"} hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
tone={latestLevel.color === "success" ? "green" : "orange"} tone="blue"
/> />
<MetricCard <MetricCard
label="测点 / 样本" label="有效 / 排除测点"
value={`${result.sensor_nodes.length} / ${result.sample_count}`} value={`${result.sensor_nodes.length} / ${excludedCount}`}
hint={`每日采样点数:${result.points_per_day}`} hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
tone="blue" tone="blue"
/> />
</Box> </Box>
</Box> </Box>
{/* Score Trend Chart */}
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
<ShowChartIcon className="h-5 w-5 text-blue-600" /> <ShowChartIcon className="text-blue-600" />
<Typography variant="subtitle1" className="font-bold text-gray-800"> <Typography variant="subtitle1" className="font-bold">
15
</Typography> </Typography>
</Box> </Box>
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。"> <Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
<InfoOutlinedIcon fontSize="small" className="text-gray-400" /> <InfoOutlinedIcon fontSize="small" className="text-gray-400" />
</Tooltip> </Tooltip>
</Box> </Box>
<Box sx={{ height: 250, px: 1.5, py: 1 }}> <Box sx={{ height: 270, px: 1.5, py: 1 }}>
<ReactECharts <ReactECharts
option={chartOption} option={chartOption}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
onEvents={{ onEvents={{
click: (params: { data?: { value?: [number, number] } }) => { click: (params: { data?: { day?: number } }) =>
const day = params?.data?.value?.[0]; setSelectedDay(params.data?.day ?? null),
if (typeof day === "number") {
setSelectedDay(day);
}
},
}} }}
/> />
</Box> </Box>
</Box> </Box>
{/* Selected Day Interpretation */} {rankingSeries.length > 0 ? (
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<Typography variant="subtitle1" className="font-bold text-gray-800"> <Typography variant="subtitle1" className="font-bold">
选中日解读
</Typography>
{selectedRow ? (
<Chip
size="small"
label={`第 ${selectedRow.Day} 天`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
) : null}
</Box>
{selectedRow ? (
<Box className="space-y-3 px-4 py-3">
<Box className="flex items-center gap-2">
<Chip
label={getScoreLevel(selectedRow.Score).label}
color={getScoreLevel(selectedRow.Score).color}
variant="filled"
/>
</Box>
<Typography variant="body2" className="text-gray-700">
异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span>
</Typography> </Typography>
<Typography variant="body2" className="text-gray-700"> <Typography variant="caption" color="text.secondary">
模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1" : "正常日(Prediction = 1"}
</Typography>
<Typography variant="body2" className="text-gray-700">
解读建议:
{selectedRow.Score <= -0.6
? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
: selectedRow.Score <= -0.2
? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
: "未见明显异常,可作为基线日参考。"}
</Typography> </Typography>
</Box> </Box>
) : ( <Box sx={{ height: 280, px: 1.5, py: 1 }}>
<Typography variant="body2" className="px-4 py-3 text-gray-500"> <ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
请在趋势图或表格中选择一天查看详细解释。 </Box>
</Typography> </Box>
)} ) : null}
</Box> */}
{/* Latest Sensor Rankings */}
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
<Typography variant="subtitle1" className="font-bold text-gray-800">
最新测点高频特征排名
</Typography>
<Typography variant="caption" className="text-gray-500">
仅展示最新一天
</Typography>
</Box>
<Box sx={{ height: 260, px: 1.5, py: 1 }}>
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
</Box>
<Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3">
{result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
<Button
key={item.sensor_node}
size="small"
variant="outlined"
onClick={() => locateSensors([item.sensor_node])}
sx={{
borderColor: "#bfdbfe",
color: "#2563eb",
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
}}
>
{item.sensor_node}
</Button>
))}
</Box>
</Box> */}
{/* Results Table */}
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Box className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
<Box className="flex items-center gap-2"> <FormatListBulleted className="text-blue-600" />
<FormatListBulleted className="h-5 w-5 text-blue-600" /> <Typography variant="subtitle1" className="font-bold">
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography>
</Typography>
</Box>
<Chip
size="small"
label={`${rows.length}`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
</Box> </Box>
<Box sx={{ height: 320, px: 1, py: 1 }}> <Box sx={{ height: 360, px: 1, py: 1 }}>
<DataGrid <DataGrid
rows={rows} rows={tableRows}
columns={columns} columns={columns}
columnBufferPx={100}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText} localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
initialState={{ pageSizeOptions={[15]}
pagination: { paginationModel: { pageSize: 50, page: 0 } }, initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
}}
pageSizeOptions={[50]}
hideFooterSelectedRowCount
sx={{
border: "none",
"& .MuiDataGrid-cell": { borderColor: "#f0f0f0" },
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" },
"& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" },
// Hide the rows per page selector since it's fixed to 50
"& .MuiTablePagination-selectLabel": { display: "none" },
"& .MuiTablePagination-input": { display: "none" },
}}
disableRowSelectionOnClick disableRowSelectionOnClick
onRowClick={(params) => setSelectedDay(Number(params.row.Day))} onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
getRowClassName={(params) =>
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
}
sx={{ border: "none" }}
/> />
</Box> </Box>
</Box> </Box>
@@ -115,6 +115,12 @@ const SchemeQuery: React.FC<Props> = ({
username: payload?.username ?? scheme.username, username: payload?.username ?? scheme.username,
create_time: payload?.create_time ?? scheme.create_time, create_time: payload?.create_time ?? scheme.create_time,
algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params, 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<Props> = ({
const mostAnomalousDay = const mostAnomalousDay =
payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-"; payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0; 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 ( return (
<Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md"> <Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md">
@@ -293,30 +302,36 @@ const SchemeQuery: React.FC<Props> = ({
<Box className="grid grid-cols-3 gap-2"> <Box className="grid grid-cols-3 gap-2">
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "目标时刻" : "异常天数"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{anomalyDayCount} {targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount}
</Typography> </Typography>
</Box> </Box>
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "目标分数" : "最异常日"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{isBurst {targetTime
? typeof mostAnomalousDay === "number" ? typeof targetScore === "number"
? `${mostAnomalousDay}` ? targetScore.toFixed(4)
: mostAnomalousDay : "-"
: "无"} : isBurst
? typeof mostAnomalousDay === "number"
? `${mostAnomalousDay}`
: mostAnomalousDay
: "无"}
</Typography> </Typography>
</Box> </Box>
<Box className="rounded bg-gray-50 p-2"> <Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500"> <Typography variant="caption" className="text-gray-500">
{targetTime ? "异常排名" : "测点数"}
</Typography> </Typography>
<Typography variant="body2" className="font-semibold text-gray-900"> <Typography variant="body2" className="font-semibold text-gray-900">
{sensorCount} {targetTime && targetRank
? `${targetRank} / ${payload?.day_count ?? 15}`
: sensorCount}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -336,6 +351,8 @@ const SchemeQuery: React.FC<Props> = ({
if (ds === "monitoring") return "监测数据"; if (ds === "monitoring") return "监测数据";
if (os === "simulation_scheme_timerange") return "模拟数据"; if (os === "simulation_scheme_timerange") return "模拟数据";
if (os === "backend_timerange") return "监测数据"; if (os === "backend_timerange") return "监测数据";
if (os === "latest_monitoring") return "最新监测数据";
if (os === "historical_monitoring") return "历史监测回放";
return os || "-"; return os || "-";
})()} })()}
</Typography> </Typography>
@@ -354,14 +371,16 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600"> <Typography variant="caption" className="text-gray-600">
: :
</Typography> </Typography>
<Typography variant="caption" className="font-medium text-gray-900"> <Typography variant="caption" className="font-medium text-gray-900">
{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 ?? {scheme.scheme_detail?.algorithm_params?.points_per_day ??
payload?.algorithm_params?.points_per_day ?? payload?.algorithm_params?.points_per_day ??
"-"} "-"}
{payload?.summary?.score_threshold ?? "-"}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -3,11 +3,16 @@ export interface BurstDetectionRow {
Score: number; Score: number;
Prediction: number; Prediction: number;
IsBurst: boolean; IsBurst: boolean;
Timestamp?: string;
Role?: "reference" | "target";
} }
export interface BurstDetectionSensorRanking { export interface BurstDetectionSensorRanking {
sensor_node: string; sensor_node: string;
latest_high_frequency_value: number; latest_high_frequency_value: number;
historical_mean?: number;
historical_std?: number;
standardized_deviation?: number;
} }
export interface BurstDetectionSummary { export interface BurstDetectionSummary {
@@ -17,6 +22,11 @@ export interface BurstDetectionSummary {
anomaly_days: number[]; anomaly_days: number[];
anomaly_day_count: number; anomaly_day_count: number;
latest_sensor_rankings: BurstDetectionSensorRanking[]; latest_sensor_rankings: BurstDetectionSensorRanking[];
target_score?: number;
score_threshold?: number;
target_rank?: number;
target_time?: string;
reference_day_count?: number;
} }
export interface BurstDetectionAlgorithmParams { export interface BurstDetectionAlgorithmParams {
@@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams {
contamination?: number | "auto"; contamination?: number | "auto";
random_state?: number; random_state?: number;
}; };
score_threshold?: number;
} }
export interface BurstDetectionResult { export interface BurstDetectionResult {
@@ -51,6 +62,25 @@ export interface BurstDetectionResult {
type?: string; type?: string;
}; };
algorithm_params?: BurstDetectionAlgorithmParams; 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 { export interface BurstDetectionSchemeDetail {