[예측] - 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>
453 lines
16 KiB
TypeScript
Executable File
453 lines
16 KiB
TypeScript
Executable File
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
|