From cd9d04a37086569e30d897dbfb21aa8830d69f56 Mon Sep 17 00:00:00 2001 From: Huarch Date: Sat, 7 Nov 2026 12:54:19 +0800 Subject: [PATCH] feat(history): merge event simulation history with interpolation --- .../core/Controls/HistoryDataPanel.test.ts | 27 ++-- .../olmap/core/Controls/HistoryDataPanel.tsx | 19 ++- src/lib/elementHistory.ts | 9 +- src/lib/eventHistory.test.ts | 106 +++++++++++++++ src/lib/eventHistory.ts | 128 ++++++++++++++++++ 5 files changed, 273 insertions(+), 16 deletions(-) create mode 100644 src/lib/eventHistory.test.ts create mode 100644 src/lib/eventHistory.ts diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts index cdd5ea4..7e3f396 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts +++ b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts @@ -126,10 +126,10 @@ describe("fetchHistoryData", () => { ] }); }); 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(result.series).toHaveLength(3); + expect(result.points[0].values).toEqual({ '["pipe","P-1","event_simulation"]': 36, '["junction","J-1","event_simulation"]': 30 }); + expect(result.points[1].values['["pipe","P-1","event_simulation"]']).toBe(72); + expect(result.points[2].values['["pipe","P-1","event_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); @@ -317,7 +317,7 @@ describe("HistoryDataPanel requests", () => { expect(getAnalysisScheme).toHaveBeenCalledTimes(2); }); - it("plots native timestamps and explicit gaps with the event interval marked", async () => { + it("plots a single interpolated event curve 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, @@ -340,16 +340,21 @@ describe("HistoryDataPanel requests", () => { 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).toHaveLength(1); + expect(option.series[0].name).toBe("管道 P-1 (事件模拟) [m³/h]"); + expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T08:00:00Z"), 10]); + expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:00:00Z"), 8.4]); + expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:15:00Z"), 8]); + expect(option.series[0].data).toContainEqual([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]]); + expect(screen.queryByText(/当前时段暂无在线模拟数据/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "表格" })); + expect(await screen.findByRole("columnheader", { name: "管道 P-1 (事件模拟) [m³/h]" })).toBeInTheDocument(); + expect(screen.getAllByRole("columnheader")).toHaveLength(2); + expect(screen.getByRole("gridcell", { name: "8.40" })).toBeInTheDocument(); }); it("snaps off-step event context without changing the actual event marker", async () => { diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 92665a8..c9a1276 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -40,9 +40,9 @@ import { fetchElementHistory, historySeriesKey, historySeriesLabel, - toTimeSeriesPoints, TimeSeriesPoint, } from "@/lib/elementHistory"; +import { mergeEventHistory, type EventHistoryOptions } from "@/lib/eventHistory"; import { type AnalysisHistoryContext, getAnalysisHistoryContext, @@ -106,6 +106,7 @@ export const fetchHistoryData = async ( type: "realtime" | "scheme" | "none", schemeRunId?: string, signal?: AbortSignal, + eventOptions?: EventHistoryOptions, ): Promise => { if (featureInfos.length === 0) { return { points: [], series: [] }; @@ -157,7 +158,9 @@ export const fetchHistoryData = async ( ...online.series, ...analysis.series.filter((item) => item.source === "analysis_simulation"), ]; - return { series, points: toTimeSeriesPoints(series) }; + return mergeEventHistory(series, range, eventOptions ?? { + eventRange: null, stepSeconds: 0, + }); } return fetchElementHistory( elements, @@ -308,6 +311,7 @@ const SCADADataPanel: React.FC = ({ const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); const [historySeries, setHistorySeries] = useState([]); + const [hasOnlineSimulationData, setHasOnlineSimulationData] = useState(false); const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); const [isSimulating, setIsSimulating] = useState(false); @@ -402,6 +406,7 @@ const SCADADataPanel: React.FC = ({ requestControllerRef.current = null; setTimeSeries([]); setHistorySeries([]); + setHasOnlineSimulationData(false); setLoadingState("idle"); setError(null); return; @@ -425,6 +430,7 @@ const SCADADataPanel: React.FC = ({ : type, schemeContext?.simulationRunId, requestController.signal, + { eventRange: eventRange ?? null, stepSeconds }, ); if ( requestController.signal.aborted || @@ -432,6 +438,10 @@ const SCADADataPanel: React.FC = ({ ) return; setTimeSeries(result.points); setHistorySeries(result.series); + setHasOnlineSimulationData(result.hasOnlineSimulationData ?? result.series.some( + (series) => series.source === "realtime_simulation" + && series.points.some((point) => point.value !== null && Number.isFinite(point.value)), + )); setLoadingState("success"); } catch (err) { if ( @@ -443,13 +453,14 @@ const SCADADataPanel: React.FC = ({ } setTimeSeries([]); setHistorySeries([]); + setHasOnlineSimulationData(false); setError(err instanceof Error ? err.message : "未知错误"); setLoadingState("error"); } }, [ requestedFeatureInfos, hasDevices, normalizedRange, - schemeReady, schemeError, schemeContext, type, + schemeReady, schemeError, schemeContext, type, eventRange, stepSeconds, ], ); @@ -1017,7 +1028,7 @@ const SCADADataPanel: React.FC = ({ )} {!isSimulating && type !== "none" && hasDevices && loadingState === "success" && - !historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && ( + !hasOnlineSimulationData && ( 当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""} diff --git a/src/lib/elementHistory.ts b/src/lib/elementHistory.ts index 3af2048..695ede7 100644 --- a/src/lib/elementHistory.ts +++ b/src/lib/elementHistory.ts @@ -12,7 +12,9 @@ export type HistorySource = | "scada_raw" | "scada_cleaned" | "realtime_simulation" - | "analysis_simulation"; + | "analysis_simulation" + // 前端按事件时段拼接历史在线结果与方案结果。 + | "event_simulation"; export interface ElementHistoryTarget { element_id: string; @@ -40,6 +42,8 @@ export interface TimeSeriesPoint { export interface ElementHistoryResult { points: TimeSeriesPoint[]; series: ElementHistorySeries[]; + /** 事件序列合并后仍保留在线历史是否存在的信息。 */ + hasOnlineSimulationData?: boolean; } const SOURCE_SUFFIX: Record = { @@ -47,6 +51,7 @@ const SOURCE_SUFFIX: Record = { scada_cleaned: "clean", realtime_simulation: "sim", analysis_simulation: "scheme_sim", + event_simulation: "event_sim", }; export const historySeriesKey = (series: ElementHistorySeries) => { @@ -64,6 +69,8 @@ export const historySourceLabel = (source: HistorySource) => { return "实时模拟"; case "analysis_simulation": return "方案模拟"; + case "event_simulation": + return "事件模拟"; } }; diff --git a/src/lib/eventHistory.test.ts b/src/lib/eventHistory.test.ts new file mode 100644 index 0000000..fc77c20 --- /dev/null +++ b/src/lib/eventHistory.test.ts @@ -0,0 +1,106 @@ +import { type ElementHistorySeries, historySeriesKey } from "./elementHistory"; +import { mergeEventHistory } from "./eventHistory"; + +const time = (minutes: number) => new Date(Date.UTC(2026, 7, 17, 8, minutes)).toISOString(); +const series = ( + source: ElementHistorySeries["source"], + points: Array<[number, number | null]>, + overrides: Partial = {}, +): ElementHistorySeries => ({ + element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow", + source, source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false, + points: points.map(([minutes, value]) => ({ time: time(minutes), value })), + ...overrides, +}); +const range = { from: new Date(time(0)), to: new Date(time(120)) }; +const options = { eventRange: { start: time(38), end: time(83) }, stepSeconds: 900 }; +const values = (item: ElementHistorySeries) => Object.fromEntries( + item.points.map((point) => [point.time, point.value]), +); + +describe("mergeEventHistory", () => { + it("uses event values only in the event window and interpolates mismatched times and transitions", () => { + const result = mergeEventHistory([ + series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [60, 999], [90, 100], [120, 100]]), + series("analysis_simulation", [[23, 999], [38, 80], [53, 60], [68, 40], [83, 80], [98, 999]]), + ], range, options); + expect(result.series).toHaveLength(1); + expect(result.series[0].source).toBe("event_simulation"); + const merged = values(result.series[0]); + expect(merged[time(15)]).toBe(100); + expect(merged[time(38)]).toBe(80); + expect(merged[time(45)]).toBeCloseTo(70.6666667); + expect(merged[time(60)]).toBeCloseTo(50.6666667); + expect(merged[time(83)]).toBe(80); + expect(merged[time(90)]).toBe(100); + expect(merged[time(105)]).toBe(100); + expect(merged[time(23)]).toBeUndefined(); + expect(merged[time(98)]).toBeUndefined(); + expect(result.points.find((point) => point.timestamp === time(45))?.values[ + historySeriesKey(result.series[0]) + ]).toBeCloseTo(70.6666667); + }); + + it("interpolates across the seam when the first event sample starts after the event boundary", () => { + const result = mergeEventHistory([ + series("realtime_simulation", [[30, 100], [45, 999], [90, 100]]), + series("analysis_simulation", [[53, 54], [68, 24]]), + ], range, options); + const merged = values(result.series[0]); + expect(merged[time(38)]).toBe(84); + expect(merged[time(45)]).toBe(70); + expect(merged[time(75)]).toBeCloseTo(48.181818); + expect(merged[time(0)]).toBeNull(); + expect(merged[time(120)]).toBeNull(); + }); + + it("converts different source units before merging and preserves SCADA samples", () => { + const observed = series("scada_raw", [[40, 12], [50, null]], { device_id: "F-1", source_unit: "L/s" }); + const result = mergeEventHistory([ + observed, + series("realtime_simulation", [[0, 10], [90, 10]], { source_unit: "L/s" }), + series("analysis_simulation", [[38, 20], [83, 20]]), + ], range, options); + expect(result.series[0]).toBe(observed); + expect(result.points.find((point) => point.timestamp === time(40))?.values["F-1_raw"]).toBe(43.2); + const merged = values(result.series[1]); + expect(merged[time(0)]).toBe(36); + expect(merged[time(38)]).toBe(20); + expect(merged[time(90)]).toBe(36); + }); + + it("preserves explicit missing samples and never interpolates through them", () => { + const result = mergeEventHistory([ + series("realtime_simulation", [[0, 100], [30, null], [90, 100], [120, 100]]), + series("analysis_simulation", [[38, 80], [53, null], [68, 40], [83, 80]]), + ], range, options); + const merged = values(result.series[0]); + expect(merged[time(15)]).toBeNull(); + expect(merged[time(30)]).toBeNull(); + expect(merged[time(45)]).toBeNull(); + expect(merged[time(53)]).toBeNull(); + expect(merged[time(60)]).toBeNull(); + expect(merged[time(75)]).toBeCloseTo(58.6666667); + }); + + it("keeps missing event results as gaps instead of inserting normal baseline values", () => { + const result = mergeEventHistory([ + series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [90, 100], [120, 100]]), + ], range, options); + expect(values(result.series[0])[time(45)]).toBeNull(); + expect(values(result.series[0])[time(90)]).toBe(100); + }); + + it("keeps same-name pipes and junctions separate and supports analysis-only results", () => { + const result = mergeEventHistory([ + series("analysis_simulation", [[38, 80], [83, 80]]), + series("analysis_simulation", [[38, 30], [83, 30]], { + element_type: "junction", metric: "pressure", source_unit: "m", display_unit: "m", + }), + ], range, options); + expect(result.series).toHaveLength(2); + const point = result.points.find((item) => item.timestamp === time(45))!; + expect(Object.values(point.values)).toEqual([80, 30]); + expect(Object.values(result.points[0].values)).toEqual([null, null]); + }); +}); diff --git a/src/lib/eventHistory.ts b/src/lib/eventHistory.ts new file mode 100644 index 0000000..d16ea31 --- /dev/null +++ b/src/lib/eventHistory.ts @@ -0,0 +1,128 @@ +import { + type ElementHistoryResult, + type ElementHistorySeries, + toTimeSeriesPoints, +} from "./elementHistory"; +import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, toDisplayValue } from "@/utils/units"; + +export interface EventHistoryOptions { + eventRange: { start: string; end: string } | null; + stepSeconds: number; +} + +type Sample = { time: number; value: number | null }; + +const samples = (series: ElementHistorySeries | undefined): Sample[] => { + const values = new Map(); + series?.points.forEach((point) => { + const time = Date.parse(point.time); + if (!Number.isFinite(time)) return; + const value = toDisplayValue(point.value, series.metric, series.source_unit); + values.set(time, value !== null && Number.isFinite(value) ? value : null); + }); + return Array.from(values, ([time, value]) => ({ time, value })) + .sort((left, right) => left.time - right.time); +}; + +/** 拼接已保存的模拟结果;仅在相邻有效样本之间插值,保留显式缺测,不外推。 */ +export const mergeEventHistory = ( + series: ElementHistorySeries[], + range: { from: Date; to: Date }, + { eventRange, stepSeconds }: EventHistoryOptions, +): ElementHistoryResult => { + const groups = new Map(); + const observed = series.filter((item) => item.source.startsWith("scada_")); + series.forEach((item) => { + if (item.source !== "realtime_simulation" && item.source !== "analysis_simulation") return; + const key = JSON.stringify([item.element_type, item.element_id, item.metric]); + const group = groups.get(key) ?? {}; + if (item.source === "realtime_simulation") group.online = item; + else group.analysis = item; + groups.set(key, group); + }); + + const merged = Array.from(groups.values(), ({ online, analysis }) => { + const baseline = samples(online); + const event = samples(analysis); + const start = eventRange ? Date.parse(eventRange.start) : event[0]?.time ?? NaN; + const end = eventRange ? Date.parse(eventRange.end) : event.at(-1)?.time ?? NaN; + const hasEventRange = Number.isFinite(start) && Number.isFinite(end) && end >= start; + const anchors = new Map(); + baseline.forEach((point) => { + if (!hasEventRange || point.time < start || point.time > end) { + anchors.set(point.time, point.value); + } + }); + event.forEach((point) => { + if (hasEventRange && point.time >= start && point.time <= end) { + anchors.set(point.time, point.value); + } + }); + if (hasEventRange && !event.some((point) => point.time >= start && point.time <= end)) { + anchors.set(start, null); + anchors.set(end, null); + } + const ordered = Array.from(anchors, ([time, value]) => ({ time, value })) + .sort((left, right) => left.time - right.time); + const from = range.from.getTime(); + const to = range.to.getTime(); + const times = new Set([ + ...baseline.map((point) => point.time), + ...ordered.map((point) => point.time), + from, to, + ]); + if (hasEventRange) { + times.add(start); + times.add(end); + } + const step = stepSeconds * 1000; + if (Number.isFinite(step) && step > 0 && ordered.length) { + const first = Math.max(from, ordered[0].time); + const last = Math.min(to, ordered[ordered.length - 1].time); + for (let time = Math.ceil(first / step) * step; time <= last; time += step) { + times.add(time); + } + } + + let index = 0; + const points = Array.from(times).filter((time) => time >= from && time <= to) + .sort((left, right) => left - right).map((time) => { + while (index < ordered.length && ordered[index].time < time) index += 1; + const right = ordered[index]; + const left = ordered[index - 1]; + let value: number | null = null; + if (right?.time === time) value = right.value; + else if (left?.value != null && right?.value != null) { + value = left.value + (right.value - left.value) + * (time - left.time) / (right.time - left.time); + } + return { time: new Date(time).toISOString(), value }; + }); + const metadata = analysis ?? online!; + const unit = metadata.metric === "flow" ? FLOW_DISPLAY_UNIT : PRESSURE_DISPLAY_UNIT; + return { + ...metadata, + device_id: null, + source: "event_simulation" as const, + source_unit: unit, + display_unit: unit, + unit_inferred: Boolean(online?.unit_inferred || analysis?.unit_inferred), + points, + }; + }); + const result = [...observed, ...merged]; + return { + series: result, + points: toTimeSeriesPoints(result), + hasOnlineSimulationData: series.some((item) => + item.source === "realtime_simulation" && item.points.some((point) => + point.value !== null && Number.isFinite(point.value) + && Date.parse(point.time) >= range.from.getTime() + && Date.parse(point.time) <= range.to.getTime(), + ), + ), + }; +};