Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9d04a370 |
@@ -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>
|
||||
|
||||
@@ -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 "事件模拟";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user