wing-ops/backend/src/prediction/predictionService.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

524 lines
15 KiB
TypeScript

import { wingPool } from '../db/wingDb.js';
interface PredictionAnalysis {
acdntSn: number;
acdntNm: string;
occurredAt: string;
analysisDate: string;
requestor: string;
duration: string;
oilType: string;
volume: number | null;
location: string;
lat: number | null;
lon: number | null;
kospsStatus: string;
poseidonStatus: string;
opendriftStatus: string;
backtrackStatus: string;
analyst: string;
officeName: string;
}
interface PredictionDetail {
acdnt: {
acdntSn: number;
acdntNm: string;
occurredAt: string;
lat: number | null;
lon: number | null;
location: string;
analyst: string;
officeName: string;
};
spill: {
oilType: string;
volume: number | null;
unit: string;
fcstHr: number | null;
} | null;
vessels: Array<{
vesselInfoSn: number;
imoNo: string;
vesselNm: string;
vesselTp: string;
loaM: number | null;
breadthM: number | null;
draftM: number | null;
gt: number | null;
dwt: number | null;
builtYr: number | null;
flagCd: string;
callsign: string;
engineDc: string;
insuranceData: unknown;
}>;
weather: Array<{
obsDtm: string;
locNm: string;
temp: string;
weatherDc: string;
wind: string;
wave: string;
humid: string;
vis: string;
sst: string;
}>;
}
interface BacktrackResult {
backtrackSn: number;
acdntSn: number;
estSpilDtm: string | null;
anlysRange: string | null;
lon: number | null;
lat: number | null;
srchRadiusNm: number | null;
totalVessels: number | null;
execSttsCd: string;
rsltData: unknown;
regDtm: string;
}
interface CreateBacktrackInput {
acdntSn: number;
lat: number;
lon: number;
estSpilDtm?: string;
anlysRange?: string;
srchRadiusNm?: number;
}
interface SaveBoomLineInput {
acdntSn: number;
boomNm: string;
priorityOrd?: number;
geojson: unknown;
lengthM?: number;
efficiencyPct?: number;
}
interface BoomLineItem {
boomLineSn: number;
acdntSn: number;
boomNm: string;
priorityOrd: number;
geom: unknown;
lengthM: number | null;
efficiencyPct: number | null;
sttsCd: string;
regDtm: string;
}
interface ListAnalysesInput {
search?: string;
}
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
const params: unknown[] = [];
const conditions: string[] = ["A.USE_YN = 'Y'"];
if (input.search) {
params.push(`%${input.search}%`);
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT
A.ACDNT_SN,
A.ACDNT_NM,
A.OCCRN_DTM,
A.LAT,
A.LNG,
A.LOC_DC,
A.ANALYST_NM,
A.OFFICE_NM,
A.REGION_NM,
S.OIL_TP_CD,
S.SPIL_QTY,
S.SPIL_UNIT_CD,
S.FCST_HR,
P.KOSPS_STATUS,
P.POSEIDON_STATUS,
P.OPENDRIFT_STATUS,
B.BACKTRACK_STATUS
FROM ACDNT A
LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN
LEFT JOIN (
SELECT
ACDNT_SN,
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
FROM PRED_EXEC
GROUP BY ACDNT_SN
) P ON P.ACDNT_SN = A.ACDNT_SN
LEFT JOIN (
SELECT
ACDNT_SN,
MAX(CASE WHEN B.EXEC_STTS_CD IS NOT NULL THEN B.EXEC_STTS_CD ELSE 'pending' END) AS BACKTRACK_STATUS
FROM BACKTRACK B
GROUP BY ACDNT_SN
) B ON B.ACDNT_SN = A.ACDNT_SN
${whereClause}
ORDER BY A.OCCRN_DTM DESC
`;
const { rows } = await wingPool.query(sql, params);
return rows.map((row: Record<string, unknown>) => ({
acdntSn: Number(row['acdnt_sn']),
acdntNm: String(row['acdnt_nm'] ?? ''),
occurredAt: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
analysisDate: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
requestor: String(row['analyst_nm'] ?? ''),
duration: row['fcst_hr'] != null ? `${row['fcst_hr']}hr` : '',
oilType: String(row['oil_tp_cd'] ?? ''),
volume: row['spil_qty'] != null ? parseFloat(String(row['spil_qty'])) : null,
location: String(row['loc_dc'] ?? ''),
lat: row['lat'] != null ? parseFloat(String(row['lat'])) : null,
lon: row['lng'] != null ? parseFloat(String(row['lng'])) : null,
kospsStatus: String(row['kosps_status'] ?? 'pending').toLowerCase(),
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
analyst: String(row['analyst_nm'] ?? ''),
officeName: String(row['office_nm'] ?? ''),
}));
}
export async function getAnalysisDetail(acdntSn: number): Promise<PredictionDetail | null> {
const acdntSql = `
SELECT
A.ACDNT_SN,
A.ACDNT_NM,
A.OCCRN_DTM,
A.LAT,
A.LNG,
A.LOC_DC,
A.ANALYST_NM,
A.OFFICE_NM
FROM ACDNT A
WHERE A.ACDNT_SN = $1
AND A.USE_YN = 'Y'
`;
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
if (acdntRows.length === 0) return null;
const a = acdntRows[0] as Record<string, unknown>;
const spillSql = `
SELECT
OIL_TP_CD,
SPIL_QTY,
SPIL_UNIT_CD,
FCST_HR
FROM SPIL_DATA
WHERE ACDNT_SN = $1
ORDER BY SPIL_DATA_SN ASC
LIMIT 1
`;
const { rows: spillRows } = await wingPool.query(spillSql, [acdntSn]);
const vesselSql = `
SELECT
VESSEL_INFO_SN,
IMO_NO,
VESSEL_NM,
VESSEL_TP,
LOA_M,
BREADTH_M,
DRAFT_M,
GT,
DWT,
BUILT_YR,
FLAG_CD,
CALLSIGN,
ENGINE_DC,
INSURANCE_DATA
FROM VESSEL_INFO
WHERE ACDNT_SN = $1
ORDER BY VESSEL_INFO_SN ASC
`;
const { rows: vesselRows } = await wingPool.query(vesselSql, [acdntSn]);
const weatherSql = `
SELECT
OBS_DTM,
LOC_NM,
TEMP,
WEATHER_DC,
WIND,
WAVE,
HUMID,
VIS,
SST
FROM ACDNT_WEATHER
WHERE ACDNT_SN = $1
ORDER BY OBS_DTM ASC
`;
const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]);
const spill =
spillRows.length > 0
? (() => {
const s = spillRows[0] as Record<string, unknown>;
return {
oilType: String(s['oil_tp_cd'] ?? ''),
volume: s['spil_qty'] != null ? parseFloat(String(s['spil_qty'])) : null,
unit: String(s['spil_unit_cd'] ?? ''),
fcstHr: s['fcst_hr'] != null ? parseFloat(String(s['fcst_hr'])) : null,
};
})()
: null;
const vessels = vesselRows.map((v: Record<string, unknown>) => ({
vesselInfoSn: Number(v['vessel_info_sn']),
imoNo: String(v['imo_no'] ?? ''),
vesselNm: String(v['vessel_nm'] ?? ''),
vesselTp: String(v['vessel_tp'] ?? ''),
loaM: v['loa_m'] != null ? parseFloat(String(v['loa_m'])) : null,
breadthM: v['breadth_m'] != null ? parseFloat(String(v['breadth_m'])) : null,
draftM: v['draft_m'] != null ? parseFloat(String(v['draft_m'])) : null,
gt: v['gt'] != null ? parseFloat(String(v['gt'])) : null,
dwt: v['dwt'] != null ? parseFloat(String(v['dwt'])) : null,
builtYr: v['built_yr'] != null ? Number(v['built_yr']) : null,
flagCd: String(v['flag_cd'] ?? ''),
callsign: String(v['callsign'] ?? ''),
engineDc: String(v['engine_dc'] ?? ''),
insuranceData: v['insurance_data'] ?? null,
}));
const weather = weatherRows.map((w: Record<string, unknown>) => ({
obsDtm: w['obs_dtm'] ? String(w['obs_dtm']) : '',
locNm: String(w['loc_nm'] ?? ''),
temp: String(w['temp'] ?? ''),
weatherDc: String(w['weather_dc'] ?? ''),
wind: String(w['wind'] ?? ''),
wave: String(w['wave'] ?? ''),
humid: String(w['humid'] ?? ''),
vis: String(w['vis'] ?? ''),
sst: String(w['sst'] ?? ''),
}));
return {
acdnt: {
acdntSn: Number(a['acdnt_sn']),
acdntNm: String(a['acdnt_nm'] ?? ''),
occurredAt: a['occrn_dtm'] ? String(a['occrn_dtm']) : '',
lat: a['lat'] != null ? parseFloat(String(a['lat'])) : null,
lon: a['lng'] != null ? parseFloat(String(a['lng'])) : null,
location: String(a['loc_dc'] ?? ''),
analyst: String(a['analyst_nm'] ?? ''),
officeName: String(a['office_nm'] ?? ''),
},
spill,
vessels,
weather,
};
}
export async function getBacktrack(sn: number): Promise<BacktrackResult | null> {
const sql = `
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
EXEC_STTS_CD, RSLT_DATA, REG_DTM
FROM BACKTRACK
WHERE BACKTRACK_SN = $1 AND USE_YN = 'Y'
`;
const { rows } = await wingPool.query(sql, [sn]);
if (rows.length === 0) return null;
return rowToBacktrack(rows[0] as Record<string, unknown>);
}
export async function listBacktracksByAcdnt(acdntSn: number): Promise<BacktrackResult[]> {
const sql = `
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
EXEC_STTS_CD, RSLT_DATA, REG_DTM
FROM BACKTRACK
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
ORDER BY REG_DTM DESC
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
return rows.map((r: Record<string, unknown>) => rowToBacktrack(r));
}
function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
return {
backtrackSn: Number(r['backtrack_sn']),
acdntSn: Number(r['acdnt_sn']),
estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null,
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null,
srchRadiusNm: r['srch_radius_nm'] != null ? parseFloat(String(r['srch_radius_nm'])) : null,
totalVessels: r['total_vessels'] != null ? Number(r['total_vessels']) : null,
execSttsCd: String(r['exec_stts_cd'] ?? ''),
rsltData: r['rslt_data'] ?? null,
regDtm: String(r['reg_dtm'] ?? ''),
};
}
export async function createBacktrack(
input: CreateBacktrackInput,
): Promise<{ backtrackSn: number }> {
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
const sql = `
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
VALUES (
$1, $2, $3,
ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326),
$3 || ' + ' || $2,
$4, $5, $6, 'PENDING'
)
RETURNING BACKTRACK_SN
`;
const { rows } = await wingPool.query(sql, [
acdntSn, lat, lon,
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
]);
return { backtrackSn: Number((rows[0] as Record<string, unknown>)['backtrack_sn']) };
}
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
const { acdntSn, boomNm, priorityOrd = 0, geojson, lengthM, efficiencyPct } = input;
const sql = `
INSERT INTO BOOM_LINE (ACDNT_SN, BOOM_NM, PRIORITY_ORD, GEOM, LENGTH_M, EFFICIENCY_PCT)
VALUES ($1, $2, $3, ST_GeomFromGeoJSON($4), $5, $6)
RETURNING BOOM_LINE_SN
`;
const { rows } = await wingPool.query(sql, [
acdntSn, boomNm, priorityOrd,
JSON.stringify(geojson),
lengthM || null, efficiencyPct || null,
]);
return { boomLineSn: Number((rows[0] as Record<string, unknown>)['boom_line_sn']) };
}
interface TrajectoryParticle {
lat: number;
lon: number;
stranded?: 0 | 1;
}
interface TrajectoryWindPoint {
lat: number;
lon: number;
wind_speed: number;
wind_direction: number;
}
interface TrajectoryHydrGrid {
lonInterval: number[];
boundLonLat: { top: number; bottom: number; left: number; right: number };
rows: number;
cols: number;
latInterval: number[];
}
interface TrajectoryTimeStep {
particles: TrajectoryParticle[];
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?: TrajectoryWindPoint[];
hydr_data?: [number[][], number[][]];
hydr_grid?: TrajectoryHydrGrid;
}
interface TrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>;
summary: {
remainingVolume: number;
weatheredVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
};
centerPoints: Array<{ lat: number; lon: number; time: number }>;
windData: TrajectoryWindPoint[][];
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
}
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult {
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 async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
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
`;
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[]);
}
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
const sql = `
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
ST_AsGeoJSON(GEOM) AS GEOM, LENGTH_M, EFFICIENCY_PCT, STTS_CD, REG_DTM
FROM BOOM_LINE
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
ORDER BY PRIORITY_ORD ASC
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
return rows.map((r: Record<string, unknown>) => ({
boomLineSn: Number(r['boom_line_sn']),
acdntSn: Number(r['acdnt_sn']),
boomNm: String(r['boom_nm'] ?? ''),
priorityOrd: Number(r['priority_ord'] ?? 0),
geom: r['geom'] != null ? JSON.parse(String(r['geom'])) : null,
lengthM: r['length_m'] != null ? parseFloat(String(r['length_m'])) : null,
efficiencyPct: r['efficiency_pct'] != null ? parseFloat(String(r['efficiency_pct'])) : null,
sttsCd: String(r['stts_cd'] ?? 'PLANNED'),
regDtm: String(r['reg_dtm'] ?? ''),
}));
}