From d9ba1b0e1ab24559af96e3f6cfc872dba855b97d Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:29:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=98=EC=A0=81=ED=83=90=EC=A7=80=20?= =?UTF-8?q?Python=20=EC=9D=B4=EA=B4=80=20=E2=80=94=20O(n=C2=B2)=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EA=B7=BC?= =?UTF-8?q?=EC=A0=91=ED=83=90=EC=A7=80=20=E2=86=92=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=20=EA=B3=B5=EA=B0=84=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prediction/algorithms/transshipment.py 신규: 그리드 공간인덱스 O(n log n) 환적 쌍 탐지 → 후보 필터(sog<2, tanker/cargo/fishing, 외국해안 제외) + 110m 근접 + 60분 지속 - prediction/scheduler.py: 8단계 환적탐지 사이클 추가, pair_history 영속화 - prediction/models/result.py: is_transship_suspect, transship_pair_mmsi, transship_duration_min - prediction/db/kcgdb.py: UPSERT 쿼리에 3개 컬럼 추가 - database/migration/008_transshipment.sql: ALTER TABLE 3개 컬럼 추가 - backend VesselAnalysisResult + VesselAnalysisDto: TransshipInfo 중첩 DTO 추가 - frontend types.ts: algorithms.transship 타입 추가 - frontend useKoreaFilters.ts: O(n²) 65줄 → analysisMap 소비 8줄 → currentTime 매초 의존성 제거, proximityStartRef 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../domain/analysis/VesselAnalysisDto.java | 15 ++ .../domain/analysis/VesselAnalysisResult.java | 11 + database/migration/008_transshipment.sql | 7 + frontend/src/hooks/useKoreaFilters.ts | 70 +----- frontend/src/types.ts | 1 + prediction/algorithms/transshipment.py | 234 ++++++++++++++++++ prediction/db/kcgdb.py | 7 +- prediction/models/result.py | 8 + prediction/scheduler.py | 19 +- 9 files changed, 307 insertions(+), 65 deletions(-) create mode 100644 database/migration/008_transshipment.sql create mode 100644 prediction/algorithms/transshipment.py diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java index a8d2e52..e6ce3cd 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java @@ -39,6 +39,7 @@ public class VesselAnalysisDto { private ClusterInfo cluster; private FleetRoleInfo fleetRole; private RiskScoreInfo riskScore; + private TransshipInfo transship; } @Getter @@ -99,6 +100,15 @@ public class VesselAnalysisDto { private String level; } + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class TransshipInfo { + private Boolean isSuspect; + private String pairMmsi; + private Integer durationMin; + } + public static VesselAnalysisDto from(VesselAnalysisResult r) { return VesselAnalysisDto.builder() .mmsi(r.getMmsi()) @@ -141,6 +151,11 @@ public class VesselAnalysisDto { .score(r.getRiskScore()) .level(r.getRiskLevel()) .build()) + .transship(TransshipInfo.builder() + .isSuspect(r.getIsTransshipSuspect()) + .pairMmsi(r.getTransshipPairMmsi()) + .durationMin(r.getTransshipDurationMin()) + .build()) .build()) .features(r.getFeatures()) .build(); diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java index 0ba31d5..4a4e926 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -76,6 +76,14 @@ public class VesselAnalysisResult { @Column(length = 20) private String riskLevel; + @Column(nullable = false) + private Boolean isTransshipSuspect; + + @Column(length = 15) + private String transshipPairMmsi; + + private Integer transshipDurationMin; + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private Map features; @@ -94,5 +102,8 @@ public class VesselAnalysisResult { if (isLeader == null) { isLeader = false; } + if (isTransshipSuspect == null) { + isTransshipSuspect = false; + } } } diff --git a/database/migration/008_transshipment.sql b/database/migration/008_transshipment.sql new file mode 100644 index 0000000..b82e508 --- /dev/null +++ b/database/migration/008_transshipment.sql @@ -0,0 +1,7 @@ +-- 008: 환적 의심 탐지 필드 추가 +SET search_path TO kcg, public; + +ALTER TABLE vessel_analysis_results + ADD COLUMN IF NOT EXISTS is_transship_suspect BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS transship_pair_mmsi VARCHAR(15) DEFAULT '', + ADD COLUMN IF NOT EXISTS transship_duration_min INTEGER DEFAULT 0; diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index b1f4d1e..5a7dcde 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -32,7 +32,6 @@ interface UseKoreaFiltersResult { anyFilterOn: boolean; } -const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간 const ONE_HOUR_MS = 60 * 60 * 1000; const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간 const DOKDO = { lat: 37.2417, lng: 131.8647 }; @@ -56,7 +55,6 @@ export function useKoreaFilters( }); const [dokdoAlerts, setDokdoAlerts] = useState([]); - const proximityStartRef = useRef>(new Map()); const aisHistoryRef = useRef>(new Map()); const cableNearStartRef = useRef>(new Map()); const dokdoAlertedRef = useRef>(new Set()); @@ -74,71 +72,17 @@ export function useKoreaFilters( filters.ferryWatch || cnFishingOn; - // 불법환적 의심 선박 탐지 + // 불법환적 의심 선박 탐지 (Python 분석 결과 소비) const transshipSuspects = useMemo(() => { if (!filters.illegalTransship) return new Set(); - - const suspects = new Set(); - const isOffshore = (s: Ship) => { - const nearCoastWest = s.lng > 125.5 && s.lng < 130.0 && s.lat > 33.5 && s.lat < 38.5; - if (nearCoastWest) { - const distFromEastCoast = s.lng - 129.5; - const distFromWestCoast = 126.0 - s.lng; - const distFromSouthCoast = 34.5 - s.lat; - if (distFromEastCoast > 0.15 || distFromWestCoast > 0.15 || distFromSouthCoast > 0.15) return true; - return false; - } - return true; - }; - - const isNearForeignCoast = (s: Ship) => { - if (s.lng < 123.5 && s.lat > 25 && s.lat < 40) return true; - if (s.lng > 130.5 && s.lat > 30 && s.lat < 46) return true; - if (s.lng > 129.1 && s.lng < 129.6 && s.lat > 34.0 && s.lat < 34.8) return true; - if (s.lng > 129.5 && s.lat > 31 && s.lat < 34) return true; - return false; - }; - - const candidates = koreaShips.filter(s => { - if (s.speed >= 2) return false; - const mtCat = s.mtCategory; - if (mtCat !== 'tanker' && mtCat !== 'cargo' && mtCat !== 'fishing') return false; - if (isNearForeignCoast(s)) return false; - return isOffshore(s); - }); - - const now = currentTime; - const prevMap = proximityStartRef.current; - const currentPairs = new Set(); - const PROXIMITY_DEG = 0.001; // ~110m - - for (let i = 0; i < candidates.length; i++) { - for (let j = i + 1; j < candidates.length; j++) { - const a = candidates[i]; - const b = candidates[j]; - const dlat = Math.abs(a.lat - b.lat); - const dlng = Math.abs(a.lng - b.lng) * Math.cos((a.lat * Math.PI) / 180); - if (dlat < PROXIMITY_DEG && dlng < PROXIMITY_DEG) { - const pairKey = [a.mmsi, b.mmsi].sort().join(':'); - currentPairs.add(pairKey); - if (!prevMap.has(pairKey)) { - prevMap.set(pairKey, now); - } - const pairStartTime = prevMap.get(pairKey)!; - if (now - pairStartTime >= TRANSSHIP_DURATION_MS) { - suspects.add(a.mmsi); - suspects.add(b.mmsi); - } - } + const result = new Set(); + if (analysisMap) { + for (const [mmsi, dto] of analysisMap) { + if (dto.algorithms.transship?.isSuspect) result.add(mmsi); } } - - for (const key of prevMap.keys()) { - if (!currentPairs.has(key)) prevMap.delete(key); - } - - return suspects; - }, [koreaShips, filters.illegalTransship, currentTime]); + return result; + }, [filters.illegalTransship, analysisMap]); // 다크베셀 탐지: AIS 신호 이력 추적 const darkVesselSet = useMemo(() => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 816e4aa..5752ef4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -184,6 +184,7 @@ export interface VesselAnalysisDto { cluster: { clusterId: number; clusterSize: number }; fleetRole: { isLeader: boolean; role: FleetRole }; riskScore: { score: number; level: RiskLevel }; + transship: { isSuspect: boolean; pairMmsi: string; durationMin: number }; }; features: Record; } diff --git a/prediction/algorithms/transshipment.py b/prediction/algorithms/transshipment.py new file mode 100644 index 0000000..9e26b95 --- /dev/null +++ b/prediction/algorithms/transshipment.py @@ -0,0 +1,234 @@ +"""환적(Transshipment) 의심 선박 탐지 — 서버사이드 O(n log n) 구현. + +프론트엔드 useKoreaFilters.ts의 O(n²) 근접 탐지를 대체한다. +scipy 미설치 환경을 고려하여 그리드 기반 공간 인덱스를 사용한다. + +알고리즘 개요: +1. 후보 선박 필터: sog < 2kn, 선종 (tanker/cargo/fishing), 외국 해안선 제외 +2. 그리드 셀 기반 근접 쌍 탐지: O(n log n) ← 셀 분할 + 인접 9셀 조회 +3. pair_history dict로 쌍별 최초 탐지 시각 영속화 (호출 간 유지) +4. 60분 이상 지속 근접 시 의심 쌍으로 판정 +""" + +from __future__ import annotations + +import logging +import math +from datetime import datetime, timezone +from typing import Optional + +import pandas as pd + +logger = logging.getLogger(__name__) + +# ────────────────────────────────────────────────────────────── +# 상수 +# ────────────────────────────────────────────────────────────── + +SOG_THRESHOLD_KN = 2.0 # 정박/표류 기준 속도 (노트) +PROXIMITY_DEG = 0.001 # 근접 판정 임계값 (~110m) +SUSPECT_DURATION_MIN = 60 # 의심 판정 최소 지속 시간 (분) +PAIR_EXPIRY_MIN = 120 # pair_history 항목 만료 기준 (분) + +# 외국 해안 근접 제외 경계 +_CN_LON_MAX = 123.5 # 중국 해안: 경도 < 123.5 +_JP_LON_MIN = 130.5 # 일본 해안: 경도 > 130.5 +_TSUSHIMA_LAT_MIN = 33.8 # 대마도: 위도 > 33.8 AND 경도 > 129.0 +_TSUSHIMA_LON_MIN = 129.0 + +# 탐지 대상 선종 (소문자 정규화 후 비교) +_CANDIDATE_TYPES: frozenset[str] = frozenset({'tanker', 'cargo', 'fishing'}) + +# 그리드 셀 크기 = PROXIMITY_DEG (셀 하나 = 근접 임계와 동일 크기) +_GRID_CELL_DEG = PROXIMITY_DEG + + +# ────────────────────────────────────────────────────────────── +# 내부 헬퍼 +# ────────────────────────────────────────────────────────────── + +def _is_near_foreign_coast(lat: float, lon: float) -> bool: + """외국 해안 근처 여부 — 중국/일본/대마도 경계 확인.""" + if lon < _CN_LON_MAX: + return True + if lon > _JP_LON_MIN: + return True + if lat > _TSUSHIMA_LAT_MIN and lon > _TSUSHIMA_LON_MIN: + return True + return False + + +def _cell_key(lat: float, lon: float) -> tuple[int, int]: + """위도/경도를 그리드 셀 인덱스로 변환.""" + return (int(math.floor(lat / _GRID_CELL_DEG)), + int(math.floor(lon / _GRID_CELL_DEG))) + + +def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]: + """선박 리스트를 그리드 셀로 분류. + + Returns: {(row, col): [record index, ...]} + """ + grid: dict[tuple[int, int], list[int]] = {} + for idx, rec in enumerate(records): + key = _cell_key(rec['lat'], rec['lon']) + if key not in grid: + grid[key] = [] + grid[key].append(idx) + return grid + + +def _within_proximity(a: dict, b: dict) -> bool: + """두 선박이 PROXIMITY_DEG 이내인지 확인 (위경도 직교 근사).""" + dlat = abs(a['lat'] - b['lat']) + if dlat >= PROXIMITY_DEG: + return False + cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0)) + dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat + return dlon_scaled < PROXIMITY_DEG + + +def _normalize_type(raw: Optional[str]) -> str: + """선종 문자열 소문자 정규화.""" + if not raw: + return '' + return raw.strip().lower() + + +def _pair_key(mmsi_a: str, mmsi_b: str) -> tuple[str, str]: + """MMSI 순서를 정규화하여 중복 쌍 방지.""" + return (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a) + + +def _evict_expired_pairs( + pair_history: dict[tuple[str, str], datetime], + now: datetime, +) -> None: + """PAIR_EXPIRY_MIN 이상 갱신 없는 pair_history 항목 제거.""" + expired = [ + key for key, first_seen in pair_history.items() + if (now - first_seen).total_seconds() / 60 > PAIR_EXPIRY_MIN + ] + for key in expired: + del pair_history[key] + + +# ────────────────────────────────────────────────────────────── +# 공개 API +# ────────────────────────────────────────────────────────────── + +def detect_transshipment( + df: pd.DataFrame, + pair_history: dict[tuple[str, str], datetime], +) -> list[tuple[str, str, int]]: + """환적 의심 쌍 탐지. + + Args: + df: 선박 위치 DataFrame. + 필수 컬럼: mmsi, lat, lon, sog + 선택 컬럼: ship_type (없으면 전체 선종 허용) + pair_history: 쌍별 최초 탐지 시각을 저장하는 영속 dict. + 스케줄러에서 호출 간 유지하여 전달해야 한다. + 키: (mmsi_a, mmsi_b) — mmsi_a < mmsi_b 정규화 적용. + 값: 최초 탐지 시각 (UTC datetime, timezone-aware). + + Returns: + [(mmsi_a, mmsi_b, duration_minutes), ...] — 60분 이상 지속된 의심 쌍. + mmsi_a < mmsi_b 정규화 적용. + """ + if df.empty: + return [] + + required_cols = {'mmsi', 'lat', 'lon', 'sog'} + missing = required_cols - set(df.columns) + if missing: + logger.error('detect_transshipment: missing required columns: %s', missing) + return [] + + now = datetime.now(timezone.utc) + + # ── 1. 후보 선박 필터 ────────────────────────────────────── + has_type_col = 'ship_type' in df.columns + + candidate_mask = df['sog'] < SOG_THRESHOLD_KN + + if has_type_col: + type_mask = df['ship_type'].apply(_normalize_type).isin(_CANDIDATE_TYPES) + candidate_mask = candidate_mask & type_mask + + candidates = df[candidate_mask].copy() + + if candidates.empty: + _evict_expired_pairs(pair_history, now) + return [] + + # 외국 해안 근처 제외 + coast_mask = candidates.apply( + lambda row: not _is_near_foreign_coast(row['lat'], row['lon']), + axis=1, + ) + candidates = candidates[coast_mask] + + if len(candidates) < 2: + _evict_expired_pairs(pair_history, now) + return [] + + records = candidates[['mmsi', 'lat', 'lon']].to_dict('records') + for rec in records: + rec['mmsi'] = str(rec['mmsi']) + + # ── 2. 그리드 기반 근접 쌍 탐지 ────────────────────────── + grid = _build_grid(records) + active_pairs: set[tuple[str, str]] = set() + + for (row, col), indices in grid.items(): + # 현재 셀 내부 쌍 + for i in range(len(indices)): + for j in range(i + 1, len(indices)): + a = records[indices[i]] + b = records[indices[j]] + if _within_proximity(a, b): + active_pairs.add(_pair_key(a['mmsi'], b['mmsi'])) + + # 인접 셀 (우측 3셀 + 아래 3셀 = 중복 없는 방향성 순회) + for dr, dc in ((0, 1), (1, -1), (1, 0), (1, 1)): + neighbor_key = (row + dr, col + dc) + if neighbor_key not in grid: + continue + for ai in indices: + for bi in grid[neighbor_key]: + a = records[ai] + b = records[bi] + if _within_proximity(a, b): + active_pairs.add(_pair_key(a['mmsi'], b['mmsi'])) + + # ── 3. pair_history 갱신 ───────────────────────────────── + # 현재 활성 쌍 → 최초 탐지 시각 등록 + for pair in active_pairs: + if pair not in pair_history: + pair_history[pair] = now + + # 비활성 쌍 → pair_history에서 제거 (다음 접근 시 재시작) + inactive = [key for key in pair_history if key not in active_pairs] + for key in inactive: + del pair_history[key] + + # 만료 항목 정리 (비활성 제거 후 잔여 방어용) + _evict_expired_pairs(pair_history, now) + + # ── 4. 의심 쌍 판정 ────────────────────────────────────── + suspects: list[tuple[str, str, int]] = [] + + for pair, first_seen in pair_history.items(): + duration_min = int((now - first_seen).total_seconds() / 60) + if duration_min >= SUSPECT_DURATION_MIN: + suspects.append((pair[0], pair[1], duration_min)) + + if suspects: + logger.info( + 'transshipment detection: %d suspect pairs (candidates=%d)', + len(suspects), + len(candidates), + ) + + return suspects diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 1014d3d..12a362b 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -74,7 +74,9 @@ def upsert_results(results: list['AnalysisResult']) -> int: ucaf_score, ucft_score, is_dark, gap_duration_min, spoofing_score, bd09_offset_m, speed_jump_count, cluster_size, is_leader, fleet_role, - risk_score, risk_level, features, analyzed_at + risk_score, risk_level, + is_transship_suspect, transship_pair_mmsi, transship_duration_min, + features, analyzed_at ) VALUES %s ON CONFLICT (mmsi, timestamp) DO UPDATE SET vessel_type = EXCLUDED.vessel_type, @@ -97,6 +99,9 @@ def upsert_results(results: list['AnalysisResult']) -> int: fleet_role = EXCLUDED.fleet_role, risk_score = EXCLUDED.risk_score, risk_level = EXCLUDED.risk_level, + is_transship_suspect = EXCLUDED.is_transship_suspect, + transship_pair_mmsi = EXCLUDED.transship_pair_mmsi, + transship_duration_min = EXCLUDED.transship_duration_min, features = EXCLUDED.features, analyzed_at = EXCLUDED.analyzed_at """ diff --git a/prediction/models/result.py b/prediction/models/result.py index e1680a5..3ef41a1 100644 --- a/prediction/models/result.py +++ b/prediction/models/result.py @@ -44,6 +44,11 @@ class AnalysisResult: risk_score: int = 0 risk_level: str = 'LOW' + # ALGO 08: 환적 의심 + is_transship_suspect: bool = False + transship_pair_mmsi: str = '' + transship_duration_min: int = 0 + # 특징 벡터 features: dict = field(default_factory=dict) @@ -91,6 +96,9 @@ class AnalysisResult: str(self.fleet_role), _i(self.risk_score), str(self.risk_level), + bool(self.is_transship_suspect), + str(self.transship_pair_mmsi), + _i(self.transship_duration_min), json.dumps(safe_features), self.analyzed_at, ) diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 9d889d3..ff48c3b 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -18,6 +18,8 @@ _last_run: dict = { 'error': None, } +_transship_pair_history: dict = {} + def get_last_run() -> dict: return _last_run.copy() @@ -158,7 +160,22 @@ def run_analysis_cycle(): features=c.get('features', {}), )) - # 6. 결과 저장 + # 6. 환적 의심 탐지 (pair_history 모듈 레벨로 사이클 간 유지) + from algorithms.transshipment import detect_transshipment + + results_map = {r.mmsi: r for r in results} + transship_pairs = detect_transshipment(df_targets, _transship_pair_history) + for mmsi_a, mmsi_b, dur in transship_pairs: + if mmsi_a in results_map: + results_map[mmsi_a].is_transship_suspect = True + results_map[mmsi_a].transship_pair_mmsi = mmsi_b + results_map[mmsi_a].transship_duration_min = dur + if mmsi_b in results_map: + results_map[mmsi_b].is_transship_suspect = True + results_map[mmsi_b].transship_pair_mmsi = mmsi_a + results_map[mmsi_b].transship_duration_min = dur + + # 7. 결과 저장 upserted = kcgdb.upsert_results(results) kcgdb.cleanup_old(hours=48)