feat(history): align units and history data
This commit is contained in:
@@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
ElementHistoryResult,
|
||||
ElementHistorySeries,
|
||||
ElementHistoryTarget,
|
||||
fetchElementHistory,
|
||||
historySeriesKey,
|
||||
TimeSeriesPoint,
|
||||
toTimeSeriesPoints,
|
||||
} from "@/lib/elementHistory";
|
||||
import {
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
} from "@/utils/units";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -50,13 +63,6 @@ type IUser = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
/** ISO8601 时间戳 */
|
||||
timestamp: string;
|
||||
/** 每个设备对应的值 */
|
||||
values: Record<string, number | null | undefined>;
|
||||
}
|
||||
|
||||
export interface SCADADataPanelProps {
|
||||
/** 选中的设备 ID 列表 */
|
||||
deviceIds: string[];
|
||||
@@ -90,164 +96,75 @@ const panelHeaderActionSx = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 从后端 API 获取 SCADA 数据
|
||||
*/
|
||||
interface ScadaDeviceMetadata {
|
||||
device_id: string;
|
||||
device_type: string;
|
||||
node_id: string | null;
|
||||
link_id: string | null;
|
||||
}
|
||||
|
||||
/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */
|
||||
const fetchFromBackend = async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date },
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
): Promise<ElementHistoryResult> => {
|
||||
if (deviceIds.length === 0) {
|
||||
return [];
|
||||
return { points: [], series: [] };
|
||||
}
|
||||
|
||||
const device_ids = deviceIds.join(",");
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
// 优先查询清洗数据和模拟数据
|
||||
const [cleaningRes, simulationRes] = await Promise.all([
|
||||
apiFetch(cleaningDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(simulationDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const cleaningData = transformBackendData(cleaningRes, deviceIds);
|
||||
const simulationData = transformBackendData(simulationRes, deviceIds);
|
||||
|
||||
// 如果清洗数据有数据,返回清洗和模拟数据
|
||||
if (cleaningData.length > 0) {
|
||||
return mergeTimeSeriesData(
|
||||
cleaningData,
|
||||
simulationData,
|
||||
deviceIds,
|
||||
"clean",
|
||||
"sim",
|
||||
);
|
||||
} else {
|
||||
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
||||
const rawRes = await apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
const rawData = transformBackendData(rawRes, deviceIds);
|
||||
return mergeTimeSeriesData(
|
||||
simulationData,
|
||||
rawData,
|
||||
deviceIds,
|
||||
"sim",
|
||||
"raw",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换后端数据格式
|
||||
* 根据实际后端返回的数据结构进行调整
|
||||
*/
|
||||
const transformBackendData = (
|
||||
backendData: any,
|
||||
deviceIds: string[],
|
||||
): TimeSeriesPoint[] => {
|
||||
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
|
||||
if (backendData && !Array.isArray(backendData)) {
|
||||
// 检查是否是设备ID为键的对象格式
|
||||
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
|
||||
|
||||
if (hasDeviceKeys) {
|
||||
// 获取所有时间点的集合
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = backendData[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[deviceId] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为 TimeSeriesPoint 数组并按时间排序
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}),
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// 默认返回空数组
|
||||
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 合并两个时间序列数据,为每个设备添加后缀
|
||||
*/
|
||||
const mergeTimeSeriesData = (
|
||||
data1: TimeSeriesPoint[],
|
||||
data2: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
suffix1: string,
|
||||
suffix2: string,
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: TimeSeriesPoint[], suffix: string) => {
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
processData(data1, suffix1);
|
||||
processData(data2, suffix2);
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}));
|
||||
|
||||
result.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
const metadataResponse = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/scada-devices`,
|
||||
);
|
||||
if (!metadataResponse.ok) {
|
||||
throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`);
|
||||
}
|
||||
const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[];
|
||||
const requested = new Set(deviceIds);
|
||||
const devices = allDevices.filter((device) => requested.has(device.device_id));
|
||||
const missing = deviceIds.filter(
|
||||
(deviceId) => !devices.some((device) => device.device_id === deviceId),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
const targetsByElement = new Map<string, ElementHistoryTarget>();
|
||||
devices.forEach((device) => {
|
||||
const isFlow = ["pipe_flow", "flow"].includes(
|
||||
device.device_type.toLowerCase(),
|
||||
);
|
||||
const elementId = isFlow ? device.link_id : device.node_id;
|
||||
const elementType = isFlow ? "pipe" : "junction";
|
||||
if (!elementId) {
|
||||
throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`);
|
||||
}
|
||||
const key = `${elementType}:${elementId}`;
|
||||
const target = targetsByElement.get(key) ?? {
|
||||
element_id: elementId,
|
||||
element_type: elementType,
|
||||
device_ids: [],
|
||||
};
|
||||
target.device_ids!.push(device.device_id);
|
||||
targetsByElement.set(key, target);
|
||||
});
|
||||
const targets = Array.from(targetsByElement.values());
|
||||
const result = await fetchElementHistory(
|
||||
targets,
|
||||
range,
|
||||
"realtime_comparison",
|
||||
);
|
||||
const expandedSeries = result.series.flatMap((series) => {
|
||||
if (series.device_id || series.source.startsWith("scada_")) return [series];
|
||||
const target = targets.find(
|
||||
(item) =>
|
||||
item.element_id === series.element_id &&
|
||||
item.element_type === series.element_type,
|
||||
);
|
||||
return (target?.device_ids ?? []).map((deviceId) => ({
|
||||
...series,
|
||||
device_id: deviceId,
|
||||
}));
|
||||
});
|
||||
return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) };
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) =>
|
||||
@@ -337,83 +254,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const { open } = useNotification();
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
|
||||
const customFetcher = useMemo(() => {
|
||||
if (!showCleaning) {
|
||||
return fetchFromBackend;
|
||||
}
|
||||
|
||||
return async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date },
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
const device_ids = deviceIds.join(",");
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||
apiFetch(cleaningDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(simulationDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: any, suffix: string) => {
|
||||
if (!data) return;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = data[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[`${deviceId}_${suffix}`] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processData(cleanRes, "clean");
|
||||
processData(rawRes, "raw");
|
||||
processData(simRes, "sim");
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}),
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 获取三种数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}, [showCleaning]);
|
||||
const customFetcher = fetchFromBackend;
|
||||
|
||||
const [from, setFrom] = useState<Dayjs>(() => {
|
||||
if (start_time) {
|
||||
@@ -435,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
@@ -473,11 +315,30 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||
[timeSeries, deviceIds, fractionDigits, showCleaning],
|
||||
);
|
||||
const seriesByKey = useMemo(
|
||||
() =>
|
||||
new Map(historySeries.map((series) => [historySeriesKey(series), series])),
|
||||
[historySeries],
|
||||
);
|
||||
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
|
||||
const hasPressureSeries = historySeries.some(
|
||||
(item) => item.metric === "pressure",
|
||||
);
|
||||
const unitForKey = useCallback(
|
||||
(key: string) => seriesByKey.get(key)?.display_unit ?? "",
|
||||
[seriesByKey],
|
||||
);
|
||||
const axisForKey = useCallback(
|
||||
(key: string) =>
|
||||
seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0,
|
||||
[hasFlowSeries, seriesByKey],
|
||||
);
|
||||
|
||||
const handleFetch = useCallback(
|
||||
async (reason: string) => {
|
||||
if (!hasDevices) {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
setLoadingState("idle");
|
||||
setError(null);
|
||||
return;
|
||||
@@ -491,7 +352,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
});
|
||||
setTimeSeries(result);
|
||||
setTimeSeries(result.points);
|
||||
setHistorySeries(result.series);
|
||||
setLoadingState("success");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "未知错误");
|
||||
@@ -583,6 +445,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
handleFetch("device-change");
|
||||
} else {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
}
|
||||
}, [deviceIdsKey, handleFetch, hasDevices]);
|
||||
|
||||
@@ -610,7 +473,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
return deviceIds.flatMap<GridColDef>((id) => [
|
||||
{
|
||||
field: `${id}_raw`,
|
||||
headerName: `${id} (原始)`,
|
||||
headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -623,7 +486,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
{
|
||||
field: `${id}_clean`,
|
||||
headerName: `${id} (清洗)`,
|
||||
headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -636,7 +499,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
{
|
||||
field: `${id}_sim`,
|
||||
headerName: `${id} (模拟)`,
|
||||
headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -652,7 +515,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
// 单一数据源模式:只显示选中的数据源
|
||||
return deviceIds.map<GridColDef>((id) => ({
|
||||
field: `${id}_${selectedSource}`,
|
||||
headerName: id,
|
||||
headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -688,7 +551,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (hasData) {
|
||||
cols.push({
|
||||
field: fieldKey,
|
||||
headerName: `${deviceName} (${name})`,
|
||||
headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -708,7 +571,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
})();
|
||||
|
||||
return [...base, ...dynamic];
|
||||
}, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]);
|
||||
}, [
|
||||
deviceIds,
|
||||
fractionDigits,
|
||||
showCleaning,
|
||||
selectedSource,
|
||||
dataset,
|
||||
unitForKey,
|
||||
]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -766,8 +636,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (selectedSource === "all") {
|
||||
return deviceIds.flatMap((id, index) => [
|
||||
{
|
||||
name: `${id} (原始)`,
|
||||
name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_raw`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -775,8 +646,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
data: dataset.map((item) => item[`${id}_raw`]),
|
||||
},
|
||||
{
|
||||
name: `${id} (清洗)`,
|
||||
name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_clean`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -784,8 +656,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
data: dataset.map((item) => item[`${id}_clean`]),
|
||||
},
|
||||
{
|
||||
name: `${id} (模拟)`,
|
||||
name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_sim`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -795,8 +668,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
]);
|
||||
} else {
|
||||
return deviceIds.map((id, index) => ({
|
||||
name: id,
|
||||
name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_${selectedSource}`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -820,8 +694,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
: suffix === "clean"
|
||||
? "清洗"
|
||||
: "模拟"
|
||||
})`,
|
||||
}) [${unitForKey(key)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(key),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -908,10 +783,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
boundaryGap: false,
|
||||
data: xData,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
scale: true,
|
||||
},
|
||||
yAxis: [
|
||||
...(hasFlowSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasPressureSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: "inside",
|
||||
|
||||
Reference in New Issue
Block a user