import { wingPool } from '../db/wingDb.js'; import { getBacktrack } from './predictionService.js'; const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'; const VESSEL_TRACK_API_URL = process.env.VESSEL_TRACK_API_URL ?? 'http://localhost:9090'; // 유종 코드(DB) → OpenDrift 유종 코드 매핑 const OIL_TYPE_MAP: Record = { 'BUNKER_C': 'GENERIC BUNKER C', 'DIESEL': 'GENERIC DIESEL', 'CRUDE_OIL': 'WEST TEXAS INTERMEDIATE (WTI)', 'HEAVY_FUEL_OIL': 'GENERIC HEAVY FUEL OIL', 'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)', 'GASOLINE': 'GENERIC GASOLINE', }; const POLL_INTERVAL_MS = 3000; const POLL_TIMEOUT_MS = 30 * 60 * 1000; // AIS 선박유형 코드 → 위험도 점수 매핑 // AIS VESSEL_TP: 80-89=유조선류, 70-79=카고, 30-39=어선 const VESSEL_TYPE_SCORES: Array<[number, number, number]> = [ [80, 89, 1.0], // 유조선 계열 [70, 79, 0.5], // 화물선 계열 [30, 39, 0.3], // 어선 ]; const RANK_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6', '#f59e0b', '#6366f1']; interface PythonParticle { lat: number; lon: number } interface PythonTimeStep { particles: PythonParticle[]; center_lat?: number; center_lon?: number; remaining_volume_m3: number; weathered_volume_m3: number; pollution_area_km2: number; beached_volume_m3: number; pollution_coast_length_m: number; } // ============================================================ // 선박 항적 API 타입 // ============================================================ interface VesselTrackApiRequest { startTime: string; endTime: string; mode: 'SEQUENTIAL'; polygons: Array<{ id: string; name: string; coordinates: [number, number][]; }>; } interface ChnPrmShipInfo { imo: number; name: string; callsign: string; vesselType: string; lat: number; lon: number; sog: number; cog: number; heading: number; length: number; width: number; draught: number; destination: string; status: string; messageTimestamp: string; } interface VesselTrack { vesselId: string; nationalCode: string; geometry: [number, number][]; // [lon, lat][] speeds: number[]; // knots totalDistance: number; avgSpeed: number; maxSpeed: number; pointCount: number; shipName: string; shipType: string; // vessel type code string (e.g. "74") shipKindCode: string; chnPrmShipInfo: ChnPrmShipInfo | null; timestamps: string[]; // Unix timestamp strings } interface HitDetail { polygonId: string; polygonName: string; entryTimestamp: number; exitTimestamp: number; hitPointCount: number; visitIndex: number; } interface VesselTrackApiResponse { tracks: VesselTrack[]; hitDetails: Record; summary: { totalVessels: number; totalPoints: number; mode: string; polygonIds: string[]; processingTimeMs: number; cachedDates: string[]; totalCachedVessels: number; }; } // haversine 거리 계산 (NM) function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 3440.065; // NM const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // anlysRange 파싱: '12', '±12시간', '12h' 등 → 숫자 function parseAnalysisHours(anlysRange: string): number { const m = anlysRange.match(/(\d+)/); return m ? parseInt(m[1], 10) : 12; } // 시간 포맷: Date → 'HH:MM' 형식 (KST) function toTimeLabel(d: Date): string { const kst = new Date(d.getTime() + 9 * 3600000); return `${String(kst.getUTCHours()).padStart(2, '0')}:${String(kst.getUTCMinutes()).padStart(2, '0')}`; } // Python 결과 폴링 (DONE까지 대기) async function pollUntilDone(jobId: string): Promise { const deadline = Date.now() + POLL_TIMEOUT_MS; while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); try { const res = await fetch(`${PYTHON_API_URL}/status/${jobId}`, { signal: AbortSignal.timeout(5000), }); if (!res.ok) continue; const data = await res.json() as { status: string; result?: PythonTimeStep[]; error?: string }; if (data.status === 'DONE' && data.result) return data.result; if (data.status === 'ERROR') throw new Error(data.error ?? 'Python 분석 오류'); } catch (e) { if (e instanceof Error && e.message !== 'fetch failed') throw e; } } throw new Error('역추적 분석 시간 초과 (30분)'); } // 파티클 스텝별 탐색 영역 계산 function computeParticleSteps( rawResult: PythonTimeStep[], spillTime: Date, anlysHours: number, ): Array<{ stepIdx: number; atTime: Date; centroid: { lat: number; lon: number }; radiusNm: number }> { const totalSteps = rawResult.length; const msPerStep = anlysHours * 3600000 / Math.max(totalSteps - 1, 1); return rawResult.map((step, idx) => { const atTime = new Date(spillTime.getTime() - idx * msPerStep); const particles = step.particles.filter(p => p.lat != null && p.lon != null); let centroid: { lat: number; lon: number }; let radiusNm: number; if (particles.length === 0) { centroid = { lat: step.center_lat ?? 0, lon: step.center_lon ?? 0 }; radiusNm = 5; } else { centroid = { lat: particles.reduce((s, p) => s + p.lat, 0) / particles.length, lon: particles.reduce((s, p) => s + p.lon, 0) / particles.length, }; const maxDist = Math.max(...particles.map(p => haversineNm(centroid.lat, centroid.lon, p.lat, p.lon))); radiusNm = Math.max(maxDist * 1.2, 2); // 최소 2 NM } return { stepIdx: idx, atTime, centroid, radiusNm }; }); } // 선박유형 점수 function getVesselTypeScore(vesselTp: number | null): number { if (vesselTp == null) return 0.3; for (const [min, max, score] of VESSEL_TYPE_SCORES) { if (vesselTp >= min && vesselTp <= max) return score; } return 0.2; } // 급감속 감지: 속도 배열에서 50% 이상 감소 여부 → 0~1 function detectSpeedDrop(speeds: number[]): number { const valid = speeds.filter(s => s > 0); if (valid.length < 2) return 0; let maxDrop = 0; for (let i = 1; i < valid.length; i++) { const drop = (valid[i - 1] - valid[i]) / valid[i - 1]; if (drop > maxDrop) maxDrop = drop; } return maxDrop > 0.5 ? Math.min(1, maxDrop) : 0; } // AIS 단절 감지: 타임스탬프 배열에서 평균 간격의 3배 이상 gap 존재 여부 function detectAisGapFromTimestamps(timestamps: string[]): boolean { if (timestamps.length < 3) return false; const sorted = timestamps.map(Number).sort((a, b) => a - b); const avgInterval = (sorted[sorted.length - 1] - sorted[0]) / (sorted.length - 1); for (let i = 1; i < sorted.length; i++) { if (sorted[i] - sorted[i - 1] > avgInterval * 3) return true; } return false; } function vesselTypeToLabel(tp: number | null): string { if (tp == null) return '미분류'; if (tp >= 80 && tp <= 89) return '유조선'; if (tp >= 70 && tp <= 79) return '화물선'; if (tp >= 30 && tp <= 39) return '어선'; if (tp >= 60 && tp <= 69) return '여객선'; if (tp >= 90 && tp <= 99) return '특수선'; return `선박(${tp})`; } interface RankedVessel { rank: number; name: string; imo: string; type: string; flag: string; flagCountry: string; probability: number; closestTime: string; closestDistance: number; speedChange: string; aisStatus: string; description: string; color: string; mmsi: string; _rawScore: number; _track: VesselTrack; _minDistIdx: number; } // 탐색 폴리곤 빌드 (사고위치 + 탐색반경 → 바운딩 박스) function buildSearchPolygon( lat: number, lon: number, radiusNm: number, ): { id: string; name: string; coordinates: [number, number][] } { const latDelta = radiusNm / 60; const lonDelta = radiusNm / (60 * Math.cos(lat * Math.PI / 180)); return { id: 'zone_0', name: '역추적 탐색구역', coordinates: [ [lon - lonDelta, lat - latDelta], [lon + lonDelta, lat - latDelta], [lon + lonDelta, lat + latDelta], [lon - lonDelta, lat + latDelta], [lon - lonDelta, lat - latDelta], ], }; } // 선박 항적 API 호출 async function fetchVesselTracks( spillLat: number, spillLon: number, srchRadiusNm: number, startTime: Date, endTime: Date, ): Promise { const polygon = buildSearchPolygon(spillLat, spillLon, srchRadiusNm); const toApiDatetime = (d: Date) => d.toISOString().substring(0, 19); const body: VesselTrackApiRequest = { startTime: toApiDatetime(startTime), endTime: toApiDatetime(endTime), mode: 'SEQUENTIAL', polygons: [polygon], }; console.log(body); const res = await fetch(`${VESSEL_TRACK_API_URL}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`선박 항적 API 오류: ${res.status}`); return res.json() as Promise; } // 스코어링 + 순위 (선박 항적 API 응답 기반) function scoreAndRankVesselsFromApi( tracks: VesselTrack[], hitDetails: Record, spillLat: number, spillLon: number, srchRadiusNm: number, anlysHours: number, ): RankedVessel[] { const anlysWindowSec = anlysHours * 3600; const scored = tracks.map(track => { // 1. spatialScore (40%): 사고 지점과의 최근접 거리 let minDist = Infinity; let minDistIdx = 0; track.geometry.forEach(([lon, lat], idx) => { const d = haversineNm(lat, lon, spillLat, spillLon); if (d < minDist) { minDist = d; minDistIdx = idx; } }); const spatialScore = Math.max(0, 1 - minDist / srchRadiusNm); // 2. temporalScore (25%): 탐색구역 체류시간 / 분석 윈도우 const hits = hitDetails[track.vesselId] ?? []; const totalTimeInZoneSec = hits.reduce((sum, h) => sum + (h.exitTimestamp - h.entryTimestamp), 0); const temporalScore = Math.min(1, totalTimeInZoneSec / anlysWindowSec); // 3. behaviorScore (20%): 급감속 + AIS 단절 const speedDrop = detectSpeedDrop(track.speeds); const aisGap = detectAisGapFromTimestamps(track.timestamps); const behaviorScore = Math.min(1, speedDrop * 0.6 + (aisGap ? 0.4 : 0)); // 4. vesselTypeScore (15%): 선박 유형별 위험도 const vesselTpRaw = parseInt(track.shipType ?? '', 10); const vesselTp = isNaN(vesselTpRaw) ? null : vesselTpRaw; const vesselTypeScore = getVesselTypeScore(vesselTp); const rawScore = 0.40 * spatialScore + 0.25 * temporalScore + 0.20 * behaviorScore + 0.15 * vesselTypeScore; const speedChangeLabel = speedDrop > 0.5 ? '급감속' : speedDrop > 0.2 ? '감속' : '정상'; const aisStatusLabel = aisGap ? 'AIS단절' : '정상'; const closestTs = track.timestamps[minDistIdx]; const closestDate = closestTs ? new Date(Number(closestTs) * 1000) : new Date(); const descParts: string[] = []; if (speedDrop > 0.5) descParts.push(`${toTimeLabel(closestDate)} 급감속 감지`); if (aisGap) descParts.push('AIS 신호 단절 구간 존재'); if (minDist < 1) descParts.push(`최근접 ${minDist.toFixed(2)} NM 이내 통과`); const imo = track.chnPrmShipInfo?.imo?.toString() ?? ''; const vesselName = track.shipName || track.chnPrmShipInfo?.name || `MMSI:${track.vesselId}`; return { mmsi: track.vesselId, imo, name: vesselName, type: vesselTypeToLabel(vesselTp), flag: track.nationalCode ?? '', flagCountry: track.nationalCode ?? '', closestTime: toTimeLabel(closestDate), closestDistance: Math.round(minDist * 100) / 100, speedChange: speedChangeLabel, aisStatus: aisStatusLabel, description: descParts.join(' · '), probability: 0, rank: 0, color: '', _rawScore: rawScore, _track: track, _minDistIdx: minDistIdx, }; }); scored.sort((a, b) => b._rawScore - a._rawScore); const top = scored.slice(0, 10); const maxScore = top[0]?._rawScore ?? 1; return top.map((v, i) => ({ ...v, rank: i + 1, probability: Math.round((v._rawScore / maxScore) * 95 * 10) / 10, color: RANK_COLORS[i] ?? '#888888', })); } interface ReplayShip { vesselName: string; color: string; path: Array<{ lat: number; lon: number }>; speedLabels: string[]; } // 리플레이용 선박 경로 빌드 (상위 5척, API geometry 직접 사용) function buildReplayShipsFromApi(ranked: RankedVessel[]): ReplayShip[] { return ranked.slice(0, 5).map(v => ({ vesselName: v.name, color: v.color, path: v._track.geometry.map(([lon, lat]) => ({ lat, lon })), speedLabels: v._track.speeds.map(s => `${s.toFixed(1)} kts`), })); } interface CollisionEvent { position: { lat: number; lon: number }; timeLabel: string; progressPercent: number; } // 최고 확률 선박의 최근접 지점 이벤트 function findCollisionEventFromApi( ranked: RankedVessel[], startTime: Date, endTime: Date, ): CollisionEvent | null { const top = ranked[0]; if (!top) return null; const idx = top._minDistIdx; const ts = top._track.timestamps[idx]; const pointDate = ts ? new Date(Number(ts) * 1000) : new Date(); const totalMs = endTime.getTime() - startTime.getTime(); const pointMs = pointDate.getTime() - startTime.getTime(); const progressPercent = totalMs > 0 ? Math.max(0, Math.min(100, Math.round((pointMs / totalMs) * 100))) : 0; const point = top._track.geometry[idx]; const [lon, lat] = point ?? [0, 0]; return { position: { lat, lon }, timeLabel: toTimeLabel(pointDate) + ' 최근접', progressPercent, }; } // ============================================================ // 메인 분석 함수 (외부에서 호출) // ============================================================ export async function runBacktrackAnalysis(backtrackSn: number): Promise { await wingPool.query( `UPDATE wing.BACKTRACK SET EXEC_STTS_CD='RUNNING' WHERE BACKTRACK_SN=$1`, [backtrackSn], ); try { const bt = await getBacktrack(backtrackSn); if (!bt || bt.lat == null || bt.lon == null || !bt.estSpilDtm) { throw new Error('역추적 레코드 정보 불충분 (lat/lon/estSpilDtm 필요)'); } const anlysHours = parseAnalysisHours(bt.anlysRange ?? '12'); const spillTime = new Date(bt.estSpilDtm); const analysisStart = new Date(spillTime.getTime() - anlysHours * 3600000); // SPIL_DATA에서 유출량 및 유종 조회 let matVol: number | null = null; let matTy: string | undefined; try { const { rows: spillRows } = await wingPool.query( `SELECT SPIL_QTY, OIL_TP_CD FROM wing.SPIL_DATA WHERE ACDNT_SN=$1 ORDER BY SPIL_DATA_SN ASC LIMIT 1`, [bt.acdntSn], ); if (spillRows.length > 0) { const row = spillRows[0] as Record; matVol = row['spil_qty'] != null ? Number(row['spil_qty']) : null; const oilTpCd = row['oil_tp_cd'] as string | null; matTy = oilTpCd ? (OIL_TYPE_MAP[oilTpCd] ?? oilTpCd) : undefined; } } catch (spillErr) { console.warn('[backtrack] SPIL_DATA 조회 실패, matVol 없이 진행:', spillErr); } // Python 역방향 시뮬레이션 실행 (파티클 시각화용) let rawResult: PythonTimeStep[]; try { const pythonRes = await fetch(`${PYTHON_API_URL}/run-model-backward`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat: bt.lat, lon: bt.lon, startTime: spillTime.toISOString(), runTime: anlysHours, matVol: matVol ?? 1, matTy, name: `BACKTRACK_${backtrackSn}`, }), signal: AbortSignal.timeout(30000), }); if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`); const { job_id } = await pythonRes.json() as { job_id: string }; rawResult = await pollUntilDone(job_id); } catch (pyErr) { // Python 미연동 시 폴백: 빈 파티클 스텝 생성 console.warn('[backtrack] Python 미연동 — 폴백 모드 사용:', pyErr); rawResult = Array.from({ length: anlysHours + 1 }, () => ({ particles: [], remaining_volume_m3: 0, weathered_volume_m3: 0, pollution_area_km2: 0, beached_volume_m3: 0, pollution_coast_length_m: 0, })); } const steps = computeParticleSteps(rawResult, spillTime, anlysHours); if (rawResult.every(s => s.particles.length === 0)) { steps.forEach((s, i) => { s.centroid = { lat: bt.lat!, lon: bt.lon! }; s.radiusNm = (bt.srchRadiusNm ?? 10) + i * 2; }); } // 선박 항적 API 호출 const srchRadius = bt.srchRadiusNm ?? 10; const apiResponse = await fetchVesselTracks( bt.lat, bt.lon, srchRadius, analysisStart, spillTime, ); const totalVessels = apiResponse.summary.totalVessels; const ranked = scoreAndRankVesselsFromApi( apiResponse.tracks, apiResponse.hitDetails, bt.lat, bt.lon, srchRadius, anlysHours, ); const replayShips = buildReplayShipsFromApi(ranked); const collisionEvent = findCollisionEventFromApi(ranked, analysisStart, spillTime); const timeRange = { start: analysisStart.toISOString(), end: spillTime.toISOString(), }; // vessels에서 내부 필드 제거 const vessels = ranked.map(({ _rawScore: _r, _track: _t, _minDistIdx: _m, ...v }) => v); const rsltData = { vessels, replayShips, collisionEvent, timeRange }; await wingPool.query( `UPDATE wing.BACKTRACK SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1, TOTAL_VESSELS=$2 WHERE BACKTRACK_SN=$3`, [JSON.stringify(rsltData), totalVessels, backtrackSn], ); console.info(`[backtrack] 분석 완료 SN=${backtrackSn}, 후보선박=${ranked.length}/${totalVessels}`); } catch (err) { console.error('[backtrack] 분석 실패:', err); const errMsg = err instanceof Error ? err.message : '알 수 없는 오류'; await wingPool.query( `UPDATE wing.BACKTRACK SET EXEC_STTS_CD='FAILED', RSLT_DATA=$1 WHERE BACKTRACK_SN=$2`, [JSON.stringify({ error: errMsg }), backtrackSn], ); } }