feat(burst-detection): update analysis parameters
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user