feat(3d): integrate ZJB scene with project context
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { config } from "@config/config";
|
||||
|
||||
export const ZJB_PROJECT_CODE = "zjb";
|
||||
export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23";
|
||||
export const SCENE_FRAME_TOLERANCE_MS = 2_000;
|
||||
|
||||
export const supportsThreeDimensionalScene = (projectCode?: string | null) =>
|
||||
projectCode?.trim().toLowerCase() === ZJB_PROJECT_CODE;
|
||||
|
||||
export type SceneModelIndex = {
|
||||
modelId: string;
|
||||
nodeIds: ReadonlySet<string>;
|
||||
linkIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type SceneNodeResult = {
|
||||
pressure: number;
|
||||
source: "simulation" | "scada";
|
||||
deviceId?: string;
|
||||
simulationPressure?: number;
|
||||
};
|
||||
|
||||
export type SceneLinkResult = {
|
||||
velocity?: number;
|
||||
flow?: number;
|
||||
status?: "open" | "closed" | "active";
|
||||
direction?: -1 | 0 | 1;
|
||||
};
|
||||
|
||||
export type SceneResultsPayload = {
|
||||
modelId: string;
|
||||
units: {
|
||||
velocity: "m/s";
|
||||
pressure: "mH2O";
|
||||
flow: "L/s";
|
||||
};
|
||||
timestamp: string;
|
||||
nodes: Record<string, SceneNodeResult>;
|
||||
links: Record<string, SceneLinkResult>;
|
||||
};
|
||||
|
||||
export type SceneFrameStats = {
|
||||
simulationNodes: number;
|
||||
simulationLinks: number;
|
||||
scadaOverrides: number;
|
||||
missingNodes: number;
|
||||
missingLinks: number;
|
||||
ignoredElements: number;
|
||||
};
|
||||
|
||||
export type SceneFrame = {
|
||||
selectedTime: string;
|
||||
resultTime: string | null;
|
||||
payload: SceneResultsPayload | null;
|
||||
stats: SceneFrameStats;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type PressureDevice = {
|
||||
device_id: string;
|
||||
device_type: string;
|
||||
node_id: string;
|
||||
};
|
||||
|
||||
type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
|
||||
node_id?: string | null;
|
||||
};
|
||||
|
||||
type Page<T> = {
|
||||
items: T[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type RealtimeNodeRow = {
|
||||
time: string;
|
||||
node_id: string;
|
||||
pressure: number | null;
|
||||
};
|
||||
|
||||
type RealtimeLinkRow = {
|
||||
time: string;
|
||||
link_id: string;
|
||||
velocity: number | null;
|
||||
flow: number | null;
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
type ScadaReadingRow = {
|
||||
time: string;
|
||||
device_id: string;
|
||||
monitored_value: number | null;
|
||||
cleaned_value: number | null;
|
||||
};
|
||||
|
||||
const emptyStats = (model: SceneModelIndex): SceneFrameStats => ({
|
||||
simulationNodes: 0,
|
||||
simulationLinks: 0,
|
||||
scadaOverrides: 0,
|
||||
missingNodes: model.nodeIds.size,
|
||||
missingLinks: model.linkIds.size,
|
||||
ignoredElements: 0,
|
||||
});
|
||||
|
||||
const toTimestamp = (value: string) => {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
};
|
||||
|
||||
const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value);
|
||||
|
||||
const frameWindow = (queryTime: Date) => ({
|
||||
startTime: new Date(queryTime.getTime() - SCENE_FRAME_TOLERANCE_MS),
|
||||
endTime: new Date(queryTime.getTime() + SCENE_FRAME_TOLERANCE_MS),
|
||||
});
|
||||
|
||||
const rowsAtTime = <T extends { time: string }>(rows: T[], timestamp: number) =>
|
||||
rows.filter((row) => toTimestamp(row.time) === timestamp);
|
||||
|
||||
const resolveCommonFrameTime = (
|
||||
queryTime: Date,
|
||||
nodeRows: RealtimeNodeRow[],
|
||||
linkRows: RealtimeLinkRow[],
|
||||
) => {
|
||||
const target = queryTime.getTime();
|
||||
const nodeTimes = new Set(
|
||||
nodeRows
|
||||
.map((row) => toTimestamp(row.time))
|
||||
.filter((time): time is number => time !== null),
|
||||
);
|
||||
const commonTimes = Array.from(
|
||||
new Set(
|
||||
linkRows
|
||||
.map((row) => toTimestamp(row.time))
|
||||
.filter(
|
||||
(time): time is number =>
|
||||
time !== null &&
|
||||
nodeTimes.has(time) &&
|
||||
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (commonTimes.length === 0) return null;
|
||||
return commonTimes.sort(
|
||||
(left, right) => Math.abs(left - target) - Math.abs(right - target),
|
||||
)[0];
|
||||
};
|
||||
|
||||
const normalizeLinkStatus = (
|
||||
value: number | null,
|
||||
): SceneLinkResult["status"] => {
|
||||
if (!isFiniteNumber(value)) return undefined;
|
||||
if (value <= 0) return "closed";
|
||||
if (value === 1) return "open";
|
||||
return "active";
|
||||
};
|
||||
|
||||
const nearestScadaReadings = (
|
||||
rows: ScadaReadingRow[],
|
||||
resultTimestamp: number,
|
||||
) => {
|
||||
const byDevice = new Map<string, ScadaReadingRow>();
|
||||
rows.forEach((row) => {
|
||||
const timestamp = toTimestamp(row.time);
|
||||
if (
|
||||
timestamp === null ||
|
||||
Math.abs(timestamp - resultTimestamp) > SCENE_FRAME_TOLERANCE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const existing = byDevice.get(row.device_id);
|
||||
const existingTimestamp = existing ? toTimestamp(existing.time) : null;
|
||||
if (
|
||||
existingTimestamp === null ||
|
||||
Math.abs(timestamp - resultTimestamp) <
|
||||
Math.abs(existingTimestamp - resultTimestamp)
|
||||
) {
|
||||
byDevice.set(row.device_id, row);
|
||||
}
|
||||
});
|
||||
return byDevice;
|
||||
};
|
||||
|
||||
export const buildSceneFrame = ({
|
||||
queryTime,
|
||||
model,
|
||||
nodeRows,
|
||||
linkRows,
|
||||
pressureDevices,
|
||||
scadaRows,
|
||||
}: {
|
||||
queryTime: Date;
|
||||
model: SceneModelIndex;
|
||||
nodeRows: RealtimeNodeRow[];
|
||||
linkRows: RealtimeLinkRow[];
|
||||
pressureDevices: PressureDevice[];
|
||||
scadaRows: ScadaReadingRow[];
|
||||
}): SceneFrame => {
|
||||
const selectedTime = queryTime.toISOString();
|
||||
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
|
||||
if (frameTime === null) {
|
||||
return {
|
||||
selectedTime,
|
||||
resultTime: null,
|
||||
payload: null,
|
||||
stats: emptyStats(model),
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const nodes: Record<string, SceneNodeResult> = {};
|
||||
const links: Record<string, SceneLinkResult> = {};
|
||||
let ignoredElements = 0;
|
||||
|
||||
rowsAtTime(nodeRows, frameTime).forEach((row) => {
|
||||
const id = String(row.node_id);
|
||||
if (!model.nodeIds.has(id)) {
|
||||
ignoredElements += 1;
|
||||
return;
|
||||
}
|
||||
if (isFiniteNumber(row.pressure)) {
|
||||
nodes[id] = { pressure: row.pressure, source: "simulation" };
|
||||
}
|
||||
});
|
||||
|
||||
rowsAtTime(linkRows, frameTime).forEach((row) => {
|
||||
const id = String(row.link_id);
|
||||
if (!model.linkIds.has(id)) {
|
||||
ignoredElements += 1;
|
||||
return;
|
||||
}
|
||||
const result: SceneLinkResult = {};
|
||||
if (isFiniteNumber(row.velocity)) result.velocity = row.velocity;
|
||||
if (isFiniteNumber(row.flow)) {
|
||||
result.flow = row.flow;
|
||||
result.direction = Math.sign(row.flow) as -1 | 0 | 1;
|
||||
}
|
||||
const status = normalizeLinkStatus(row.status);
|
||||
if (status) result.status = status;
|
||||
links[id] = result;
|
||||
});
|
||||
|
||||
const simulationNodeCount = Object.keys(nodes).length;
|
||||
const readingsByDevice = nearestScadaReadings(scadaRows, frameTime);
|
||||
let scadaOverrides = 0;
|
||||
pressureDevices.forEach((device) => {
|
||||
if (!model.nodeIds.has(device.node_id)) {
|
||||
ignoredElements += 1;
|
||||
return;
|
||||
}
|
||||
const reading = readingsByDevice.get(device.device_id);
|
||||
if (!reading) return;
|
||||
const value = isFiniteNumber(reading.cleaned_value)
|
||||
? reading.cleaned_value
|
||||
: reading.monitored_value;
|
||||
if (!isFiniteNumber(value)) return;
|
||||
const simulationPressure = nodes[device.node_id]?.pressure;
|
||||
nodes[device.node_id] = {
|
||||
pressure: value,
|
||||
source: "scada",
|
||||
deviceId: device.device_id,
|
||||
...(simulationPressure === undefined ? {} : { simulationPressure }),
|
||||
};
|
||||
scadaOverrides += 1;
|
||||
});
|
||||
|
||||
const resultTime = new Date(frameTime).toISOString();
|
||||
return {
|
||||
selectedTime,
|
||||
resultTime,
|
||||
payload: {
|
||||
modelId: model.modelId,
|
||||
units: { velocity: "m/s", pressure: "mH2O", flow: "L/s" },
|
||||
timestamp: resultTime,
|
||||
nodes,
|
||||
links,
|
||||
},
|
||||
stats: {
|
||||
simulationNodes: simulationNodeCount,
|
||||
simulationLinks: Object.keys(links).length,
|
||||
scadaOverrides,
|
||||
missingNodes: Math.max(0, model.nodeIds.size - Object.keys(nodes).length),
|
||||
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
|
||||
ignoredElements,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
const readJson = async <T>(url: string, signal: AbortSignal): Promise<T> => {
|
||||
const response = await apiFetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(detail || `请求失败:HTTP ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const fetchPressureDevices = async (signal: AbortSignal) => {
|
||||
const page = await readJson<Page<RawPressureDevice>>(
|
||||
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
|
||||
signal,
|
||||
);
|
||||
return page.items
|
||||
.filter(
|
||||
(device): device is PressureDevice =>
|
||||
device.device_type?.trim().toLowerCase() === "pressure" &&
|
||||
typeof device.node_id === "string" &&
|
||||
device.node_id.trim().length > 0,
|
||||
)
|
||||
.map((device) => ({ ...device, node_id: device.node_id.trim() }));
|
||||
};
|
||||
|
||||
export const fetchSceneFrame = async ({
|
||||
queryTime,
|
||||
model,
|
||||
pressureDevices,
|
||||
signal,
|
||||
}: {
|
||||
queryTime: Date;
|
||||
model: SceneModelIndex;
|
||||
pressureDevices: PressureDevice[];
|
||||
signal: AbortSignal;
|
||||
}) => {
|
||||
const { startTime, endTime } = frameWindow(queryTime);
|
||||
const range = new URLSearchParams({
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
});
|
||||
const scadaRange = new URLSearchParams(range);
|
||||
scadaRange.set(
|
||||
"device_ids",
|
||||
pressureDevices.map((device) => device.device_id).join(","),
|
||||
);
|
||||
|
||||
const [nodeRows, linkRows] = await Promise.all([
|
||||
readJson<RealtimeNodeRow[]>(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
|
||||
signal,
|
||||
),
|
||||
readJson<RealtimeLinkRow[]>(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
|
||||
signal,
|
||||
),
|
||||
]);
|
||||
|
||||
let scadaRows: ScadaReadingRow[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (pressureDevices.length > 0) {
|
||||
try {
|
||||
const rows = await readJson<ScadaReadingRow[]>(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/scada-readings?${scadaRange}`,
|
||||
signal,
|
||||
);
|
||||
scadaRows = Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
warnings.push("SCADA 数据暂不可用,当前仅显示模拟结果。");
|
||||
}
|
||||
}
|
||||
|
||||
const frame = buildSceneFrame({
|
||||
queryTime,
|
||||
model,
|
||||
nodeRows: Array.isArray(nodeRows) ? nodeRows : [],
|
||||
linkRows: Array.isArray(linkRows) ? linkRows : [],
|
||||
pressureDevices,
|
||||
scadaRows,
|
||||
});
|
||||
return { ...frame, warnings };
|
||||
};
|
||||
Reference in New Issue
Block a user