import { apiFetch } from "@/lib/apiFetch"; import { fetchNetworkResultUnits } from "@/hooks/useNetworkResultUnits"; import { config } from "@config/config"; import { DEFAULT_NETWORK_RESULT_UNITS, type NetworkResultUnits, toDisplayValue, } from "@/utils/units"; 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; linkIds: ReadonlySet; }; 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: "m"; flow: "m³/h"; }; timestamp: string; nodes: Record; links: Record; }; 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; measurement_unit?: string; }; type RawPressureDevice = Omit & { node_id?: string | null; }; type Page = { 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 = (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 resolveNearestRowTime = ( queryTime: Date, rows: T[], ) => { const target = queryTime.getTime(); const candidates = Array.from( new Set( rows .map((row) => toTimestamp(row.time)) .filter( (time): time is number => time !== null && Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS, ), ), ); if (candidates.length === 0) return null; return candidates.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(); 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, simulationUnits = DEFAULT_NETWORK_RESULT_UNITS, }: { queryTime: Date; model: SceneModelIndex; nodeRows: RealtimeNodeRow[]; linkRows: RealtimeLinkRow[]; pressureDevices: PressureDevice[]; scadaRows: ScadaReadingRow[]; simulationUnits?: NetworkResultUnits; }): SceneFrame => { const selectedTime = queryTime.toISOString(); const simulationFrameTime = resolveCommonFrameTime( queryTime, nodeRows, linkRows, ); const frameTime = simulationFrameTime ?? resolveNearestRowTime(queryTime, scadaRows); if (frameTime === null) { return { selectedTime, resultTime: null, payload: null, stats: emptyStats(model), warnings: [], }; } const nodes: Record = {}; const links: Record = {}; 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: toDisplayValue( row.pressure, "pressure", simulationUnits.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 = toDisplayValue( row.velocity, "velocity", simulationUnits.velocity, )!; } if (isFiniteNumber(row.flow)) { result.flow = toDisplayValue(row.flow, "flow", simulationUnits.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: toDisplayValue( value, "pressure", device.measurement_unit || "m", )!, 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: "m", flow: "m³/h" }, 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: simulationFrameTime === null ? ["当前时刻无在线模拟结果,已显示 SCADA 监测点。"] : [], }; }; const readJson = async (url: string, signal: AbortSignal): Promise => { 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 response = await readJson | RawPressureDevice[]>( `${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`, signal, ); const items = Array.isArray(response) ? response : response.items; return 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(), measurement_unit: device.measurement_unit?.trim() || "m", })); }; 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, networkOptions] = await Promise.all([ readJson( `${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`, signal, ), readJson( `${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`, signal, ), fetchNetworkResultUnits(ZJB_PROJECT_CODE, signal), ]); let scadaRows: ScadaReadingRow[] = []; const warnings: string[] = []; if (pressureDevices.length > 0) { try { const rows = await readJson( `${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, simulationUnits: networkOptions, }); return { ...frame, warnings: [...frame.warnings, ...warnings] }; };