feat(history): merge event simulation history with interpolation
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 "事件模拟";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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