wing-ops/backend/src/prediction/predictionService.ts
jeonghyo.k 1da2553694 feat(incidents): 통합 분석 패널 분할 뷰 및 유출유 확산 요약 API 추가
- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출
- 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가
- prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel)
- HNS 분석 생성 시 acdntSn 연결 지원
- GSC 사고 목록 응답에 acdntSn 노출
- 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
2026-04-16 15:24:06 +09:00

938 lines
31 KiB
TypeScript

import { wingPool } from '../db/wingDb.js';
import { runBacktrackAnalysis } from './backtrackAnalysisService.js';
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371;
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));
}
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;
acdntSttsCd: string;
predRunSn: number | null;
runDtm: string | null;
}
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;
acdntSn?: number;
}
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
const params: unknown[] = [];
const conditions: string[] = ["A.USE_YN = 'Y'"];
if (input.acdntSn) {
params.push(input.acdntSn);
conditions.push(`A.ACDNT_SN = $${params.length}`);
}
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.ACDNT_STTS_CD,
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.PRED_RUN_SN,
P.RUN_DTM,
P.KOSPS_STATUS,
P.POSEIDON_STATUS,
P.OPENDRIFT_STATUS,
B.BACKTRACK_STATUS,
COALESCE(U.USER_NM, A.ANALYST_NM) AS RESOLVED_ANALYST,
COALESCE(O.ORG_NM, A.OFFICE_NM) AS RESOLVED_OFFICE
FROM ACDNT A
INNER JOIN (
SELECT
ACDNT_SN,
PRED_RUN_SN,
MIN(BGNG_DTM) AS RUN_DTM,
MIN(SPIL_DATA_SN) AS SPIL_DATA_SN,
MIN(EXEC_USER_ID::TEXT)::UUID AS EXEC_USER_ID,
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, PRED_RUN_SN
) P ON P.ACDNT_SN = A.ACDNT_SN
LEFT JOIN SPIL_DATA S ON S.SPIL_DATA_SN = P.SPIL_DATA_SN
LEFT JOIN AUTH_USER U ON U.USER_ID = P.EXEC_USER_ID
LEFT JOIN AUTH_ORG O ON O.ORG_SN = U.ORG_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 P.RUN_DTM DESC NULLS LAST, 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['resolved_analyst'] ?? ''),
officeName: String(row['resolved_office'] ?? ''),
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
predRunSn: row['pred_run_sn'] != null ? Number(row['pred_run_sn']) : null,
runDtm: row['run_dtm'] ? String(row['run_dtm']) : null,
}));
}
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'] ? new Date(r['est_spil_dtm'] as string | Date).toISOString() : 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<BacktrackResult> {
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::double precision, $3::double precision,
ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326),
$3::text || ' + ' || $2::text,
$4, $5, $6, 'PENDING'
)
RETURNING BACKTRACK_SN
`;
const { rows } = await wingPool.query(sql, [
acdntSn, lat, lon,
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
]);
const backtrackSn = Number((rows[0] as Record<string, unknown>)['backtrack_sn']);
// 동기 분석 (완료까지 대기 후 결과 반환)
await runBacktrackAnalysis(backtrackSn);
const result = await getBacktrack(backtrackSn);
if (!result) throw new Error('역추적 결과를 찾을 수 없습니다');
return result;
}
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;
evaporation_volume_m3?: number;
dispersion_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;
}
// ALGO_CD → 프론트엔드 모델명 매핑
const ALGO_CD_TO_MODEL: Record<string, string> = {
'OPENDRIFT': 'OpenDrift',
'POSEIDON': 'POSEIDON',
};
interface SingleModelTrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
summary: {
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
};
stepSummaries: Array<{
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
}>;
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
windData: TrajectoryWindPoint[][];
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
}
interface TrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
summary: {
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
};
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
windDataByModel: Record<string, TrajectoryWindPoint[][]>;
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']>;
}
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
const trajectory = rawResult.flatMap((step, stepIdx) =>
step.particles.map((p, i) => ({
lat: p.lat,
lon: p.lon,
time: stepIdx,
particle: i,
stranded: p.stranded,
model,
}))
);
const lastStep = rawResult[rawResult.length - 1];
const summary = {
remainingVolume: lastStep.remaining_volume_m3,
weatheredVolume: lastStep.weathered_volume_m3,
evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65,
dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35,
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, model }
: null
)
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null);
const stepSummaries = rawResult.map((step) => ({
remainingVolume: step.remaining_volume_m3,
weatheredVolume: step.weathered_volume_m3,
evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65,
dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35,
pollutionArea: step.pollution_area_km2,
beachedVolume: step.beached_volume_m3,
pollutionCoastLength: step.pollution_coast_length_m,
}));
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, stepSummaries, centerPoints, windData, hydrData };
}
export async function getAnalysisTrajectory(acdntSn: number, predRunSn?: number): Promise<TrajectoryResult | null> {
// 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회
// predRunSn이 있으면 해당 실행의 결과만, 없으면 최신 결과
const sql = predRunSn != null
? `
SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
AND PRED_RUN_SN = $2
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
ORDER BY CMPL_DTM DESC
`
: `
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 params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
const { rows } = await wingPool.query(sql, params);
if (rows.length === 0) return null;
// 모든 모델의 파티클을 하나의 배열로 병합
let mergedTrajectory: TrajectoryResult['trajectory'] = [];
let allCenterPoints: TrajectoryResult['centerPoints'] = [];
// summary: 가장 최근 완료된 OpenDrift 기준, 없으면 POSEIDON 기준
let baseResult: SingleModelTrajectoryResult | null = null;
const windDataByModel: Record<string, TrajectoryWindPoint[][]> = {};
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
const stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']> = {};
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
const baseRow = opendriftRow ?? poseidonRow ?? null;
for (const row of rows as Array<Record<string, unknown>>) {
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);
windDataByModel[modelName] = parsed.windData;
hydrDataByModel[modelName] = parsed.hydrData;
summaryByModel[modelName] = parsed.summary;
stepSummariesByModel[modelName] = parsed.stepSummaries;
if (row === baseRow) {
baseResult = parsed;
}
}
if (!baseResult) return null;
return {
trajectory: mergedTrajectory,
summary: baseResult.summary,
centerPoints: allCenterPoints,
windDataByModel,
hydrDataByModel,
summaryByModel,
stepSummariesByModel,
};
}
export async function getSensitiveResourcesByAcdntSn(
acdntSn: number,
): Promise<{ category: string; count: number; totalArea: number | null }[]> {
const sql = `
WITH all_wkts AS (
SELECT step_data ->> 'wkt' AS wkt
FROM wing.PRED_EXEC,
jsonb_array_elements(RSLT_DATA) AS step_data
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
AND RSLT_DATA IS NOT NULL
),
union_geom AS (
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
FROM all_wkts
WHERE wkt IS NOT NULL AND wkt <> ''
)
SELECT sr.CATEGORY,
COUNT(*)::int AS count,
CASE
WHEN bool_and(sr.PROPERTIES ? 'area')
THEN SUM((sr.PROPERTIES->>'area')::float)
ELSE NULL
END AS total_area
FROM wing.SENSITIVE_RESOURCE sr, union_geom
WHERE union_geom.geom IS NOT NULL
AND ST_Intersects(sr.GEOM, union_geom.geom)
GROUP BY sr.CATEGORY
ORDER BY sr.CATEGORY
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
return rows.map((r: Record<string, unknown>) => ({
category: String(r['category'] ?? ''),
count: Number(r['count'] ?? 0),
totalArea: r['total_area'] != null ? Number(r['total_area']) : null,
}));
}
export async function getSensitiveResourcesGeoJsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
const sql = `
WITH all_wkts AS (
SELECT step_data ->> 'wkt' AS wkt
FROM wing.PRED_EXEC,
jsonb_array_elements(RSLT_DATA) AS step_data
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
AND RSLT_DATA IS NOT NULL
),
union_geom AS (
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
FROM all_wkts
WHERE wkt IS NOT NULL AND wkt <> ''
)
SELECT sr.SR_ID, sr.CATEGORY, sr.PROPERTIES,
ST_AsGeoJSON(sr.GEOM)::jsonb AS geom_json
FROM wing.SENSITIVE_RESOURCE sr, union_geom
WHERE union_geom.geom IS NOT NULL
AND ST_Intersects(sr.GEOM, union_geom.geom)
ORDER BY sr.CATEGORY, sr.SR_ID
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
const features = rows.map((r: Record<string, unknown>) => ({
type: 'Feature',
geometry: r['geom_json'],
properties: {
srId: Number(r['sr_id']),
category: String(r['category'] ?? ''),
...(r['properties'] as Record<string, unknown> ?? {}),
},
}));
return { type: 'FeatureCollection', features };
}
export async function getSensitivityEvaluationGeojsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`;
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] };
const lat = Number(acdntRows[0]['lat']);
const lng = Number(acdntRows[0]['lng']);
const sql = `
SELECT SR_ID, PROPERTIES,
ST_AsGeoJSON(GEOM)::jsonb AS geom_json,
ST_Area(GEOM::geography) / 1000000.0 AS area_km2
FROM wing.SENSITIVE_EVALUATION
WHERE ST_DWithin(
GEOM::geography,
ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
10000
)
ORDER BY SR_ID
`;
const { rows } = await wingPool.query(sql, [lat, lng]);
const features = rows.map((r: Record<string, unknown>) => ({
type: 'Feature',
geometry: r['geom_json'],
properties: {
srId: Number(r['sr_id']),
area_km2: Number(r['area_km2']),
...(r['properties'] as Record<string, unknown> ?? {}),
},
}));
return { type: 'FeatureCollection', features };
}
export async function getPredictionParticlesGeojsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> {
const sql = `
SELECT ALGO_CD, RSLT_DATA
FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
AND RSLT_DATA IS NOT NULL
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 };
const ALGO_TO_MODEL: Record<string, string> = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' };
const features: unknown[] = [];
let globalMaxStep = 0;
for (const row of rows) {
const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']);
const steps = row['rslt_data'] as TrajectoryTimeStep[];
const maxStep = steps.length - 1;
if (maxStep > globalMaxStep) globalMaxStep = maxStep;
steps.forEach((step, stepIdx) => {
step.particles.forEach(p => {
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: {
model,
time: stepIdx,
stranded: p.stranded ?? 0,
isLastStep: stepIdx === maxStep,
},
});
});
});
}
return { type: 'FeatureCollection', features, maxStep: globalMaxStep };
}
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'] ?? ''),
}));
}
// ── 유출유 확산 요약 (통합조회 분할 패널용) ──────────────
export interface OilSpillSummary {
model: string;
forecastDurationHr: number | null;
maxSpreadDistanceKm: number | null;
coastArrivalTimeHr: number | null;
affectedCoastlineKm: number | null;
weatheringRatePct: number | null;
remainingVolumeKl: number | null;
}
export interface OilSpillSummaryResponse {
primary: OilSpillSummary;
byModel: Record<string, OilSpillSummary>;
}
export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise<OilSpillSummaryResponse | null> {
const baseSql = `
SELECT pe.ALGO_CD, pe.RSLT_DATA,
sd.FCST_HR,
ST_Y(a.LOC_GEOM) AS spil_lat,
ST_X(a.LOC_GEOM) AS spil_lon
FROM wing.PRED_EXEC pe
LEFT JOIN wing.SPIL_DATA sd ON sd.ACDNT_SN = pe.ACDNT_SN
LEFT JOIN wing.ACDNT a ON a.ACDNT_SN = pe.ACDNT_SN
WHERE pe.ACDNT_SN = $1
AND pe.ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND pe.EXEC_STTS_CD = 'COMPLETED'
AND pe.RSLT_DATA IS NOT NULL
`;
const sql = predRunSn != null
? baseSql + ' AND pe.PRED_RUN_SN = $2 ORDER BY pe.CMPL_DTM DESC'
: baseSql + ' ORDER BY pe.CMPL_DTM DESC';
const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
const { rows } = await wingPool.query(sql, params);
if (rows.length === 0) return null;
const byModel: Record<string, OilSpillSummary> = {};
// OpenDrift 우선, 없으면 POSEIDON
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
const primaryRow = opendriftRow ?? poseidonRow ?? null;
for (const row of rows as Array<Record<string, unknown>>) {
const rsltData = row['rslt_data'] as TrajectoryTimeStep[] | null;
if (!rsltData || rsltData.length === 0) continue;
const algoCd = String(row['algo_cd'] ?? '');
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
const fcstHr = row['fcst_hr'] != null ? Number(row['fcst_hr']) : null;
const spilLat = row['spil_lat'] != null ? Number(row['spil_lat']) : null;
const spilLon = row['spil_lon'] != null ? Number(row['spil_lon']) : null;
const totalSteps = rsltData.length;
const lastStep = rsltData[totalSteps - 1];
// 최대 확산거리 — 사고 위치 또는 첫 파티클 위치를 원점으로 사용
let maxDist: number | null = null;
const originLat = spilLat ?? rsltData[0]?.particles[0]?.lat ?? null;
const originLon = spilLon ?? rsltData[0]?.particles[0]?.lon ?? null;
if (originLat != null && originLon != null) {
let maxVal = 0;
for (const step of rsltData) {
for (const p of step.particles) {
const d = haversineKm(originLat, originLon, p.lat, p.lon);
if (d > maxVal) maxVal = d;
}
}
maxDist = maxVal;
}
// 해안 도달 시간 (stranded===1 최초 등장 step)
let coastArrivalHr: number | null = null;
for (let i = 0; i < totalSteps; i++) {
if (rsltData[i].particles.some((p) => p.stranded === 1)) {
coastArrivalHr = fcstHr != null && totalSteps > 1
? parseFloat(((i / (totalSteps - 1)) * fcstHr).toFixed(1))
: i;
break;
}
}
// 풍화율
const totalVol = lastStep.remaining_volume_m3 + lastStep.weathered_volume_m3 + lastStep.beached_volume_m3;
const weatheringPct = totalVol > 0
? parseFloat(((lastStep.weathered_volume_m3 / totalVol) * 100).toFixed(1))
: null;
byModel[modelName] = {
model: modelName,
forecastDurationHr: fcstHr,
maxSpreadDistanceKm: maxDist != null ? parseFloat(maxDist.toFixed(1)) : null,
coastArrivalTimeHr: coastArrivalHr,
affectedCoastlineKm: lastStep.pollution_coast_length_m != null
? parseFloat((lastStep.pollution_coast_length_m / 1000).toFixed(1))
: null,
weatheringRatePct: weatheringPct,
remainingVolumeKl: lastStep.remaining_volume_m3 != null
? parseFloat(lastStep.remaining_volume_m3.toFixed(1))
: null,
};
}
if (!primaryRow) return null;
const primaryAlgo = String(primaryRow['algo_cd'] ?? '');
const primaryModel = ALGO_CD_TO_MODEL[primaryAlgo] ?? primaryAlgo;
return {
primary: byModel[primaryModel] ?? Object.values(byModel)[0],
byModel,
};
}