feat(history): show event context with online simulation and SCADA

This commit is contained in:
2026-11-03 12:40:39 +08:00
parent d013f34493
commit 2d312371b0
6 changed files with 556 additions and 117 deletions
@@ -1,21 +1,34 @@
import React from "react"; import React from "react";
import { render, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import dayjs from "dayjs";
import ReactECharts from "echarts-for-react";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns";
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel"; import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({ jest.mock("@/lib/apiFetch", () => ({
apiFetch: jest.fn(), apiFetch: jest.fn(),
})); }));
jest.mock("@/lib/analysisRuns", () => ({
...jest.requireActual("@/lib/analysisRuns"),
getAnalysisScheme: jest.fn(),
}));
jest.mock("@components/olmap/core/MapComponent", () => ({ jest.mock("@components/olmap/core/MapComponent", () => ({
useData: () => null, useData: () => null,
})); }));
jest.mock("@mui/x-date-pickers", () => ({ jest.mock("@mui/x-date-pickers", () => ({
DateTimePicker: () => null, DateTimePicker: ({ label, value, onChange }: {
label: string; value: dayjs.Dayjs; onChange: (value: dayjs.Dayjs) => void;
}) => React.createElement("input", {
"aria-label": label,
value: value.toISOString(),
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(dayjs(event.target.value)),
}),
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children, LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
})); }));
jest.mock("echarts-for-react", () => () => null); jest.mock("echarts-for-react", () => jest.fn(() => null));
const jsonResponse = (payload: unknown, status = 200) => const jsonResponse = (payload: unknown, status = 200) =>
({ ({
@@ -66,14 +79,63 @@ describe("fetchHistoryData", () => {
"99dd4142-368b-54cb-bfca-d59ee48f6298", "99dd4142-368b-54cb-bfca-d59ee48f6298",
); );
expect(apiFetch).toHaveBeenCalledTimes(1); expect(apiFetch).toHaveBeenCalledTimes(2);
const [, init] = jest.mocked(apiFetch).mock.calls[0]; const bodies = jest.mocked(apiFetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
expect(JSON.parse(String(init?.body))).toMatchObject({ expect(bodies[0]).toMatchObject({ mode: "realtime_comparison" });
expect(bodies[0]).not.toHaveProperty("run_id");
expect(bodies[1]).toMatchObject({
mode: "analysis_comparison", mode: "analysis_comparison",
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298", run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
}); });
}); });
it("combines element simulations without SCADA and deduplicates monitoring", async () => {
const base = {
element_id: "P-1", element_type: "pipe", device_id: null,
metric: "flow", source_unit: "L/s", display_unit: "m³/h", unit_inferred: false,
};
const observed = {
...base, device_id: "F-1", source: "scada_raw",
points: [{ time: "2026-08-17T09:30:00Z", value: 5 }],
};
const targets: [string, string][] = [["P-1", "pipe"], ["J-1", "junction"]];
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
const { mode } = JSON.parse(String(init?.body));
return jsonResponse({ series: mode === "analysis_comparison" ? [
observed,
{ ...base, source: "analysis_simulation", points: [
{ time: "2026-08-17T09:30:00Z", value: 20 },
] },
] : [
observed,
{ ...base, source: "realtime_simulation", points: [
{ time: "2026-08-17T09:00:00Z", value: 10 },
{ time: "2026-08-17T10:00:00Z", value: 12 },
] },
{ ...base, element_id: "J-1", element_type: "junction", metric: "pressure",
source_unit: "m", source: "realtime_simulation", points: [
{ time: "2026-08-17T09:00:00Z", value: 30 },
] },
] });
});
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
expect(result.series).toHaveLength(4);
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 });
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72);
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2);
expect(apiFetch).toHaveBeenCalledTimes(2);
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
}
});
it("reports an aggregate request failure instead of showing an incomplete comparison", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(jsonResponse({ series: [] }))
.mockResolvedValueOnce(jsonResponse({ detail: "analysis unavailable" }, 503));
await expect(fetchHistoryData([["P-1", "pipe"]], range, "scheme", "run-1"))
.rejects.toThrow("analysis unavailable");
});
it("rejects oversized element selections before issuing requests", async () => { it("rejects oversized element selections before issuing requests", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({})); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
const featureInfos = Array.from( const featureInfos = Array.from(
@@ -103,10 +165,18 @@ describe("fetchHistoryData", () => {
}); });
}); });
const eventRun = (runId = "run-1", startedAt = "2026-08-17T09:00:00Z") =>
toAnalysisSchemeRecord({
run_id: runId, name: "event", run_type: "burst_analysis", created_by: "alice",
created_at: "2026-11-03T00:00:00Z", started_at: startedAt,
status: "completed", parameters: { modify_total_duration: 3600 },
});
describe("HistoryDataPanel requests", () => { describe("HistoryDataPanel requests", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun());
}); });
afterEach(() => jest.clearAllMocks()); afterEach(() => jest.clearAllMocks());
@@ -137,4 +207,138 @@ describe("HistoryDataPanel requests", () => {
); );
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
}); });
const props = {
featureInfos: [["P-1", "pipe"]] as [string, string][],
type: "scheme" as const, runId: "run-1", onClose: jest.fn(),
};
it("initializes a historical event with one hour of context on both sides", async () => {
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body))).toMatchObject({
start_time: "2026-08-17T08:00:00.000Z", end_time: "2026-08-17T11:00:00.000Z",
});
}
expect(screen.getByText(/事件方案时段:2026-08-17 17:00/)).toBeInTheDocument();
expect(screen.getByLabelText("开始时间")).toHaveValue("2026-08-17T08:00:00.000Z");
});
it("keeps explicit query dates and issues one pair of requests per date change", async () => {
render(React.createElement(HistoryDataPanel, {
...props, start_time: "2026-08-17T07:00:00Z", end_time: "2026-08-17T12:00:00Z",
}));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
fireEvent.change(screen.getByLabelText("开始时间"), { target: { value: "2026-08-17T06:00:00Z" } });
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4));
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
});
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(getAnalysisScheme).toHaveBeenCalledTimes(1);
});
it("ignores a stale scheme response after switching events", async () => {
let resolveOld!: (value: ReturnType<typeof eventRun>) => void;
jest.mocked(getAnalysisScheme).mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; }))
.mockResolvedValueOnce(eventRun("run-2", "2026-08-18T09:00:00Z"));
const { rerender } = render(React.createElement(HistoryDataPanel, props));
expect(apiFetch).not.toHaveBeenCalled();
rerender(React.createElement(HistoryDataPanel, { ...props, runId: "run-2" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
await act(async () => { resolveOld(eventRun()); });
expect(apiFetch).toHaveBeenCalledTimes(2);
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toMatchObject({
run_id: "run-2", start_time: "2026-08-18T08:00:00.000Z",
});
});
it("preserves the manually chosen end date when only the start date prop changes", async () => {
const partialProps = { ...props, type: "none" as const, start_time: "2026-08-17T07:00:00Z" };
const { rerender } = render(React.createElement(HistoryDataPanel, partialProps));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText("结束时间"), { target: { value: "2026-08-17T12:00:00Z" } });
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(React.createElement(HistoryDataPanel, { ...partialProps, start_time: "2026-08-17T06:00:00Z" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(3));
expect(screen.getByLabelText("结束时间")).toHaveValue("2026-08-17T12:00:00.000Z");
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
});
});
it("queries online element readings for an analysis without hydraulic results", async () => {
jest.mocked(getAnalysisScheme).mockResolvedValue({ ...eventRun(), run_type: "burst_detection" });
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
const body = JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body));
expect(body.mode).toBe("realtime_comparison");
expect(body).not.toHaveProperty("run_id");
});
it("does not restore stale curves after the selected elements are cleared", async () => {
const pending: Array<(value: Response) => void> = [];
jest.mocked(apiFetch).mockImplementation(() => new Promise((resolve) => { pending.push(resolve); }));
const { rerender } = render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(React.createElement(HistoryDataPanel, { ...props, featureInfos: [] }));
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(jest.mocked(apiFetch).mock.calls[1][1]?.signal?.aborted).toBe(true);
await act(async () => {
pending[0](jsonResponse({ series: [{
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source: "realtime_simulation", source_unit: "m3/h", display_unit: "m³/h",
unit_inferred: false, points: [{ time: "2026-08-17T09:00:00Z", value: 10 }],
}] }));
pending[1](jsonResponse({ series: [] }));
});
expect(ReactECharts).not.toHaveBeenCalled();
expect(screen.getByText("尚未选择地图要素")).toBeInTheDocument();
});
it("allows a failed event metadata query to be retried", async () => {
jest.mocked(getAnalysisScheme).mockRejectedValueOnce(new Error("unavailable"));
render(React.createElement(HistoryDataPanel, props));
await screen.findByText(/无法读取事件方案/);
expect(apiFetch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
});
it("plots native timestamps and explicit gaps with the event interval marked", async () => {
const series = {
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
};
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
const { mode } = JSON.parse(String(init?.body));
return jsonResponse({ series: [{ ...series,
source: mode === "analysis_comparison" ? "analysis_simulation" : "realtime_simulation",
points: mode === "analysis_comparison" ? [
{ time: "2026-08-17T09:15:00Z", value: 8 },
] : [
{ time: "2026-08-17T08:00:00Z", value: 10 },
{ time: "2026-08-17T09:00:00Z", value: null },
{ time: "2026-08-17T11:00:00Z", value: 12 },
],
}] });
});
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(ReactECharts).toHaveBeenCalled());
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
expect(option.series[0].connectNulls).toBe(false);
expect(option.series[0].data).toEqual([
[Date.parse("2026-08-17T08:00:00Z"), 10],
[Date.parse("2026-08-17T09:00:00Z"), null],
[Date.parse("2026-08-17T11:00:00Z"), 12],
]);
expect(option.series[0].markArea.data[0]).toEqual([
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
]);
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
});
}); });
@@ -41,8 +41,14 @@ import {
fetchElementHistory, fetchElementHistory,
historySeriesKey, historySeriesKey,
historySeriesLabel, historySeriesLabel,
toTimeSeriesPoints,
TimeSeriesPoint, TimeSeriesPoint,
} from "@/lib/elementHistory"; } from "@/lib/elementHistory";
import {
type AnalysisHistoryContext,
getAnalysisHistoryContext,
getAnalysisScheme,
} from "@/lib/analysisRuns";
import { import {
FLOW_DISPLAY_UNIT, FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT,
@@ -54,7 +60,7 @@ dayjs.extend(timezone);
export interface SCADADataPanelProps { export interface SCADADataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
featureInfos: [string, string][]; featureInfos: [string, string][];
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */ /** 数据类型: realtime-在线模拟与监测, none-仅监测, scheme-方案、在线模拟与监测 */
type?: "realtime" | "scheme" | "none"; type?: "realtime" | "scheme" | "none";
/** 默认展示的选项卡 */ /** 默认展示的选项卡 */
defaultTab?: "chart" | "table"; defaultTab?: "chart" | "table";
@@ -107,6 +113,13 @@ export const fetchHistoryData = async (
) { ) {
throw new Error("历史数据包含无效的管网元素 ID"); throw new Error("历史数据包含无效的管网元素 ID");
} }
if (
featureInfos.some(
([, elementType]) => !["pipe", "junction"].includes(elementType.toLowerCase()),
)
) {
throw new Error("历史数据仅支持管道和节点");
}
const uniqueFeatureInfos = Array.from( const uniqueFeatureInfos = Array.from(
new Map( new Map(
@@ -118,25 +131,33 @@ export const fetchHistoryData = async (
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据"); throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
} }
try { const elements = uniqueFeatureInfos.map(([id, elementType]) => ({
return await fetchElementHistory( element_id: id.trim(),
uniqueFeatureInfos.map(([id, elementType]) => ({ element_type: elementType.toLowerCase() as "pipe" | "junction",
element_id: id.trim(), }));
element_type: elementType.toLowerCase() as "pipe" | "junction", if (type === "scheme") {
})), const [online, analysis] = await Promise.all([
range, fetchElementHistory(
type === "none" elements, range, "realtime_comparison", undefined, signal,
? "observed" ),
: type === "scheme" fetchElementHistory(
? "analysis_comparison" elements, range, "analysis_comparison", schemeRunId, signal,
: "realtime_comparison", ),
schemeRunId, ]);
signal, // 两次聚合查询均返回监测序列,只保留在线查询中的一份。
); const series = [
} catch (error) { ...online.series,
console.error("[HistoryDataPanel] 从后端获取数据失败:", error); ...analysis.series.filter((item) => item.source === "analysis_simulation"),
throw error; ];
return { series, points: toTimeSeriesPoints(series) };
} }
return fetchElementHistory(
elements,
range,
type === "none" ? "observed" : "realtime_comparison",
undefined,
signal,
);
}; };
const formatTimestamp = (timestamp: string) => const formatTimestamp = (timestamp: string) =>
@@ -186,11 +207,11 @@ const emptyStateMessages: Record<
> = { > = {
chart: { chart: {
title: "暂无时序数据", title: "暂无时序数据",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
}, },
table: { table: {
title: "暂无表格数据", title: "暂无表格数据",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
}, },
}; };
@@ -204,30 +225,65 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
runId, runId,
onClose, onClose,
}) => { }) => {
// 从 featureInfos 中提取设备 ID 列表 const [schemeState, setSchemeState] = useState<{
const deviceIds = useMemo( runId: string;
() => featureInfos.map(([id]) => id), context?: AnalysisHistoryContext;
[featureInfos] error?: string;
); } | null>(null);
const [schemeReload, setSchemeReload] = useState(0);
const schemeReady = type !== "scheme" || !runId || schemeState?.runId === runId;
const schemeContext =
type === "scheme" && schemeState?.runId === runId
? schemeState?.context
: undefined;
const schemeError =
type === "scheme" && schemeState?.runId === runId
? schemeState?.error
: undefined;
const eventRange = schemeContext?.eventRange;
const rangeKey = JSON.stringify([type, runId]);
const defaultRange = useMemo(() => {
const now = dayjs();
const start = start_time ? dayjs(start_time) : null;
const end = end_time ? dayjs(end_time) : null;
return {
from: start?.isValid()
? start
: eventRange
? dayjs(eventRange.start).subtract(1, "hour")
: now.subtract(1, "day"),
to: end?.isValid()
? end
: eventRange
? dayjs(eventRange.end).add(1, "hour")
: now,
};
}, [start_time, end_time, eventRange]);
const [selectedRange, setSelectedRange] = useState<{
key: string;
from: Dayjs;
to: Dayjs;
startTime?: string;
endTime?: string;
} | null>(null);
const rangeForContext = selectedRange?.key === rangeKey ? selectedRange : null;
const from = rangeForContext && (
rangeForContext.startTime === start_time || !start_time || !dayjs(start_time).isValid()
) ? rangeForContext.from : defaultRange.from;
const to = rangeForContext && (
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
) ? rangeForContext.to : defaultRange.to;
const setFrom = (value: Dayjs) =>
setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time });
const setTo = (value: Dayjs) =>
setSelectedRange({ key: rangeKey, from, to: value, startTime: start_time, endTime: end_time });
const [from, setFrom] = useState<Dayjs>(() => { useEffect(() => {
if (start_time) { if (!rangeForContext || (
const parsedStart = dayjs(start_time); rangeForContext.startTime === start_time && rangeForContext.endTime === end_time
if (parsedStart.isValid()) { )) return;
return parsedStart; setSelectedRange({ key: rangeKey, from, to, startTime: start_time, endTime: end_time });
} }, [rangeForContext, rangeKey, from, to, start_time, end_time]);
}
return dayjs().subtract(1, "day");
});
const [to, setTo] = useState<Dayjs>(() => {
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
return parsedEnd;
}
}
return dayjs();
});
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]); const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]); const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
@@ -241,20 +297,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, [defaultTab]); }, [defaultTab]);
useEffect(() => { useEffect(() => {
if (!start_time && !end_time) return; if (type !== "scheme" || !runId) return;
if (start_time) { let cancelled = false;
const parsedStart = dayjs(start_time); getAnalysisScheme(runId).then(
if (parsedStart.isValid()) { (run) => {
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart)); if (!cancelled) {
} setSchemeState({ runId, context: getAnalysisHistoryContext(run) });
} }
if (end_time) { },
const parsedEnd = dayjs(end_time); () => {
if (parsedEnd.isValid()) { if (!cancelled) {
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd)); setSchemeState({ runId, error: "无法读取事件方案,请刷新重试" });
} }
} },
}, [start_time, end_time]); );
return () => {
cancelled = true;
};
}, [type, runId, schemeReload]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
@@ -272,9 +332,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
); );
const handleFetch = useCallback( const handleFetch = useCallback(
async (reason: string) => { async () => {
if (!hasDevices) { if (!hasDevices || !schemeReady || schemeError) {
requestControllerRef.current?.abort(); requestControllerRef.current?.abort();
requestControllerRef.current = null;
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
setLoadingState("idle"); setLoadingState("idle");
@@ -295,26 +356,37 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
from: rangeFrom.toDate(), from: rangeFrom.toDate(),
to: rangeTo.toDate(), to: rangeTo.toDate(),
}, },
type, type === "scheme" && schemeContext && !schemeContext.simulationRunId
runId, ? "realtime"
: type,
schemeContext?.simulationRunId,
requestController.signal, requestController.signal,
); );
if (requestControllerRef.current !== requestController) return; if (
requestController.signal.aborted ||
requestControllerRef.current !== requestController
) return;
setTimeSeries(result.points); setTimeSeries(result.points);
setHistorySeries(result.series); setHistorySeries(result.series);
setLoadingState("success"); setLoadingState("success");
} catch (err) { } catch (err) {
if ( if (
requestControllerRef.current !== requestController || requestControllerRef.current !== requestController ||
requestController.signal.aborted ||
(err instanceof Error && err.name === "AbortError") (err instanceof Error && err.name === "AbortError")
) { ) {
return; return;
} }
setTimeSeries([]);
setHistorySeries([]);
setError(err instanceof Error ? err.message : "未知错误"); setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error"); setLoadingState("error");
} }
}, },
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type], [
requestedFeatureInfos, hasDevices, normalizedRange,
schemeReady, schemeError, schemeContext, type,
],
); );
useEffect( useEffect(
@@ -324,15 +396,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
[], [],
); );
// 设备变化时自动查询 // 元素、方案或查询范围变化时自动查询。
useEffect(() => { useEffect(() => {
if (hasDevices) { void handleFetch();
handleFetch("device-change"); }, [handleFetch]);
} else {
setTimeSeries([]);
setHistorySeries([]);
}
}, [handleFetch, hasDevices]);
const columns: GridColDef[] = useMemo(() => { const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [ const base: GridColDef[] = [
@@ -424,7 +491,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
"#3f51b5", // 靛蓝色 "#3f51b5", // 靛蓝色
]; ];
const xData = dataset.map((item) => item.label);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow"); const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some( const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure", (item) => item.metric === "pressure",
@@ -453,11 +519,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
symbolSize: isObserved ? 7 : 0, symbolSize: isObserved ? 7 : 0,
showSymbol: isObserved, showSymbol: isObserved,
sampling: "lttb", sampling: "lttb",
connectNulls: !isObserved, connectNulls: false,
itemStyle: { itemStyle: {
color: colors[index % colors.length], color: colors[index % colors.length],
}, },
data: dataset.map((item) => item[key]), data: timeSeries
.filter((point) =>
Object.prototype.hasOwnProperty.call(point.values, key),
)
.map((point) => [
new Date(point.timestamp).getTime(), point.values[key] ?? null,
]),
lineStyle: isObserved ? { width: 0 } : undefined, lineStyle: isObserved ? { width: 0 } : undefined,
areaStyle: isObserved areaStyle: isObserved
? undefined ? undefined
@@ -507,9 +579,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, },
}, },
xAxis: { xAxis: {
type: "category", type: "time",
boundaryGap: false, boundaryGap: false,
data: xData, min: normalizedRange.from.valueOf(),
max: normalizedRange.to.valueOf(),
axisLabel: {
formatter: (value: number) => formatTimestamp(new Date(value).toISOString()),
},
}, },
yAxis: [ yAxis: [
...(hasFlowSeries ...(hasFlowSeries
@@ -542,7 +618,21 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
end: 100, end: 100,
}, },
], ],
series: getSeries(), series: getSeries().map((series, index) => ({
...series,
...(index === 0 && eventRange
? {
markArea: {
silent: true,
itemStyle: { color: "rgba(255, 152, 0, 0.10)" },
data: [[
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
{ xAxis: Date.parse(eventRange.end) },
]],
},
}
: {}),
})),
}; };
return ( return (
@@ -716,16 +806,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setFrom(value); setFrom(value);
} }
}} }}
onAccept={(value) => {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}
maxDateTime={to} maxDateTime={to}
slotProps={{ slotProps={{
textField: { fullWidth: true, size: "small" }, textField: { fullWidth: true, size: "small" },
@@ -739,16 +819,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setTo(value); setTo(value);
} }
}} }}
onAccept={(value) => {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}
minDateTime={from} minDateTime={from}
slotProps={{ slotProps={{
textField: { fullWidth: true, size: "small" }, textField: { fullWidth: true, size: "small" },
@@ -787,8 +857,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
size="small" size="small"
color="primary" color="primary"
startIcon={<Refresh fontSize="small" />} startIcon={<Refresh fontSize="small" />}
disabled={!hasDevices || loadingState === "loading"} disabled={
onClick={() => handleFetch("manual")} !hasDevices || !schemeReady || loadingState === "loading"
}
onClick={() => {
if (schemeError) {
setSchemeState(null);
setSchemeReload((value) => value + 1);
} else {
void handleFetch();
}
}}
> >
刷新 刷新
</Button> </Button>
@@ -799,13 +878,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack> </Stack>
</LocalizationProvider> </LocalizationProvider>
{error && ( {eventRange && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)}
</Typography>
)}
{(schemeError || error) && (
<Typography <Typography
variant="caption" variant="caption"
color="error" color="error"
sx={{ mt: 1, display: "block" }} sx={{ mt: 1, display: "block" }}
> >
获取数据失败:{error} 获取数据失败:{schemeError || error}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -814,7 +898,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
{/* Content */} {/* Content */}
<Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}> <Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}>
{loadingState === "loading" && ( {(!schemeReady || loadingState === "loading") && (
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
+47
View File
@@ -2,6 +2,7 @@ import { api } from "@/lib/api";
import { import {
getAnalysisResults, getAnalysisResults,
getAnalysisScheme, getAnalysisScheme,
getAnalysisHistoryContext,
listAnalysisSchemes, listAnalysisSchemes,
} from "./analysisRuns"; } from "./analysisRuns";
@@ -94,3 +95,49 @@ describe("analysis runs API adapter", () => {
); );
}); });
}); });
describe("analysis history context", () => {
const run = {
run_id: "run-1", name: "event", run_type: "burst_analysis",
created_by: "alice", created_at: "2026-11-03T05:00:00Z",
started_at: "2026-08-25T09:00:00+08:00", status: "completed", parameters: {},
};
it.each([
["burst_analysis", { modify_total_duration: 3600 }],
["flushing_analysis", { duration: 3600 }],
["contaminant_analysis", { duration: 3600 }],
])("uses the simulated interval of %s rather than its creation time", (run_type, parameters) => {
expect(getAnalysisHistoryContext({ ...run, run_type, parameters })).toEqual({
simulationRunId: "run-1",
eventRange: { start: "2026-08-25T01:00:00.000Z", end: "2026-08-25T02:00:00.000Z" },
});
});
it("does not treat a detection run as a hydraulic simulation", () => {
expect(getAnalysisHistoryContext({
...run, run_type: "burst_detection", parameters: {
scada_window: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
},
})).toEqual({
eventRange: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
});
});
it("uses the linked hydraulic run for a localization analysis", () => {
expect(getAnalysisHistoryContext({
...run, run_type: "burst_location", parameters: {
result_payload: {
simulation_run: { run_id: "simulation-1" },
scada_window: { burst_start: "2026-08-25T01:00:00Z", burst_end: "2026-08-25T02:00:00Z" },
},
},
}).simulationRunId).toBe("simulation-1");
});
it("rejects invalid or missing event durations without inventing an interval", () => {
expect(getAnalysisHistoryContext(run).eventRange).toBeNull();
expect(getAnalysisHistoryContext({ ...run, parameters: { duration: -1 } }).eventRange).toBeNull();
expect(getAnalysisHistoryContext({ ...run, started_at: "invalid", parameters: { duration: 3600 } }).eventRange).toBeNull();
});
});
+60
View File
@@ -11,6 +11,66 @@ export type AnalysisRun = {
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
}; };
export type AnalysisHistoryContext = {
simulationRunId?: string;
eventRange: { start: string; end: string } | null;
};
const SIMULATION_RUN_TYPES = new Set([
"burst_analysis",
"flushing_analysis",
"contaminant_analysis",
"valve_close_analysis",
"pressure_regulation",
]);
export const getAnalysisHistoryContext = (
run: AnalysisRun,
): AnalysisHistoryContext => {
const parameters = normalizeParameters(run.parameters);
if (SIMULATION_RUN_TYPES.has(run.run_type)) {
const start = Date.parse(run.started_at);
const duration = parameters.modify_total_duration ?? parameters.duration;
const end = new Date(
typeof duration === "number" ? start + duration * 1000 : NaN,
).getTime();
return {
simulationRunId: run.run_id,
eventRange:
Number.isFinite(start) && Number.isFinite(end) && end > start
? {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
}
: null,
};
}
const payload = normalizeParameters(parameters.result_payload);
const window = normalizeParameters(
parameters.scada_window ?? payload.scada_window,
);
const start = window.burst_start ?? window.start ?? parameters.scada_start;
const end = window.burst_end ?? window.end ?? parameters.scada_end;
const simulation = normalizeParameters(
payload.simulation_run ?? payload.simulation_scheme,
);
const simulationRunId = parameters.simulation_run_id ?? simulation.run_id;
return {
simulationRunId:
typeof simulationRunId === "string" && simulationRunId.trim()
? simulationRunId.trim()
: undefined,
eventRange:
typeof start === "string" &&
typeof end === "string" &&
Number.isFinite(Date.parse(start)) &&
Date.parse(end) > Date.parse(start)
? { start, end }
: null,
};
};
export type AnalysisResult = { export type AnalysisResult = {
result_id: string; result_id: string;
run_id: string; run_id: string;
+40 -2
View File
@@ -28,8 +28,46 @@ describe("element history display conversion", () => {
]; ];
const points = toTimeSeriesPoints(series); const points = toTimeSeriesPoints(series);
expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z"); expect(points[0].timestamp).toBe("2026-09-01T00:00:00.000Z");
expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334); expect(points[0].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(83.3333333334);
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213); expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
}); });
it("aligns equivalent timestamps and keeps same-name pipes and junctions separate", () => {
const base: ElementHistorySeries = {
element_id: "shared-id", element_type: "pipe", device_id: null,
metric: "flow", source: "realtime_simulation", source_unit: "L/s",
display_unit: "m³/h", unit_inferred: false,
points: [{ time: "2026-09-01T08:00:00+08:00", value: 10 }],
};
const points = toTimeSeriesPoints([
base,
{ ...base, element_type: "junction", metric: "pressure", source_unit: "m",
points: [{ time: "2026-09-01T00:00:00Z", value: 20 }] },
{ ...base, source: "analysis_simulation", points: [
{ time: "2026-09-01T00:00:00.000Z", value: null },
] },
]);
expect(points).toHaveLength(1);
expect(points[0].values).toEqual({
'["pipe","shared-id","realtime_simulation"]': 36,
'["junction","shared-id","realtime_simulation"]': 20,
'["pipe","shared-id","analysis_simulation"]': null,
});
});
it("keeps online IDs ending in _scheme distinct from another element's scheme series", () => {
const base: ElementHistorySeries = {
element_id: "P_scheme", element_type: "pipe", device_id: null,
metric: "flow", source: "realtime_simulation", source_unit: "m3/h",
display_unit: "m³/h", unit_inferred: false,
points: [{ time: "2026-09-01T00:00:00Z", value: 100 }],
};
const points = toTimeSeriesPoints([
base,
{ ...base, element_id: "P", source: "analysis_simulation",
points: [{ time: "2026-09-01T00:00:00Z", value: 5 }] },
]);
expect(Object.values(points[0].values).sort((a, b) => Number(a) - Number(b))).toEqual([5, 100]);
});
}); });
+12 -6
View File
@@ -49,8 +49,10 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
analysis_simulation: "scheme_sim", analysis_simulation: "scheme_sim",
}; };
export const historySeriesKey = (series: ElementHistorySeries) => export const historySeriesKey = (series: ElementHistorySeries) => {
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`; if (series.device_id) return `${series.device_id}_${SOURCE_SUFFIX[series.source]}`;
return JSON.stringify([series.element_type, series.element_id, series.source]);
};
export const historySourceLabel = (source: HistorySource) => { export const historySourceLabel = (source: HistorySource) => {
switch (source) { switch (source) {
@@ -65,8 +67,11 @@ export const historySourceLabel = (source: HistorySource) => {
} }
}; };
export const historySeriesLabel = (series: ElementHistorySeries) => export const historySeriesLabel = (series: ElementHistorySeries) => {
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`; const target = series.device_id ??
`${series.element_type === "pipe" ? "管道" : "节点"} ${series.element_id}`;
return `${target} (${historySourceLabel(series.source)})`;
};
export const toTimeSeriesPoints = ( export const toTimeSeriesPoints = (
seriesList: ElementHistorySeries[], seriesList: ElementHistorySeries[],
@@ -75,13 +80,14 @@ export const toTimeSeriesPoints = (
seriesList.forEach((series) => { seriesList.forEach((series) => {
const key = historySeriesKey(series); const key = historySeriesKey(series);
series.points.forEach((point) => { series.points.forEach((point) => {
const values = timeMap.get(point.time) ?? {}; const timestamp = new Date(point.time).toISOString();
const values = timeMap.get(timestamp) ?? {};
values[key] = toDisplayValue( values[key] = toDisplayValue(
point.value, point.value,
series.metric, series.metric,
series.source_unit, series.source_unit,
); );
timeMap.set(point.time, values); timeMap.set(timestamp, values);
}); });
}); });
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort( return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(