- 백엔드: backtrackAnalysisService 신규 개발 * AIS 기반 선박 항적 API 연동 및 공간 조회 * 공간(40%)/시간(25%)/행동(20%)/선박유형(15%) 가중치 위험도 점수 산정 * 상위 5척 리플레이 데이터 및 충돌 이벤트 생성 * Python 서버 미연동 시 폴백 메커니즘 제공 - 백엔드: 역추적 생성 시 동기 분석 → BacktrackResult 즉시 반환 - 프론트엔드: 모달에서 유출 시각/분석 범위/탐색 반경 직접 입력 가능 - 프론트엔드: 리플레이 바에 실제 분석 시간 범위 동적 표시 - DB: AIS_TRACK 테이블 신규 생성 (선박 항적 이력 + GIS 인덱스)
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
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<string, string> = {
|
|
'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<string, HitDetail[]>;
|
|
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<PythonTimeStep[]> {
|
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
await new Promise<void>(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<VesselTrackApiResponse> {
|
|
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<VesselTrackApiResponse>;
|
|
}
|
|
|
|
// 스코어링 + 순위 (선박 항적 API 응답 기반)
|
|
function scoreAndRankVesselsFromApi(
|
|
tracks: VesselTrack[],
|
|
hitDetails: Record<string, HitDetail[]>,
|
|
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<void> {
|
|
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<string, unknown>;
|
|
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],
|
|
);
|
|
}
|
|
}
|