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");
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 () => {
@@ -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<ElementHistoryResult> => {
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<SCADADataPanelProps> = ({
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [hasOnlineSimulationData, setHasOnlineSimulationData] = useState(false);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [isSimulating, setIsSimulating] = useState(false);
@@ -402,6 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
requestControllerRef.current = null;
setTimeSeries([]);
setHistorySeries([]);
setHasOnlineSimulationData(false);
setLoadingState("idle");
setError(null);
return;
@@ -425,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: type,
schemeContext?.simulationRunId,
requestController.signal,
{ eventRange: eventRange ?? null, stepSeconds },
);
if (
requestController.signal.aborted ||
@@ -432,6 +438,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
) 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<SCADADataPanelProps> = ({
}
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<SCADADataPanelProps> = ({
</Typography>
)}
{!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" }}>
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
</Typography>
+8 -1
View File
@@ -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<HistorySource, string> = {
@@ -47,6 +51,7 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
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 "事件模拟";
}
};
+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(),
),
),
};
};