wing-ops/backend/src/routes/simulation.ts
jeonghyo.k 88eb6b121a feat(prediction): OpenDrift 유류 확산 시뮬레이션 통합 + CCTV/관리자 고도화
[예측]
- OpenDrift Python API 서버 및 스크립트 추가 (prediction/opendrift/)
- 시뮬레이션 상태 폴링 훅(useSimulationStatus), 로딩 오버레이 추가
- HydrParticleOverlay: deck.gl 기반 입자 궤적 시각화 레이어
- OilSpillView/LeftPanel/RightPanel: 시뮬레이션 실행·결과 표시 UI 개편
- predictionService/predictionRouter: 시뮬레이션 CRUD 및 상태 관리 API
- simulation.ts: OpenDrift 연동 엔드포인트 확장
- docs/PREDICTION-GUIDE.md: 예측 기능 개발 가이드 추가

[CCTV/항공방제]
- CCTV 오일 감지 GPU 추론 연동 (OilDetectionOverlay, useOilDetection)
- CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지)
- oil_inference_server.py: Python GPU 추론 서버

[관리자]
- 관리자 화면 고도화 (사용자/권한/게시판/선박신호 패널)
- AdminSidebar, BoardMgmtPanel, VesselSignalPanel 신규 컴포넌트

[기타]
- DB: 시뮬레이션 결과, 선박보험 시드(1391건), 역할 정리 마이그레이션
- 팀 워크플로우 v1.6.1 동기화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 14:55:46 +09:00

453 lines
16 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Router, Request, Response } from 'express'
import { wingPool } from '../db/wingDb.js'
import { requireAuth } from '../auth/authMiddleware.js'
import {
isValidLatitude,
isValidLongitude,
isValidNumber,
isValidStringLength,
} from '../middleware/security.js'
const router = Router()
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'
const POLL_INTERVAL_MS = 3000
const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
// 유종 매핑: 한국어 UI 선택값 → OpenDrift 유종 코드
// 추후 DB/설정 파일로 외부화 예정 (개발 단계 임시 구현)
const OIL_TYPE_MAP: Record<string, string> = {
'벙커C유': 'GENERIC BUNKER C',
'경유': 'GENERIC DIESEL',
'원유': 'WEST TEXAS INTERMEDIATE (WTI)',
'중유': 'GENERIC HEAVY FUEL OIL',
'등유': 'FUEL OIL NO.1 (KEROSENE)',
'휘발유': 'GENERIC GASOLINE',
}
// 유종 매핑: 한국어 UI → DB 저장 코드
const OIL_DB_CODE_MAP: Record<string, string> = {
'벙커C유': 'BUNKER_C',
'경유': 'DIESEL',
'원유': 'CRUDE_OIL',
'중유': 'HEAVY_FUEL_OIL',
'등유': 'KEROSENE',
'휘발유': 'GASOLINE',
}
// 유출 형태 매핑: 한국어 UI → DB 저장 코드
const SPIL_TYPE_MAP: Record<string, string> = {
'연속': 'CONTINUOUS',
'비연속': 'DISCONTINUOUS',
'순간 유출': 'INSTANT',
}
// 단위 매핑: 한국어 UI → DB 저장 코드
const UNIT_MAP: Record<string, string> = {
'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL',
}
// ============================================================
// POST /api/simulation/run
// 확산 시뮬레이션 실행 (OpenDrift)
// ============================================================
/**
* OpenDrift 확산 시뮬레이션을 실행한다.
* Python FastAPI 서버에 작업을 제출하고 job_id를 받아
* 백그라운드에서 폴링하며 결과를 DB에 저장한다.
* 프론트엔드는 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
// 1. 필수 파라미터 검증
if (lat === undefined || lon === undefined || runTime === undefined) {
return res.status(400).json({
error: '필수 파라미터 누락',
required: ['lat', 'lon', 'runTime'],
})
}
if (!isValidLatitude(lat)) {
return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90~90 범위여야 합니다.' })
}
if (!isValidLongitude(lon)) {
return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180~180 범위여야 합니다.' })
}
if (!isValidNumber(runTime, 1, 720)) {
return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위여야 합니다.' })
}
if (matVol !== undefined && !isValidNumber(matVol, 0, 1000000)) {
return res.status(400).json({ error: '유효하지 않은 유출량' })
}
if (matTy !== undefined && (typeof matTy !== 'string' || !isValidStringLength(matTy, 50))) {
return res.status(400).json({ error: '유효하지 않은 유종' })
}
// acdntSn 없는 경우 acdntNm 필수
if (!rawAcdntSn && (!acdntNm || typeof acdntNm !== 'string' || !acdntNm.trim())) {
return res.status(400).json({ error: '사고를 선택하거나 사고명을 입력해야 합니다.' })
}
if (acdntNm && (typeof acdntNm !== 'string' || !isValidStringLength(acdntNm, 200))) {
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
}
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
let resolvedSpilDataSn: number | null = null
if (!resolvedAcdntSn && acdntNm) {
try {
const occrn = startTime ?? new Date().toISOString()
const acdntRes = await wingPool.query(
`INSERT INTO wing.ACDNT
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
VALUES (
'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' ||
LPAD(
(SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1
FROM wing.ACDNT
WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT,
4, '0'
),
$1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW()
)
RETURNING ACDNT_SN`,
[acdntNm.trim(), occrn, lat, lon]
)
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
const spilRes = await wingPool.query(
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
RETURNING SPIL_DATA_SN`,
[
resolvedAcdntSn,
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
matVol ?? 0,
UNIT_MAP[spillUnit as string] ?? 'KL',
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
runTime,
]
)
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
} catch (dbErr) {
console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
return res.status(500).json({ error: '사고 정보 생성 실패' })
}
}
// 2. Python NC 파일 존재 여부 확인
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번에서 처리
}
// 3. 기존 사고의 경우 SPIL_DATA_SN 조회
if (resolvedAcdntSn && !resolvedSpilDataSn) {
try {
const spilRes = await wingPool.query(
`SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`,
[resolvedAcdntSn]
)
if (spilRes.rows.length > 0) {
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
}
} catch (dbErr) {
console.error('[simulation] SPIL_DATA 조회 실패:', dbErr)
}
}
// 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),
})
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]
)
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
}
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]
)
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]
)
// 7. 즉시 응답 (프론트엔드는 execSn으로 폴링, acdntSn은 신규 생성 사고 추적용)
res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' })
// 8. 백그라운드 폴링 시작
pollAndSave(jobId, predExecSn).catch((err: unknown) =>
console.error('[simulation] pollAndSave 오류:', err)
)
} catch {
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
}
})
// ============================================================
// GET /api/simulation/status/:execSn
// 시뮬레이션 실행 상태 및 결과 조회
// ============================================================
/**
* PRED_EXEC 테이블에서 실행 상태를 조회한다.
* DB 상태(COMPLETED/FAILED)를 프론트 상태(DONE/ERROR)로 매핑하여 반환한다.
*/
router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) => {
const execSn = parseInt(req.params.execSn as string, 10)
if (isNaN(execSn) || execSn <= 0) {
return res.status(400).json({ error: '유효하지 않은 execSn' })
}
try {
const result = await wingPool.query(
`SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, sd.FCST_HR,
(
SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR)
FROM wing.PRED_EXEC hist
JOIN wing.SPIL_DATA hsd ON hist.SPIL_DATA_SN = hsd.SPIL_DATA_SN
WHERE hist.ALGO_CD = pe.ALGO_CD
AND hist.EXEC_STTS_CD = 'COMPLETED'
AND hist.REQD_SEC IS NOT NULL AND hist.REQD_SEC > 0
AND hsd.FCST_HR IS NOT NULL AND hsd.FCST_HR > 0
) AS avg_sec_per_hr
FROM wing.PRED_EXEC pe
LEFT JOIN wing.SPIL_DATA sd ON pe.SPIL_DATA_SN = sd.SPIL_DATA_SN
WHERE pe.PRED_EXEC_SN=$1`,
[execSn]
)
if (result.rows.length === 0) {
return res.status(404).json({ error: '분석 기록을 찾을 수 없습니다.' })
}
const row = result.rows[0]
const dbStatus: string = row.exec_stts_cd as string
// DB 상태 → API 상태 매핑
const statusMap: Record<string, string> = {
PENDING: 'PENDING',
RUNNING: 'RUNNING',
COMPLETED: 'DONE',
FAILED: 'ERROR',
}
const status = statusMap[dbStatus] ?? dbStatus
if (status === 'DONE' && row.rslt_data) {
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[])
return res.json({ status, trajectory, summary, centerPoints, windData, hydrData })
}
if (status === 'ERROR') {
return res.json({ status, error: (row.err_msg as string) || '분석 중 오류가 발생했습니다.' })
}
// PENDING/RUNNING: 경과 시간 기반 진행률 계산
// 과거 실행의 초/예측시간 비율(avg_sec_per_hr) × 현재 fcst_hr로 추정, 이력 없으면 5초/hr 폴백
let progress: number | undefined;
if (status === 'RUNNING' && row.bgng_dtm) {
const fcstHr = Number(row.fcst_hr) || 24;
const avgSecPerHr = row.avg_sec_per_hr ? Number(row.avg_sec_per_hr) : 5;
const estimatedSec = avgSecPerHr * fcstHr;
const elapsedSec = (Date.now() - new Date(row.bgng_dtm as string).getTime()) / 1000;
progress = Math.min(95, Math.floor((elapsedSec / estimatedSec) * 100));
}
res.json({ status, ...(progress !== undefined && { progress }) })
} catch {
res.status(500).json({ error: '상태 조회 실패' })
}
})
// ============================================================
// 백그라운드 폴링
// ============================================================
async function pollAndSave(jobId: string, execSn: number): Promise<void> {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
try {
const pollRes = await fetch(`${PYTHON_API_URL}/status/${jobId}`, {
signal: AbortSignal.timeout(5000),
})
if (!pollRes.ok) continue
const data = await pollRes.json() as PythonStatusResponse
if (data.status === 'DONE' && data.result) {
await wingPool.query(
`UPDATE wing.PRED_EXEC
SET EXEC_STTS_CD='COMPLETED',
RSLT_DATA=$1,
CMPL_DTM=NOW(),
REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER
WHERE PRED_EXEC_SN=$2`,
[JSON.stringify(data.result), execSn]
)
return
}
if (data.status === 'ERROR') {
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
[data.error ?? '분석 오류', execSn]
)
return
}
} catch {
// 개별 폴링 오류는 무시하고 재시도
}
}
// 타임아웃 처리
await wingPool.query(
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='분석 시간 초과 (30분)', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
[execSn]
)
}
// ============================================================
// 타입 및 결과 변환
// ============================================================
interface PythonParticle {
lat: number
lon: number
stranded?: 0 | 1
}
interface WindPoint {
lat: number
lon: number
wind_speed: number
wind_direction: number
}
interface HydrGrid {
lonInterval: number[]
boundLonLat: { top: number; bottom: number; left: number; right: number }
rows: number
cols: number
latInterval: number[]
}
interface PythonTimeStep {
particles: PythonParticle[]
remaining_volume_m3: number
weathered_volume_m3: number
pollution_area_km2: number
beached_volume_m3: number
pollution_coast_length_m: number
center_lat?: number
center_lon?: number
wind_data?: WindPoint[]
hydr_data?: [number[][], number[][]]
hydr_grid?: HydrGrid
}
interface PythonStatusResponse {
status: 'RUNNING' | 'DONE' | 'ERROR'
result?: PythonTimeStep[]
error?: string
}
function transformResult(rawResult: PythonTimeStep[]) {
const trajectory = rawResult.flatMap((step, stepIdx) =>
step.particles.map((p, i) => ({
lat: p.lat,
lon: p.lon,
time: stepIdx,
particle: i,
stranded: p.stranded,
}))
)
const lastStep = rawResult[rawResult.length - 1]
const summary = {
remainingVolume: lastStep.remaining_volume_m3,
weatheredVolume: lastStep.weathered_volume_m3,
pollutionArea: lastStep.pollution_area_km2,
beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m,
}
const centerPoints = rawResult
.map((step, stepIdx) =>
step.center_lat != null && step.center_lon != null
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
: null
)
.filter((p): p is { lat: number; lon: number; time: number } => p !== null)
const windData = rawResult.map((step) => step.wind_data ?? [])
const hydrData = rawResult.map((step) =>
step.hydr_data && step.hydr_grid
? { value: step.hydr_data, grid: step.hydr_grid }
: null
)
return { trajectory, summary, centerPoints, windData, hydrData }
}
export default router