Compare commits

...
Author SHA1 Message Date
jiang cd9d04a370 feat(history): merge event simulation history with interpolation
Generic Container CI/CD / test-build-publish (push) Successful in 1m8s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m8s
2026-11-07 12:54:19 +08:00
5 changed files with 273 additions and 16 deletions
@@ -126,10 +126,10 @@ describe("fetchHistoryData", () => {
] }); ] });
}); });
const result = await fetchHistoryData(targets, range, "scheme", "run-1"); const result = await fetchHistoryData(targets, range, "scheme", "run-1");
expect(result.series).toHaveLength(4); expect(result.series).toHaveLength(3);
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 }); 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","analysis_simulation"]']).toBe(72); expect(result.points[1].values['["pipe","P-1","event_simulation"]']).toBe(72);
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2); expect(result.points[2].values['["pipe","P-1","event_simulation"]']).toBeCloseTo(43.2);
expect(apiFetch).toHaveBeenCalledTimes(2); expect(apiFetch).toHaveBeenCalledTimes(2);
for (const [, init] of jest.mocked(apiFetch).mock.calls) { for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2); expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
@@ -317,7 +317,7 @@ describe("HistoryDataPanel requests", () => {
expect(getAnalysisScheme).toHaveBeenCalledTimes(2); 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 = { const series = {
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow", element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false, 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; 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.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].connectNulls).toBe(false);
expect(option.series[0].data).toEqual([ expect(option.series).toHaveLength(1);
[Date.parse("2026-08-17T08:00:00Z"), 10], expect(option.series[0].name).toBe("管道 P-1 (事件模拟) [m³/h]");
[Date.parse("2026-08-17T09:00:00Z"), null], expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T08:00:00Z"), 10]);
[Date.parse("2026-08-17T11:00:00Z"), 12], 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([ expect(option.series[0].markArea.data[0]).toEqual([
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") }, { name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
{ xAxis: Date.parse("2026-08-17T10: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 () => { it("snaps off-step event context without changing the actual event marker", async () => {
@@ -40,9 +40,9 @@ import {
fetchElementHistory, fetchElementHistory,
historySeriesKey, historySeriesKey,
historySeriesLabel, historySeriesLabel,
toTimeSeriesPoints,
TimeSeriesPoint, TimeSeriesPoint,
} from "@/lib/elementHistory"; } from "@/lib/elementHistory";
import { mergeEventHistory, type EventHistoryOptions } from "@/lib/eventHistory";
import { import {
type AnalysisHistoryContext, type AnalysisHistoryContext,
getAnalysisHistoryContext, getAnalysisHistoryContext,
@@ -106,6 +106,7 @@ export const fetchHistoryData = async (
type: "realtime" | "scheme" | "none", type: "realtime" | "scheme" | "none",
schemeRunId?: string, schemeRunId?: string,
signal?: AbortSignal, signal?: AbortSignal,
eventOptions?: EventHistoryOptions,
): Promise<ElementHistoryResult> => { ): Promise<ElementHistoryResult> => {
if (featureInfos.length === 0) { if (featureInfos.length === 0) {
return { points: [], series: [] }; return { points: [], series: [] };
@@ -157,7 +158,9 @@ export const fetchHistoryData = async (
...online.series, ...online.series,
...analysis.series.filter((item) => item.source === "analysis_simulation"), ...analysis.series.filter((item) => item.source === "analysis_simulation"),
]; ];
return { series, points: toTimeSeriesPoints(series) }; return mergeEventHistory(series, range, eventOptions ?? {
eventRange: null, stepSeconds: 0,
});
} }
return fetchElementHistory( return fetchElementHistory(
elements, elements,
@@ -308,6 +311,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
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[]>([]);
const [hasOnlineSimulationData, setHasOnlineSimulationData] = useState(false);
const [loadingState, setLoadingState] = useState<LoadingState>("idle"); const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSimulating, setIsSimulating] = useState(false); const [isSimulating, setIsSimulating] = useState(false);
@@ -402,6 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
requestControllerRef.current = null; requestControllerRef.current = null;
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
setHasOnlineSimulationData(false);
setLoadingState("idle"); setLoadingState("idle");
setError(null); setError(null);
return; return;
@@ -425,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: type, : type,
schemeContext?.simulationRunId, schemeContext?.simulationRunId,
requestController.signal, requestController.signal,
{ eventRange: eventRange ?? null, stepSeconds },
); );
if ( if (
requestController.signal.aborted || requestController.signal.aborted ||
@@ -432,6 +438,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
) return; ) return;
setTimeSeries(result.points); setTimeSeries(result.points);
setHistorySeries(result.series); 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"); setLoadingState("success");
} catch (err) { } catch (err) {
if ( if (
@@ -443,13 +453,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
} }
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
setHasOnlineSimulationData(false);
setError(err instanceof Error ? err.message : "未知错误"); setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error"); setLoadingState("error");
} }
}, },
[ [
requestedFeatureInfos, hasDevices, normalizedRange, requestedFeatureInfos, hasDevices, normalizedRange,
schemeReady, schemeError, schemeContext, type, schemeReady, schemeError, schemeContext, type, eventRange, stepSeconds,
], ],
); );
@@ -1017,7 +1028,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Typography> </Typography>
)} )}
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" && {!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && ( !hasOnlineSimulationData && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}> <Typography variant="caption" sx={{ mt: 1, display: "block" }}>
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""} 当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
</Typography> </Typography>
+8 -1
View File
@@ -12,7 +12,9 @@ export type HistorySource =
| "scada_raw" | "scada_raw"
| "scada_cleaned" | "scada_cleaned"
| "realtime_simulation" | "realtime_simulation"
| "analysis_simulation"; | "analysis_simulation"
// 前端按事件时段拼接历史在线结果与方案结果。
| "event_simulation";
export interface ElementHistoryTarget { export interface ElementHistoryTarget {
element_id: string; element_id: string;
@@ -40,6 +42,8 @@ export interface TimeSeriesPoint {
export interface ElementHistoryResult { export interface ElementHistoryResult {
points: TimeSeriesPoint[]; points: TimeSeriesPoint[];
series: ElementHistorySeries[]; series: ElementHistorySeries[];
/** 事件序列合并后仍保留在线历史是否存在的信息。 */
hasOnlineSimulationData?: boolean;
} }
const SOURCE_SUFFIX: Record<HistorySource, string> = { const SOURCE_SUFFIX: Record<HistorySource, string> = {
@@ -47,6 +51,7 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
scada_cleaned: "clean", scada_cleaned: "clean",
realtime_simulation: "sim", realtime_simulation: "sim",
analysis_simulation: "scheme_sim", analysis_simulation: "scheme_sim",
event_simulation: "event_sim",
}; };
export const historySeriesKey = (series: ElementHistorySeries) => { export const historySeriesKey = (series: ElementHistorySeries) => {
@@ -64,6 +69,8 @@ export const historySourceLabel = (source: HistorySource) => {
return "实时模拟"; return "实时模拟";
case "analysis_simulation": case "analysis_simulation":
return "方案模拟"; return "方案模拟";
case "event_simulation":
return "事件模拟";
} }
}; };
+106
View File
@@ -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> = {},
): 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]);
});
});
+128
View File
@@ -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<number, number | null>();
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<string, {
online?: ElementHistorySeries;
analysis?: ElementHistorySeries;
}>();
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<number, number | null>();
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(),
),
),
};
};