feat: 환적탐지 Python 이관 — O(n²) 프론트엔드 근접탐지 → 서버사이드 공간인덱스
- 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) <noreply@anthropic.com>
This commit is contained in:
부모
13427f32bb
커밋
d9ba1b0e1a
@ -39,6 +39,7 @@ public class VesselAnalysisDto {
|
|||||||
private ClusterInfo cluster;
|
private ClusterInfo cluster;
|
||||||
private FleetRoleInfo fleetRole;
|
private FleetRoleInfo fleetRole;
|
||||||
private RiskScoreInfo riskScore;
|
private RiskScoreInfo riskScore;
|
||||||
|
private TransshipInfo transship;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@ -99,6 +100,15 @@ public class VesselAnalysisDto {
|
|||||||
private String level;
|
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) {
|
public static VesselAnalysisDto from(VesselAnalysisResult r) {
|
||||||
return VesselAnalysisDto.builder()
|
return VesselAnalysisDto.builder()
|
||||||
.mmsi(r.getMmsi())
|
.mmsi(r.getMmsi())
|
||||||
@ -141,6 +151,11 @@ public class VesselAnalysisDto {
|
|||||||
.score(r.getRiskScore())
|
.score(r.getRiskScore())
|
||||||
.level(r.getRiskLevel())
|
.level(r.getRiskLevel())
|
||||||
.build())
|
.build())
|
||||||
|
.transship(TransshipInfo.builder()
|
||||||
|
.isSuspect(r.getIsTransshipSuspect())
|
||||||
|
.pairMmsi(r.getTransshipPairMmsi())
|
||||||
|
.durationMin(r.getTransshipDurationMin())
|
||||||
|
.build())
|
||||||
.build())
|
.build())
|
||||||
.features(r.getFeatures())
|
.features(r.getFeatures())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@ -76,6 +76,14 @@ public class VesselAnalysisResult {
|
|||||||
@Column(length = 20)
|
@Column(length = 20)
|
||||||
private String riskLevel;
|
private String riskLevel;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean isTransshipSuspect;
|
||||||
|
|
||||||
|
@Column(length = 15)
|
||||||
|
private String transshipPairMmsi;
|
||||||
|
|
||||||
|
private Integer transshipDurationMin;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(columnDefinition = "jsonb")
|
@Column(columnDefinition = "jsonb")
|
||||||
private Map<String, Double> features;
|
private Map<String, Double> features;
|
||||||
@ -94,5 +102,8 @@ public class VesselAnalysisResult {
|
|||||||
if (isLeader == null) {
|
if (isLeader == null) {
|
||||||
isLeader = false;
|
isLeader = false;
|
||||||
}
|
}
|
||||||
|
if (isTransshipSuspect == null) {
|
||||||
|
isTransshipSuspect = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
database/migration/008_transshipment.sql
Normal file
7
database/migration/008_transshipment.sql
Normal file
@ -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;
|
||||||
@ -32,7 +32,6 @@ interface UseKoreaFiltersResult {
|
|||||||
anyFilterOn: boolean;
|
anyFilterOn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간
|
|
||||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||||
const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간
|
const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간
|
||||||
const DOKDO = { lat: 37.2417, lng: 131.8647 };
|
const DOKDO = { lat: 37.2417, lng: 131.8647 };
|
||||||
@ -56,7 +55,6 @@ export function useKoreaFilters(
|
|||||||
});
|
});
|
||||||
const [dokdoAlerts, setDokdoAlerts] = useState<DokdoAlert[]>([]);
|
const [dokdoAlerts, setDokdoAlerts] = useState<DokdoAlert[]>([]);
|
||||||
|
|
||||||
const proximityStartRef = useRef<Map<string, number>>(new Map());
|
|
||||||
const aisHistoryRef = useRef<Map<string, { seen: number[]; lastGapStart: number | null }>>(new Map());
|
const aisHistoryRef = useRef<Map<string, { seen: number[]; lastGapStart: number | null }>>(new Map());
|
||||||
const cableNearStartRef = useRef<Map<string, number>>(new Map());
|
const cableNearStartRef = useRef<Map<string, number>>(new Map());
|
||||||
const dokdoAlertedRef = useRef<Set<string>>(new Set());
|
const dokdoAlertedRef = useRef<Set<string>>(new Set());
|
||||||
@ -74,71 +72,17 @@ export function useKoreaFilters(
|
|||||||
filters.ferryWatch ||
|
filters.ferryWatch ||
|
||||||
cnFishingOn;
|
cnFishingOn;
|
||||||
|
|
||||||
// 불법환적 의심 선박 탐지
|
// 불법환적 의심 선박 탐지 (Python 분석 결과 소비)
|
||||||
const transshipSuspects = useMemo(() => {
|
const transshipSuspects = useMemo(() => {
|
||||||
if (!filters.illegalTransship) return new Set<string>();
|
if (!filters.illegalTransship) return new Set<string>();
|
||||||
|
const result = new Set<string>();
|
||||||
const suspects = new Set<string>();
|
if (analysisMap) {
|
||||||
const isOffshore = (s: Ship) => {
|
for (const [mmsi, dto] of analysisMap) {
|
||||||
const nearCoastWest = s.lng > 125.5 && s.lng < 130.0 && s.lat > 33.5 && s.lat < 38.5;
|
if (dto.algorithms.transship?.isSuspect) result.add(mmsi);
|
||||||
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<string>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
for (const key of prevMap.keys()) {
|
}, [filters.illegalTransship, analysisMap]);
|
||||||
if (!currentPairs.has(key)) prevMap.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return suspects;
|
|
||||||
}, [koreaShips, filters.illegalTransship, currentTime]);
|
|
||||||
|
|
||||||
// 다크베셀 탐지: AIS 신호 이력 추적
|
// 다크베셀 탐지: AIS 신호 이력 추적
|
||||||
const darkVesselSet = useMemo(() => {
|
const darkVesselSet = useMemo(() => {
|
||||||
|
|||||||
@ -184,6 +184,7 @@ export interface VesselAnalysisDto {
|
|||||||
cluster: { clusterId: number; clusterSize: number };
|
cluster: { clusterId: number; clusterSize: number };
|
||||||
fleetRole: { isLeader: boolean; role: FleetRole };
|
fleetRole: { isLeader: boolean; role: FleetRole };
|
||||||
riskScore: { score: number; level: RiskLevel };
|
riskScore: { score: number; level: RiskLevel };
|
||||||
|
transship: { isSuspect: boolean; pairMmsi: string; durationMin: number };
|
||||||
};
|
};
|
||||||
features: Record<string, number>;
|
features: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|||||||
234
prediction/algorithms/transshipment.py
Normal file
234
prediction/algorithms/transshipment.py
Normal file
@ -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
|
||||||
@ -74,7 +74,9 @@ def upsert_results(results: list['AnalysisResult']) -> int:
|
|||||||
ucaf_score, ucft_score, is_dark, gap_duration_min,
|
ucaf_score, ucft_score, is_dark, gap_duration_min,
|
||||||
spoofing_score, bd09_offset_m, speed_jump_count,
|
spoofing_score, bd09_offset_m, speed_jump_count,
|
||||||
cluster_size, is_leader, fleet_role,
|
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
|
) VALUES %s
|
||||||
ON CONFLICT (mmsi, timestamp) DO UPDATE SET
|
ON CONFLICT (mmsi, timestamp) DO UPDATE SET
|
||||||
vessel_type = EXCLUDED.vessel_type,
|
vessel_type = EXCLUDED.vessel_type,
|
||||||
@ -97,6 +99,9 @@ def upsert_results(results: list['AnalysisResult']) -> int:
|
|||||||
fleet_role = EXCLUDED.fleet_role,
|
fleet_role = EXCLUDED.fleet_role,
|
||||||
risk_score = EXCLUDED.risk_score,
|
risk_score = EXCLUDED.risk_score,
|
||||||
risk_level = EXCLUDED.risk_level,
|
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,
|
features = EXCLUDED.features,
|
||||||
analyzed_at = EXCLUDED.analyzed_at
|
analyzed_at = EXCLUDED.analyzed_at
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -44,6 +44,11 @@ class AnalysisResult:
|
|||||||
risk_score: int = 0
|
risk_score: int = 0
|
||||||
risk_level: str = 'LOW'
|
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)
|
features: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@ -91,6 +96,9 @@ class AnalysisResult:
|
|||||||
str(self.fleet_role),
|
str(self.fleet_role),
|
||||||
_i(self.risk_score),
|
_i(self.risk_score),
|
||||||
str(self.risk_level),
|
str(self.risk_level),
|
||||||
|
bool(self.is_transship_suspect),
|
||||||
|
str(self.transship_pair_mmsi),
|
||||||
|
_i(self.transship_duration_min),
|
||||||
json.dumps(safe_features),
|
json.dumps(safe_features),
|
||||||
self.analyzed_at,
|
self.analyzed_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,6 +18,8 @@ _last_run: dict = {
|
|||||||
'error': None,
|
'error': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_transship_pair_history: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def get_last_run() -> dict:
|
def get_last_run() -> dict:
|
||||||
return _last_run.copy()
|
return _last_run.copy()
|
||||||
@ -158,7 +160,22 @@ def run_analysis_cycle():
|
|||||||
features=c.get('features', {}),
|
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)
|
upserted = kcgdb.upsert_results(results)
|
||||||
kcgdb.cleanup_old(hours=48)
|
kcgdb.cleanup_old(hours=48)
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user