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";
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<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> = ({
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<Props> = ({
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<Props> = ({
};
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>
<Typography variant="subtitle2" className="mb-1 font-medium">
@@ -297,211 +228,89 @@ const AnalysisParameters: React.FC<Props> = ({
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<FormControl fullWidth size="small">
<Select
value={dataSource}
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")}
value={detectionMode}
onChange={(event) =>
setFormField(
"detectionMode",
event.target.value as "latest" | "historical",
)
}
>
<MenuItem value="monitoring"></MenuItem>
<MenuItem value="simulation"></MenuItem>
<MenuItem value="latest"></MenuItem>
<MenuItem value="historical"></MenuItem>
</Select>
</FormControl>
</Box>
{isSimulationMode && (
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<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" },
}}
{detectionMode === "historical" ? (
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
>
<Typography variant="body2" color="text.secondary">
</Typography>
<ExpandMoreIcon
sx={{
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
/>
</Box>
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
<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>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<DateTimePicker
value={targetTime}
onChange={(value) => setFormField("targetTime", value)}
maxDateTime={dayjs()}
minutesStep={15}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
</Collapse>
</Box>
</LocalizationProvider>
) : 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 className="mt-auto pt-3 flex gap-2">
<Box className="mt-auto flex gap-2 pt-3">
<Button
variant="outlined"
fullWidth
disabled={running}
sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => {
setParametersState((previous) => ({
...previous,
schemeName: `Burst_Detection_${Date.now()}`,
scadaStart: dayjs().subtract(3, "day"),
scadaEnd: dayjs(),
mu: 100,
pointsPerDay: 96,
nEstimators: 50,
contaminationInput: "auto",
}));
}}
onClick={() =>
setParametersState(createBurstDetectionAnalysisParametersState())
}
>
</Button>
@@ -509,11 +318,13 @@ const AnalysisParameters: React.FC<Props> = ({
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 ? <CircularProgress size={20} color="inherit" /> : "开始侦测"}
{running
? "侦测中..."
: detectionMode === "latest"
? "侦测最新数据"
: "回放目标时刻"}
</Button>
</Box>
</Box>
@@ -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<MetricCardProps["tone"], string> = {
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 (
<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}`}>
{label}
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => (
<Box
className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`}
>
<Typography variant="caption" className="mb-1 block font-semibold">
{label}
</Typography>
<Typography variant="body2" className="font-bold">
{value}
</Typography>
{hint ? (
<Typography variant="caption" className="mt-0.5 block opacity-75">
{hint}
</Typography>
<Typography variant="body2" className={`font-bold ${style.darkText}`}>
{value}
</Typography>
{hint ? (
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}>
{hint}
</Typography>
) : null}
</Box>
);
};
) : null}
</Box>
);
const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
const EmptyState = () => (
<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 variant="body2" className="max-w-xs text-gray-500">
14
</Typography>
</Box>
);
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<Props> = ({
result,
state,
onStateChange,
}) => {
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [internalResultsState, setInternalResultsState] =
useState<BurstDetectionResultsState>(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<BurstDetectionResultsState>(
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<Props> = ({
queryable: false,
},
});
map.addLayer(layer);
highlightLayerRef.current = layer;
return () => {
highlightLayerRef.current = null;
map.removeLayer(layer);
@@ -174,58 +134,67 @@ const DetectionResults: React.FC<Props> = ({
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<BurstDetectionRow | null>(() => {
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<Props> = ({
});
};
if (!result) {
return <EmptyState />;
}
if (!result) return <EmptyState />;
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]}<br/>异常分数:${point[1]}`;
formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => {
const data = params[0]?.data;
return data
? `${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: {
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<Props> = ({
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<Props> = ({
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 <Chip size="small" label={level.label} color={level.color} variant="outlined" />;
},
headerName: "目标判定",
width: 110,
renderCell: ({ value, row }) =>
row.Role === "target" || row.Day === result.day_count ? (
<Chip
size="small"
label={value ? "爆管异常" : "正常"}
color={value ? "error" : "success"}
variant="outlined"
/>
) : (
<Typography variant="caption" color="text.secondary">
</Typography>
),
},
];
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row }));
return (
<Box className="h-full overflow-auto p-1">
<Box className="mb-4 space-y-3">
{/* Status Banner */}
<Box
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected
? "bg-red-50 border-red-100 text-red-900"
: "bg-green-50 border-green-100 text-green-900"
}`}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
isBurstDetected
? "border-red-100 bg-red-50 text-red-900"
: "border-green-100 bg-green-50 text-green-900"
}`}
>
{isBurstDetected ? (
<ErrorOutlineIcon className="text-red-600" />
) : (
<CheckCircleIcon className="text-green-600" />
)}
{isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />}
<Box className="flex-1">
<Typography variant="subtitle2" className="font-bold">
{isBurstDetected
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
: "未侦测到爆管异常"}
{isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"}
</Typography>
<Typography variant="caption" className="opacity-80">
{isBurstDetected
? "建议检查异常日期的压力波动情况"
: "当前时间窗口内数据特征平稳,符合历史模式"}
{formatDateTime(result.target_time ?? result.summary.target_time)}
</Typography>
</Box>
</Box>
{/* Header */}
<Box className="flex items-center justify-between px-1">
<Box className="flex items-center gap-2">
<Box className="h-4 w-1 rounded-full bg-blue-600" />
<Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}>
{result.scheme_name || "爆管侦测结果"}
</Typography>
</Box>
<Box className="flex items-center gap-2">
{result.username ? (
<Chip
label={result.username}
size="small"
sx={{
height: 24,
backgroundColor: "#f3f4f6",
color: "#4b5563",
border: "none",
fontWeight: 500,
}}
/>
) : 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 className="flex items-center justify-between gap-2 px-1">
<Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900">
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<RoomIcon />}
onClick={() =>
void locateSensors(
result.summary.latest_sensor_rankings
.slice(0, 5)
.map((item) => item.sensor_node),
)
}
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
>
</Button>
</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">
<MetricCard
label="异常数"
value={`${result.summary.anomaly_day_count} / ${result.day_count}`}
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`}
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"}
label="目标异常数"
value={targetRow ? targetRow.Score.toFixed(4) : "-"}
hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`}
tone={isBurstDetected ? "orange" : "green"}
/>
<MetricCard
label="最异常日"
value={
result.summary.burst_detected && result.summary.most_anomalous_day
? `${result.summary.most_anomalous_day}`
: "无"
}
hint={
result.summary.burst_detected && mostAnomalousRow
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
: "-"
}
label="目标异常排名"
value={targetRank ? `${targetRank} / ${result.day_count}` : "-"}
hint="在目标日与 14 个参考日中排序"
tone="purple"
/>
<MetricCard
label="最新状态"
value={latestLevel.label}
hint={latestDay ? `${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"}
tone={latestLevel.color === "success" ? "green" : "orange"}
label="参考区间"
value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`}
hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`}
tone="blue"
/>
<MetricCard
label="测点 / 样本"
value={`${result.sensor_nodes.length} / ${result.sample_count}`}
hint={`每日采样点数:${result.points_per_day}`}
label="有效 / 排除测点"
value={`${result.sensor_nodes.length} / ${excludedCount}`}
hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`}
tone="blue"
/>
</Box>
</Box>
{/* Score Trend Chart */}
<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">
<ShowChartIcon className="h-5 w-5 text-blue-600" />
<Typography variant="subtitle1" className="font-bold text-gray-800">
<ShowChartIcon className="text-blue-600" />
<Typography variant="subtitle1" className="font-bold">
15
</Typography>
</Box>
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。">
<Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。">
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
</Tooltip>
</Box>
<Box sx={{ height: 250, px: 1.5, py: 1 }}>
<Box sx={{ height: 270, px: 1.5, py: 1 }}>
<ReactECharts
option={chartOption}
style={{ height: "100%", width: "100%" }}
onEvents={{
click: (params: { data?: { value?: [number, number] } }) => {
const day = params?.data?.value?.[0];
if (typeof day === "number") {
setSelectedDay(day);
}
},
click: (params: { data?: { day?: number } }) =>
setSelectedDay(params.data?.day ?? null),
}}
/>
</Box>
</Box>
{/* Selected Day Interpretation */}
{/* <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>
{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>
{rankingSeries.length > 0 ? (
<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 px-4 py-3">
<Typography variant="subtitle1" className="font-bold">
</Typography>
<Typography variant="body2" className="text-gray-700">
模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1" : "正常日(Prediction = 1"}
</Typography>
<Typography variant="body2" className="text-gray-700">
解读建议:
{selectedRow.Score <= -0.6
? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
: selectedRow.Score <= -0.2
? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
: "未见明显异常,可作为基线日参考。"}
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
) : (
<Typography variant="body2" className="px-4 py-3 text-gray-500">
请在趋势图或表格中选择一天查看详细解释。
</Typography>
)}
</Box> */}
<Box sx={{ height: 280, px: 1.5, py: 1 }}>
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
</Box>
</Box>
) : null}
{/* 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="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
<Box className="flex items-center gap-2">
<FormatListBulleted className="h-5 w-5 text-blue-600" />
<Typography variant="subtitle1" className="font-bold text-gray-800">
</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 className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
<FormatListBulleted className="text-blue-600" />
<Typography variant="subtitle1" className="font-bold">
</Typography>
</Box>
<Box sx={{ height: 320, px: 1, py: 1 }}>
<Box sx={{ height: 360, px: 1, py: 1 }}>
<DataGrid
rows={rows}
rows={tableRows}
columns={columns}
columnBufferPx={100}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
initialState={{
pagination: { paginationModel: { pageSize: 50, 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" },
}}
pageSizeOptions={[15]}
initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }}
disableRowSelectionOnClick
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
getRowClassName={(params) =>
params.row.Day === resultsState.selectedDay ? "bg-blue-50" : ""
}
sx={{ border: "none" }}
/>
</Box>
</Box>
@@ -115,6 +115,12 @@ const SchemeQuery: React.FC<Props> = ({
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<Props> = ({
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 (
<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="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500">
{targetTime ? "目标时刻" : "异常天数"}
</Typography>
<Typography variant="body2" className="font-semibold text-gray-900">
{anomalyDayCount}
{targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount}
</Typography>
</Box>
<Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500">
{targetTime ? "目标分数" : "最异常日"}
</Typography>
<Typography variant="body2" className="font-semibold text-gray-900">
{isBurst
? typeof mostAnomalousDay === "number"
? `${mostAnomalousDay}`
: mostAnomalousDay
: "无"}
{targetTime
? typeof targetScore === "number"
? targetScore.toFixed(4)
: "-"
: isBurst
? typeof mostAnomalousDay === "number"
? `${mostAnomalousDay}`
: mostAnomalousDay
: "无"}
</Typography>
</Box>
<Box className="rounded bg-gray-50 p-2">
<Typography variant="caption" className="text-gray-500">
{targetTime ? "异常排名" : "测点数"}
</Typography>
<Typography variant="body2" className="font-semibold text-gray-900">
{sensorCount}
{targetTime && targetRank
? `${targetRank} / ${payload?.day_count ?? 15}`
: sensorCount}
</Typography>
</Box>
</Box>
@@ -336,6 +351,8 @@ const SchemeQuery: React.FC<Props> = ({
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 || "-";
})()}
</Typography>
@@ -354,14 +371,16 @@ const SchemeQuery: React.FC<Props> = ({
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
:
</Typography>
<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 ??
payload?.algorithm_params?.points_per_day ??
"-"}
{payload?.summary?.score_threshold ?? "-"}
</Typography>
</Box>
</Box>
@@ -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 {