diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 0769ea7..06fdc9f 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -442,8 +442,14 @@ interface TrajectoryTimeStep { hydr_grid?: TrajectoryHydrGrid; } +// ALGO_CD → 프론트엔드 모델명 매핑 +const ALGO_CD_TO_MODEL: Record = { + 'OPENDRIFT': 'OpenDrift', + 'POSEIDON': 'POSEIDON', +}; + interface TrajectoryResult { - trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>; + trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>; summary: { remainingVolume: number; weatheredVolume: number; @@ -451,12 +457,12 @@ interface TrajectoryResult { beachedVolume: number; pollutionCoastLength: number; }; - centerPoints: Array<{ lat: number; lon: number; time: number }>; + centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>; windData: TrajectoryWindPoint[][]; hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]; } -function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult { +function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): TrajectoryResult { const trajectory = rawResult.flatMap((step, stepIdx) => step.particles.map((p, i) => ({ lat: p.lat, @@ -464,6 +470,7 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR time: stepIdx, particle: i, stranded: p.stranded, + model, })) ); const lastStep = rawResult[rawResult.length - 1]; @@ -477,10 +484,10 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR const centerPoints = rawResult .map((step, stepIdx) => step.center_lat != null && step.center_lon != null - ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx } + ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model } : null ) - .filter((p): p is { lat: number; lon: number; time: number } => p !== null); + .filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null); const windData = rawResult.map((step) => step.wind_data ?? []); const hydrData = rawResult.map((step) => step.hydr_data && step.hydr_grid @@ -491,14 +498,52 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryR } export async function getAnalysisTrajectory(acdntSn: number): Promise { + // 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회 const sql = ` - SELECT RSLT_DATA FROM wing.PRED_EXEC - WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED' - ORDER BY CMPL_DTM DESC LIMIT 1 + SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + ORDER BY CMPL_DTM DESC `; const { rows } = await wingPool.query(sql, [acdntSn]); - if (rows.length === 0 || !rows[0].rslt_data) return null; - return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]); + if (rows.length === 0) return null; + + // 모든 모델의 파티클을 하나의 배열로 병합 + let mergedTrajectory: TrajectoryResult['trajectory'] = []; + let allCenterPoints: TrajectoryResult['centerPoints'] = []; + + // summary/windData/hydrData: 가장 최근 완료된 OpenDrift 기준, 없으면 POSEIDON 기준 + let baseResult: TrajectoryResult | null = null; + + // OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근) + const opendriftRow = (rows as Array>).find((r) => r['algo_cd'] === 'OPENDRIFT'); + const poseidonRow = (rows as Array>).find((r) => r['algo_cd'] === 'POSEIDON'); + const baseRow = opendriftRow ?? poseidonRow ?? null; + + for (const row of rows as Array>) { + if (!row['rslt_data']) continue; + const algoCd = String(row['algo_cd'] ?? ''); + const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd; + const parsed = transformTrajectoryResult(row['rslt_data'] as TrajectoryTimeStep[], modelName); + mergedTrajectory = mergedTrajectory.concat(parsed.trajectory); + allCenterPoints = allCenterPoints.concat(parsed.centerPoints); + + // 기준 행의 결과를 baseResult로 사용 + if (row === baseRow) { + baseResult = parsed; + } + } + + if (!baseResult) return null; + + return { + trajectory: mergedTrajectory, + summary: baseResult.summary, + centerPoints: allCenterPoints, + windData: baseResult.windData, + hydrData: baseResult.hydrData, + }; } export async function listBoomLines(acdntSn: number): Promise { diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index e44bdea..3a5363a 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -11,6 +11,7 @@ import { const router = Router() const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003' +const POSEIDON_API_URL = process.env.POSEIDON_API_URL ?? 'http://localhost:5004' const POLL_INTERVAL_MS = 3000 const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분 @@ -71,20 +72,38 @@ async function rollbackNewRecords( } } +// 모델명 → ALGO_CD 매핑 +const MODEL_ALGO_CD_MAP: Record = { + 'OpenDrift': 'OPENDRIFT', + 'POSEIDON': 'POSEIDON', +} + +// 모델명 → API URL 매핑 +const MODEL_API_URL_MAP: Record = { + 'OpenDrift': PYTHON_API_URL, + 'POSEIDON': POSEIDON_API_URL, +} + // ============================================================ // POST /api/simulation/run -// 확산 시뮬레이션 실행 (OpenDrift) +// 확산 시뮬레이션 실행 (다중 모델 지원: OpenDrift, POSEIDON) // ============================================================ /** - * OpenDrift 확산 시뮬레이션을 실행한다. - * Python FastAPI 서버에 작업을 제출하고 job_id를 받아 - * 백그라운드에서 폴링하며 결과를 DB에 저장한다. - * 프론트엔드는 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다. + * 선택된 모델(OpenDrift, POSEIDON)로 확산 시뮬레이션을 실행한다. + * 각 모델에 대해 PRED_EXEC 레코드를 별도 생성하고 Python API에 병렬 제출한다. + * KOSPS 모델은 PRED_EXEC INSERT(PENDING)만 수행하고 외부 API 연동은 하지 않는다. + * 프론트엔드는 execSns 배열의 각 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다. */ router.post('/run', requireAuth, async (req: Request, res: Response) => { try { const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd, - lat, lon, runTime, matTy, matVol, spillTime, startTime } = req.body + lat, lon, runTime, matTy, matVol, spillTime, startTime, + models: rawModels } = req.body + + // 실행할 모델 목록 (기본값: OpenDrift) + const requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0 + ? (rawModels as string[]) + : ['OpenDrift'] // 1. 필수 파라미터 검증 if (lat === undefined || lon === undefined || runTime === undefined) { @@ -117,21 +136,24 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { } // 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지) - try { - const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lat, lon, startTime }), - signal: AbortSignal.timeout(5000), - }) - if (!checkRes.ok) { - return res.status(409).json({ - error: '해당 좌표의 해양 기상 데이터가 없습니다.', - message: 'NC 파일이 준비되지 않았습니다.', + // OpenDrift 모델이 포함된 경우에만 check-nc 수행 + if (requestedModels.includes('OpenDrift')) { + try { + const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon, startTime }), + signal: AbortSignal.timeout(5000), }) + if (!checkRes.ok) { + return res.status(409).json({ + error: '해당 좌표의 해양 기상 데이터가 없습니다.', + message: 'NC 파일이 준비되지 않았습니다.', + }) + } + } catch { + // Python 서버 미기동 — 5번에서 처리 } - } catch { - // Python 서버 미기동 — 5번에서 처리 } // 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성 @@ -199,83 +221,131 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { } } - // 4. PRED_EXEC INSERT (PENDING) — ACDNT_SN 포함 (NOT NULL FK) - const execNm = `EXPC_${Date.now()}` - let predExecSn: number - try { - const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, 'OPENDRIFT', 'PENDING', $3, NOW()) - RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, execNm] - ) - predExecSn = insertRes.rows[0].pred_exec_sn as number - } catch (dbErr) { - console.error('[simulation] PRED_EXEC INSERT 실패:', dbErr) - return res.status(500).json({ error: '분석 기록 생성 실패' }) - } - // matTy 변환: 한국어 유종 → OpenDrift 유종 코드 // 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응) const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined - // 5. Python /run-model 호출 - let jobId: string - try { - const pythonRes = await fetch(`${PYTHON_API_URL}/run-model`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - lat, - lon, - startTime, - runTime, - matTy: odMatTy, - matVol, - spillTime, - name: execNm, - }), - signal: AbortSignal.timeout(10000), - }) + // 4. 각 모델별 PRED_EXEC INSERT 및 API 호출 (병렬) + // KOSPS: PRED_EXEC PENDING 생성만 하고 배열에서 제외 (외부 API 미연동) + const execNmBase = `EXPC_${Date.now()}` + const execSns: Array<{ model: string; execSn: number }> = [] - if (pythonRes.status === 503) { - const errData = await pythonRes.json() as { error?: string } - await wingPool.query( - `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, - [errData.error || '분석 서버 포화', predExecSn] + // KOSPS 처리: PRED_EXEC INSERT(PENDING)만 수행 + if (requestedModels.includes('KOSPS')) { + try { + const kospsExecNm = `${execNmBase}_KOSPS` + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] ) - await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) - return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' }) + execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) + } catch (dbErr) { + console.error('[simulation] KOSPS PRED_EXEC INSERT 실패:', dbErr) } - - if (!pythonRes.ok) { - throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`) - } - - const pythonData = await pythonRes.json() as { job_id: string } - jobId = pythonData.job_id - } catch { - await wingPool.query( - `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, - [predExecSn] - ) - await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) - return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' }) } - // 6. RUNNING 업데이트 - await wingPool.query( - `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`, - [predExecSn] + // API 연동 모델 필터링 (KOSPS 제외) + const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined) + + // 각 모델에 대해 PRED_EXEC INSERT → /run-model 호출 + await Promise.all( + apiModels.map(async (model) => { + const algoCd = MODEL_ALGO_CD_MAP[model] + const apiUrl = MODEL_API_URL_MAP[model] + const execNm = `${execNmBase}_${algoCd}` + + // PRED_EXEC INSERT (PENDING) + let predExecSn: number + try { + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + ) + predExecSn = insertRes.rows[0].pred_exec_sn as number + } catch (dbErr) { + console.error(`[simulation] ${model} PRED_EXEC INSERT 실패:`, dbErr) + return + } + + execSns.push({ model, execSn: predExecSn }) + + // Python /run-model 호출 + let jobId: string + try { + const pythonRes = await fetch(`${apiUrl}/run-model`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lat, + lon, + startTime, + runTime, + matTy: odMatTy, + matVol, + spillTime, + name: execNm, + }), + signal: AbortSignal.timeout(10000), + }) + + if (pythonRes.status === 503) { + const errData = await pythonRes.json() as { error?: string } + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errData.error || '분석 서버 포화', predExecSn] + ) + await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) + return + } + + if (!pythonRes.ok) { + throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`) + } + + const pythonData = await pythonRes.json() as { job_id: string } + jobId = pythonData.job_id + } catch { + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, + [predExecSn] + ) + // 이 모델의 PRED_EXEC만 롤백 (다른 모델은 계속 진행) + await rollbackNewRecords(predExecSn, null, null) + return + } + + // RUNNING 업데이트 + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`, + [predExecSn] + ) + + // 백그라운드 폴링 시작 + pollAndSaveModel(jobId, predExecSn, apiUrl, algoCd).catch((err: unknown) => + console.error(`[simulation] ${model} pollAndSaveModel 오류:`, err) + ) + }) ) - // 7. 즉시 응답 (프론트엔드는 execSn으로 폴링, acdntSn은 신규 생성 사고 추적용) - res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' }) + // ACDNT/SPIL_DATA가 신규 생성됐으나 모든 모델이 실패한 경우 롤백 + const hasRunning = execSns.some(({ model }) => model !== 'KOSPS') + if (!hasRunning && newlyCreatedAcdntSn !== null) { + await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) + return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' }) + } - // 8. 백그라운드 폴링 시작 - pollAndSave(jobId, predExecSn).catch((err: unknown) => - console.error('[simulation] pollAndSave 오류:', err) - ) + // 즉시 응답 (하위 호환을 위해 execSn도 포함) + res.json({ + success: true, + execSns, + execSn: execSns[0]?.execSn ?? 0, + acdntSn: resolvedAcdntSn, + status: 'RUNNING', + }) } catch { res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' }) } @@ -297,7 +367,7 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) = try { const result = await wingPool.query( - `SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, sd.FCST_HR, + `SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, pe.ALGO_CD, sd.FCST_HR, ( SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR) FROM wing.PRED_EXEC hist @@ -328,7 +398,9 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) = const status = statusMap[dbStatus] ?? dbStatus if (status === 'DONE' && row.rslt_data) { - const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[]) + const algoCd = String(row.algo_cd ?? '') + const modelName = ALGO_CD_TO_MODEL_NAME[algoCd] ?? algoCd + const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[], modelName) return res.json({ status, trajectory, summary, centerPoints, windData, hydrData }) } @@ -356,14 +428,14 @@ router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) = // ============================================================ // 백그라운드 폴링 // ============================================================ -async function pollAndSave(jobId: string, execSn: number): Promise { +async function pollAndSaveModel(jobId: string, execSn: number, apiUrl: string, algoCode: string): Promise { const deadline = Date.now() + POLL_TIMEOUT_MS while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) try { - const pollRes = await fetch(`${PYTHON_API_URL}/status/${jobId}`, { + const pollRes = await fetch(`${apiUrl}/status/${jobId}`, { signal: AbortSignal.timeout(5000), }) if (!pollRes.ok) continue @@ -446,7 +518,13 @@ interface PythonStatusResponse { error?: string } -function transformResult(rawResult: PythonTimeStep[]) { +// ALGO_CD → 프론트엔드 모델명 매핑 +const ALGO_CD_TO_MODEL_NAME: Record = { + 'OPENDRIFT': 'OpenDrift', + 'POSEIDON': 'POSEIDON', +} + +function transformResult(rawResult: PythonTimeStep[], model: string) { const trajectory = rawResult.flatMap((step, stepIdx) => step.particles.map((p, i) => ({ lat: p.lat, @@ -467,10 +545,10 @@ function transformResult(rawResult: PythonTimeStep[]) { const centerPoints = rawResult .map((step, stepIdx) => step.center_lat != null && step.center_lon != null - ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx } + ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model } : null ) - .filter((p): p is { lat: number; lon: number; time: number } => p !== null) + .filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null) const windData = rawResult.map((step) => step.wind_data ?? []) const hydrData = rawResult.map((step) => step.hydr_data && step.hydr_grid diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index dc21fa3..3438a1c 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,11 @@ ## [Unreleased] +## [2026-03-17] + +### 추가 +- 다중 모델 시뮬레이션 지원 (OpenDrift + POSEIDON 병렬 실행 및 결과 병합) + ## [2026-03-16] ### 추가 diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index 642187e..d82ceb0 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -78,7 +78,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { {/* 실시간 상황관리 */} + 📷 + {file.name} + + + {selectedImageIndex === i && imageExifs[i] !== undefined && ( +
+ + + + + + + + + + + +
+ )} ))} @@ -239,8 +359,11 @@ export function OilAreaAnalysis() { {Array.from({ length: MAX_IMAGES }).map((_, i) => (
{ if (previewUrls[i]) setSelectedImageIndex(i); }} > {previewUrls[i] ? ( <> @@ -255,12 +378,12 @@ export function OilAreaAnalysis() {
{selectedFiles[i]?.name}
- {imageGpsInfos[i] === undefined ? ( + {imageExifs[i] === undefined ? (
GPS 읽는 중...
- ) : imageGpsInfos[i]?.lat !== null ? ( + ) : imageExifs[i]?.lat !== null ? (
- {decimalToDMS(imageGpsInfos[i]!.lat!, true)}
- {decimalToDMS(imageGpsInfos[i]!.lon!, false)} + {decimalToDMS(imageExifs[i]!.lat!, true)}
+ {decimalToDMS(imageExifs[i]!.lon!, false)}
) : (
GPS 정보 없음
diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index cc5b1fc..887b39b 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -20,6 +20,9 @@ export function LeftPanel({ isRunningSimulation, selectedModels, onModelsChange, + visibleModels, + onVisibleModelsChange, + hasResults, predictionTime, onPredictionTimeChange, spillType, @@ -82,6 +85,9 @@ export function LeftPanel({ isRunningSimulation={isRunningSimulation} selectedModels={selectedModels} onModelsChange={onModelsChange} + visibleModels={visibleModels} + onVisibleModelsChange={onVisibleModelsChange} + hasResults={hasResults} predictionTime={predictionTime} onPredictionTimeChange={onPredictionTimeChange} spillType={spillType} diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index bacb50c..ec6b944 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -14,7 +14,8 @@ import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' -import { useSimulationStatus } from '../hooks/useSimulationStatus' +import { useMultiSimulationStatus } from '../hooks/useSimulationStatus' +import type { ModelExecRef } from '../hooks/useSimulationStatus' import SimulationLoadingOverlay from './SimulationLoadingOverlay' import SimulationErrorModal from './SimulationErrorModal' import { api } from '@common/services/api' @@ -119,11 +120,13 @@ export function OilSpillView() { const [isSelectingLocation, setIsSelectingLocation] = useState(false) const [oilTrajectory, setOilTrajectory] = useState([]) const [centerPoints, setCenterPoints] = useState([]) - const [windData, setWindData] = useState([]) - const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([]) + const [windDataByModel, setWindDataByModel] = useState>({}) + const [hydrDataByModel, setHydrDataByModel] = useState>({}) + const [windHydrModel, setWindHydrModel] = useState('OpenDrift') const [isRunningSimulation, setIsRunningSimulation] = useState(false) const [simulationError, setSimulationError] = useState(null) const [selectedModels, setSelectedModels] = useState>(new Set(['OpenDrift'])) + const [visibleModels, setVisibleModels] = useState>(new Set(['OpenDrift'])) const [predictionTime, setPredictionTime] = useState(48) const [accidentTime, setAccidentTime] = useState('') const [spillType, setSpillType] = useState('연속') @@ -154,7 +157,7 @@ export function OilSpillView() { // 표시 정보 제어 const [displayControls, setDisplayControls] = useState({ showCurrent: true, - showWind: true, + showWind: false, showBeached: false, showTimeLabel: false, }) @@ -188,9 +191,9 @@ export function OilSpillView() { // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false) - const [currentExecSn, setCurrentExecSn] = useState(null) + const [pendingExecSns, setPendingExecSns] = useState([]) const [simulationSummary, setSimulationSummary] = useState(null) - const { data: simStatus } = useSimulationStatus(currentExecSn) + const { allDone: simAllDone, anyError: simAnyError, results: simResults, errors: simErrors } = useMultiSimulationStatus(pendingExecSns) // 오염분석 상태 const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') @@ -389,34 +392,82 @@ export function OilSpillView() { } }, []) - // 시뮬레이션 폴링 결과 처리 + // 시뮬레이션 폴링 결과 처리 (다중 모델) useEffect(() => { - if (!simStatus) return; - if (simStatus.status === 'DONE' && simStatus.trajectory) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setOilTrajectory(simStatus.trajectory); - setSimulationSummary(simStatus.summary ?? null); - setCenterPoints(simStatus.centerPoints ?? []); - setWindData(simStatus.windData ?? []); - setHydrData(simStatus.hydrData ?? []); - setIsRunningSimulation(false); - setCurrentExecSn(null); - // AI 방어선 자동 생성 - if (incidentCoord) { - const booms = generateAIBoomLines(simStatus.trajectory, incidentCoord, algorithmSettings); - setBoomLines(booms); + if (pendingExecSns.length === 0) return; + + if (simAllDone) { + // 모든 모델의 trajectory 병합 (model 필드 포함) + const merged: OilParticle[] = []; + let latestSummary: SimulationSummary | null = null; + let latestCenterPoints: CenterPoint[] = []; + const newWindDataByModel: Record = {}; + const newHydrDataByModel: Record = {}; + + simResults.forEach((statusData, model) => { + if (statusData.trajectory) { + const withModel = statusData.trajectory.map(p => ({ ...p, model })); + merged.push(...withModel); + } + // summary는 OpenDrift 우선, 없으면 다른 모델 + if (model === 'OpenDrift' || !latestSummary) { + if (statusData.summary) latestSummary = statusData.summary; + } + // windData/hydrData는 모델별로 저장 + if (statusData.windData) newWindDataByModel[model] = statusData.windData; + if (statusData.hydrData) newHydrDataByModel[model] = statusData.hydrData; + // centerPoints는 모든 모델 누적 (model 필드 포함 보장) + if (statusData.centerPoints) { + const withModel = statusData.centerPoints.map(p => ({ ...p, model })); + latestCenterPoints = [...latestCenterPoints, ...withModel]; + } + }); + + if (merged.length > 0) { + setOilTrajectory(merged); + const doneModels = new Set( + Array.from(simResults.entries()) + .filter(([, s]) => s.trajectory && s.trajectory.length > 0) + .map(([m]) => m as PredictionModel) + ) + setVisibleModels(doneModels) + setSimulationSummary(latestSummary); + setCenterPoints(latestCenterPoints); + + // 데이터가 없는 모델에 OpenDrift(또는 첫 번째 보유 모델) 데이터 복사 + const refWindData = newWindDataByModel['OpenDrift'] ?? Object.values(newWindDataByModel)[0]; + const refHydrData = newHydrDataByModel['OpenDrift'] ?? Object.values(newHydrDataByModel)[0]; + doneModels.forEach(model => { + if (!newWindDataByModel[model] && refWindData) newWindDataByModel[model] = refWindData; + if (!newHydrDataByModel[model] && refHydrData) newHydrDataByModel[model] = refHydrData; + }); + + setWindDataByModel(newWindDataByModel); + setHydrDataByModel(newHydrDataByModel); + setWindHydrModel('OpenDrift'); + if (incidentCoord) { + const booms = generateAIBoomLines(merged, incidentCoord, algorithmSettings); + setBoomLines(booms); + } + setSensitiveResources(DEMO_SENSITIVE_RESOURCES); + setCurrentStep(0); + setIsPlaying(true); + if (incidentCoord) { + setFlyToCoord({ lon: incidentCoord.lon, lat: incidentCoord.lat }); + } } - setSensitiveResources(DEMO_SENSITIVE_RESOURCES); - // 새 시뮬레이션 완료 시 flyTo 없으므로 즉시 재생 - setCurrentStep(0); - setIsPlaying(true); - } - if (simStatus.status === 'ERROR') { setIsRunningSimulation(false); - setCurrentExecSn(null); - setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.'); + setPendingExecSns([]); } - }, [simStatus, incidentCoord, algorithmSettings]); + + if (simAnyError) { + setIsRunningSimulation(false); + setPendingExecSns([]); + const errorMessages = Array.from(simErrors.values()).join('; '); + setSimulationError(errorMessages || '시뮬레이션 처리 중 오류가 발생했습니다.'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simAllDone, simAnyError, simResults, simErrors, pendingExecSns.length, incidentCoord, algorithmSettings]); // trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리) useEffect(() => { @@ -435,6 +486,17 @@ export function OilSpillView() { const maxTime = timeSteps[timeSteps.length - 1] ?? predictionTime; + // 유향유속/풍향풍속 데이터 — 선택한 모델 기준으로 파생 + const windHydrModelOptions = useMemo(() => Array.from(visibleModels), [visibleModels]) + const windData = useMemo( + () => windDataByModel[windHydrModel] ?? [], + [windDataByModel, windHydrModel] + ) + const hydrData = useMemo( + () => hydrDataByModel[windHydrModel] ?? [], + [hydrDataByModel, windHydrModel] + ) + useEffect(() => { if (!isPlaying || timeSteps.length === 0) return; if (currentStep >= maxTime) { @@ -482,6 +544,7 @@ export function OilSpillView() { if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON') if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift') setSelectedModels(models) + setVisibleModels(models) // 분석 상세 로딩 (선박/기상 정보) try { const detail = await fetchPredictionDetail(analysis.acdntSn) @@ -505,8 +568,9 @@ export function OilSpillView() { setOilTrajectory(trajectory) if (summary) setSimulationSummary(summary) setCenterPoints(cp ?? []) - setWindData(wd ?? []) - setHydrData(hd ?? []) + setWindDataByModel(wd && wd.length > 0 ? { 'OpenDrift': wd } : {}) + setHydrDataByModel(hd && hd.length > 0 ? { 'OpenDrift': hd } : {}) + setWindHydrModel('OpenDrift') if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 @@ -622,6 +686,7 @@ export function OilSpillView() { backtrackStatus: 'pending', analyst: '', officeName: '', + acdntSttsCd: 'ACTIVE', }) }, []) @@ -663,8 +728,12 @@ export function OilSpillView() { payload.spillTypeCd = spillType; } + payload.models = Array.from(selectedModels); + const { data } = await api.post('/simulation/run', payload); - setCurrentExecSn(data.execSn); + setPendingExecSns( + data.execSns ?? (data.execSn ? [{ model: 'OpenDrift', execSn: data.execSn }] : []) + ); // 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화 if (data.acdntSn && isDirectInput) { @@ -782,6 +851,9 @@ export function OilSpillView() { isRunningSimulation={isRunningSimulation} selectedModels={selectedModels} onModelsChange={setSelectedModels} + visibleModels={visibleModels} + onVisibleModelsChange={setVisibleModels} + hasResults={oilTrajectory.length > 0} predictionTime={predictionTime} onPredictionTimeChange={setPredictionTime} spillType={spillType} @@ -829,7 +901,7 @@ export function OilSpillView() { flyToIncident={flyToCoord} isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'} onMapClick={handleMapClick} - oilTrajectory={oilTrajectory} + oilTrajectory={oilTrajectory.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} selectedModels={selectedModels} boomLines={boomLines} isDrawingBoom={isDrawingBoom} @@ -838,7 +910,7 @@ export function OilSpillView() { layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} lightMode - centerPoints={centerPoints} + centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} windData={windData} hydrData={hydrData} flyToTarget={flyToTarget} @@ -1055,6 +1127,9 @@ export function OilSpillView() { summary={simulationSummary} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} + windHydrModel={windHydrModel} + windHydrModelOptions={windHydrModelOptions} + onWindHydrModelChange={setWindHydrModel} analysisTab={analysisTab} onSwitchAnalysisTab={setAnalysisTab} drawAnalysisMode={drawAnalysisMode} @@ -1074,8 +1149,8 @@ export function OilSpillView() { {/* 확산 예측 실행 중 로딩 오버레이 */} {isRunningSimulation && ( )} diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index c91d7a5..a27c543 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -18,6 +18,9 @@ interface PredictionInputSectionProps { isRunningSimulation: boolean selectedModels: Set onModelsChange: (models: Set) => void + visibleModels?: Set + onVisibleModelsChange?: (models: Set) => void + hasResults?: boolean predictionTime: number onPredictionTimeChange: (time: number) => void spillType: string @@ -46,6 +49,9 @@ const PredictionInputSection = ({ isRunningSimulation, selectedModels, onModelsChange, + visibleModels, + onVisibleModelsChange, + hasResults, predictionTime, onPredictionTimeChange, spillType, @@ -387,19 +393,21 @@ const PredictionInputSection = ({ ] as const).map(m => (
{ if (!m.ready) { alert(`${m.id} 모델은 현재 준비중입니다.`) return } - const next = new Set(selectedModels) - if (next.has(m.id)) { - next.delete(m.id) + if (hasResults && onVisibleModelsChange) { + const next = new Set(visibleModels ?? selectedModels) + if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) } + onVisibleModelsChange(next) } else { - next.add(m.id) + const next = new Set(selectedModels) + if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) } + onModelsChange(next) } - onModelsChange(next) }} > diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 11154dd..4a3f212 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -18,6 +18,9 @@ interface RightPanelProps { summary?: SimulationSummary | null displayControls?: DisplayControls onDisplayControlsChange?: (controls: DisplayControls) => void + windHydrModel?: string + windHydrModelOptions?: string[] + onWindHydrModelChange?: (model: string) => void analysisTab?: 'polygon' | 'circle' onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void drawAnalysisMode?: 'polygon' | null @@ -36,6 +39,7 @@ interface RightPanelProps { export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, displayControls, onDisplayControlsChange, + windHydrModel, windHydrModelOptions = [], onWindHydrModelChange, analysisTab = 'polygon', onSwitchAnalysisTab, drawAnalysisMode, analysisPolygonPoints = [], circleRadiusNm = 5, onCircleRadiusChange, @@ -85,6 +89,20 @@ export function RightPanel({ onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })} >시간 표시
+ {windHydrModelOptions.length > 1 && ( +
+ 데이터 모델 + +
+ )} {/* 오염분석 */} diff --git a/frontend/src/tabs/prediction/components/leftPanelTypes.ts b/frontend/src/tabs/prediction/components/leftPanelTypes.ts index 22fbe14..71a86de 100644 --- a/frontend/src/tabs/prediction/components/leftPanelTypes.ts +++ b/frontend/src/tabs/prediction/components/leftPanelTypes.ts @@ -17,6 +17,9 @@ export interface LeftPanelProps { isRunningSimulation: boolean selectedModels: Set onModelsChange: (models: Set) => void + visibleModels?: Set + onVisibleModelsChange?: (models: Set) => void + hasResults?: boolean predictionTime: number onPredictionTimeChange: (time: number) => void spillType: string diff --git a/frontend/src/tabs/prediction/hooks/useSimulationStatus.ts b/frontend/src/tabs/prediction/hooks/useSimulationStatus.ts index 0445057..5775b3f 100644 --- a/frontend/src/tabs/prediction/hooks/useSimulationStatus.ts +++ b/frontend/src/tabs/prediction/hooks/useSimulationStatus.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueries } from '@tanstack/react-query'; import { api } from '@common/services/api'; import type { SimulationStatusResponse } from '../services/predictionApi'; @@ -14,3 +14,73 @@ export const useSimulationStatus = (execSn: number | null) => { }, }); }; + +export interface ModelExecRef { + model: string; + execSn: number; +} + +interface MultiSimulationStatus { + allDone: boolean; + anyError: boolean; + isLoading: boolean; + results: Map; + errors: Map; +} + +export const useMultiSimulationStatus = (execSns: ModelExecRef[]): MultiSimulationStatus => { + const queries = useQueries({ + queries: execSns.map(({ model, execSn }) => ({ + queryKey: ['simulationStatus', execSn], + queryFn: () => + api.get(`/simulation/status/${execSn}`).then(r => r.data), + enabled: execSns.length > 0, + refetchInterval: (query: { state: { data?: SimulationStatusResponse } }) => { + const status = query.state.data?.status; + if (status === 'DONE' || status === 'ERROR') return false; + return 3000; + }, + meta: { model }, + })), + }); + + if (execSns.length === 0) { + return { + allDone: false, + anyError: false, + isLoading: false, + results: new Map(), + errors: new Map(), + }; + } + + const results = new Map(); + const errors = new Map(); + + execSns.forEach(({ model }, index) => { + const query = queries[index]; + if (query.data) { + results.set(model, query.data); + } + if (query.error) { + const err = query.error; + errors.set(model, err instanceof Error ? err.message : String(err)); + } + }); + + const allDone = + execSns.length > 0 && + execSns.every((_, index) => { + const status = queries[index].data?.status; + return status === 'DONE' || status === 'ERROR'; + }); + + const anyError = execSns.some((_, index) => { + const status = queries[index].data?.status; + return status === 'ERROR' || queries[index].isError; + }); + + const isLoading = execSns.some((_, index) => queries[index].isLoading); + + return { allDone, anyError, isLoading, results, errors }; +}; diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 0b3994e..7062ce7 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -123,7 +123,8 @@ export const createBacktrack = async (input: { export interface SimulationRunResponse { success: boolean; - execSn: number; + execSn: number; // 하위 호환 유지 (첫 번째 모델의 execSn) + execSns: Array<{ model: string; execSn: number }>; acdntSn: number | null; status: 'RUNNING'; } @@ -152,6 +153,7 @@ export interface CenterPoint { lat: number; lon: number; time: number; + model?: string; } export interface OilParticle { @@ -160,6 +162,7 @@ export interface OilParticle { time: number; particle?: number; stranded?: 0 | 1; + model?: string; } export interface SimulationSummary {