Use project-scoped endpoints and run IDs for current project, SCADA metadata, historical time series, migrated DMA results, and sensor placement runs. The regressions recurred because tests mocked pre-interceptor and new-only payload shapes, while history queries relied on implicit global scheme state and unbounded request fan-out. Cover post-interceptor pages, migrated result_rows, explicit run_id routing, multi-element requests, request limits, and cancellation.
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
FormControl,
|
|
MenuItem,
|
|
Select,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
|
import { useNotification } from "@refinedev/core";
|
|
import dayjs, { Dayjs } from "dayjs";
|
|
import "dayjs/locale/zh-cn";
|
|
import { api } from "@/lib/api";
|
|
import { NETWORK_NAME } from "@config/config";
|
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
|
import { getApiErrorMessage } from "@/lib/apiError";
|
|
import { BurstDetectionResult } from "./types";
|
|
import {
|
|
createSchemeName,
|
|
isSchemeNameValid,
|
|
normalizeSchemeName,
|
|
SCHEME_NAME_MAX_LENGTH,
|
|
SCHEME_NAME_PREFIXES,
|
|
} from "@utils/schemeName";
|
|
|
|
interface Props {
|
|
onResult: (result: BurstDetectionResult) => void;
|
|
state?: BurstDetectionAnalysisParametersState;
|
|
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
|
|
}
|
|
|
|
export interface BurstDetectionAnalysisParametersState {
|
|
schemeName: string;
|
|
detectionMode: "latest" | "historical";
|
|
targetTime: Dayjs | null;
|
|
samplingIntervalMinutes: number;
|
|
samplingIntervalSource: "metadata" | "manual";
|
|
}
|
|
|
|
interface ScadaInfoItem {
|
|
device_type?: string;
|
|
transmission_frequency?: string | number | null;
|
|
}
|
|
|
|
interface ScadaInfoResponse {
|
|
data?: ScadaInfoItem[];
|
|
}
|
|
|
|
const currentQuarterHour = () => {
|
|
const now = dayjs().second(0).millisecond(0);
|
|
return now.minute(Math.floor(now.minute() / 15) * 15);
|
|
};
|
|
|
|
export const createBurstDetectionAnalysisParametersState =
|
|
(): BurstDetectionAnalysisParametersState => ({
|
|
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection),
|
|
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.device_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 fetchPressureSamplingInterval = async () => {
|
|
const response = await api.get<ScadaInfoResponse>(
|
|
"/api/v1/scada-info/database-view",
|
|
);
|
|
return resolvePressureSamplingInterval(
|
|
Array.isArray(response.data.data) ? response.data.data : [],
|
|
);
|
|
};
|
|
|
|
export const buildBurstDetectionRequest = (
|
|
parameters: BurstDetectionAnalysisParametersState,
|
|
) => ({
|
|
scheme_name: normalizeSchemeName(parameters.schemeName),
|
|
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 {
|
|
schemeName,
|
|
detectionMode,
|
|
targetTime,
|
|
samplingIntervalMinutes,
|
|
samplingIntervalSource,
|
|
} = parametersState;
|
|
const [running, setRunning] = useState(false);
|
|
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
|
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
|
|
setParametersState({
|
|
...draft,
|
|
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
|
|
}),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (samplingIntervalSource !== "metadata") return;
|
|
let active = true;
|
|
setFrequencyLoading(true);
|
|
fetchPressureSamplingInterval()
|
|
.then((interval) => {
|
|
if (!active) return;
|
|
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]);
|
|
|
|
const samplingIntervalValid =
|
|
Number.isInteger(samplingIntervalMinutes) &&
|
|
samplingIntervalMinutes > 0 &&
|
|
1440 % samplingIntervalMinutes === 0;
|
|
|
|
const isValid = useMemo(
|
|
() =>
|
|
isSchemeNameValid(schemeName) &&
|
|
samplingIntervalValid &&
|
|
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
|
|
[detectionMode, samplingIntervalValid, schemeName, targetTime],
|
|
);
|
|
|
|
const handleRun = async () => {
|
|
if (!isValid) {
|
|
open?.({
|
|
type: "error",
|
|
message: "参数不完整",
|
|
description: "请输入方案名称,并检查历史目标时间。",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setRunning(true);
|
|
open?.({
|
|
key: "burst-detection-analysis-progress",
|
|
type: "progress",
|
|
message: "正在执行爆管侦测",
|
|
description: "正在读取目标时刻及前 14 天同刻基线。",
|
|
undoableTimeout: 3,
|
|
});
|
|
|
|
try {
|
|
const response = await api.post(
|
|
"/api/v1/burst-detections",
|
|
buildBurstDetectionRequest(parametersState),
|
|
);
|
|
onResult(response.data as BurstDetectionResult);
|
|
open?.({
|
|
key: "burst-detection-analysis-success",
|
|
type: "success",
|
|
message: "爆管侦测完成",
|
|
description: response.data.summary?.burst_detected
|
|
? "目标时刻存在异常信号,请优先复核相关测点。"
|
|
: "目标时刻未发现爆管异常。",
|
|
});
|
|
} catch (error) {
|
|
open?.({
|
|
key: "burst-detection-analysis-error",
|
|
type: "error",
|
|
message: "侦测失败",
|
|
description: getApiErrorMessage(error, "爆管侦测请求失败"),
|
|
});
|
|
} finally {
|
|
setRunning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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">
|
|
方案名称
|
|
</Typography>
|
|
<TextField
|
|
value={schemeName}
|
|
onChange={(event) => setFormField("schemeName", event.target.value)}
|
|
placeholder="请输入方案名称"
|
|
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
|
|
helperText={
|
|
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
|
|
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
|
|
: undefined
|
|
}
|
|
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
|
|
fullWidth
|
|
size="small"
|
|
/>
|
|
</Box>
|
|
|
|
<Box>
|
|
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
侦测方式
|
|
</Typography>
|
|
<FormControl fullWidth size="small">
|
|
<Select
|
|
value={detectionMode}
|
|
onChange={(event) =>
|
|
setFormField(
|
|
"detectionMode",
|
|
event.target.value as "latest" | "historical",
|
|
)
|
|
}
|
|
>
|
|
<MenuItem value="latest">检测最新数据</MenuItem>
|
|
<MenuItem value="historical">历史时刻回放</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
|
|
{detectionMode === "historical" ? (
|
|
<LocalizationProvider
|
|
dateAdapter={AdapterDayjs}
|
|
adapterLocale="zh-cn"
|
|
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
|
>
|
|
<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>
|
|
</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 flex gap-2 pt-3">
|
|
<Button
|
|
variant="outlined"
|
|
fullWidth
|
|
disabled={running}
|
|
onClick={() =>
|
|
setParametersState(createBurstDetectionAnalysisParametersState())
|
|
}
|
|
>
|
|
重置
|
|
</Button>
|
|
<Button
|
|
variant="contained"
|
|
fullWidth
|
|
disabled={!isValid || running}
|
|
onClick={() => void handleRun()}
|
|
>
|
|
{running
|
|
? "侦测中..."
|
|
: detectionMode === "latest"
|
|
? "侦测最新数据"
|
|
: "回放目标时刻"}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default AnalysisParameters;
|