diff --git a/prediction/algorithms/risk.py b/prediction/algorithms/risk.py index 4f2ef32..61cfd32 100644 --- a/prediction/algorithms/risk.py +++ b/prediction/algorithms/risk.py @@ -14,42 +14,71 @@ def compute_lightweight_risk_score( is_dark: bool = False, gap_duration_min: int = 0, spoofing_score: float = 0.0, + dark_suspicion_score: int = 0, + dist_from_baseline_nm: float = 999.0, + dark_history_24h: int = 0, ) -> Tuple[int, str]: """위치·허가·다크/스푸핑 기반 경량 위험도 (파이프라인 미통과 선박용). - pipeline path의 compute_vessel_risk_score와 동일한 임계값(70/50/30)을 사용해 - 분류 결과의 일관성을 유지한다. dark/spoofing 신호를 추가하여 max 100점 도달 가능. + compute_dark_suspicion 의 패턴 기반 0~100 점수를 직접 반영해 해상도를 높인다. + 이중계산 방지: dark_suspicion_score 는 이미 무허가/반복을 포함하므로 dark_suspicion_score > 0 + 인 경우 허가/반복 가산을 축소한다. + + 임계값 70/50/30 은 pipeline path(compute_vessel_risk_score)와 동일. Returns: (risk_score, risk_level) """ score = 0 - # 1. 위치 기반 (최대 40점) + # 1. 위치 기반 (최대 40점) — EEZ 외 기선 근접도 추가 zone = zone_info.get('zone', '') if zone == 'TERRITORIAL_SEA': score += 40 elif zone == 'CONTIGUOUS_ZONE': - score += 10 + score += 15 elif zone.startswith('ZONE_'): if is_permitted is not None and not is_permitted: score += 25 + elif zone == 'EEZ_OR_BEYOND': + # EEZ 외라도 기선 근접 시 가산 (공해·외해 분산) + if dist_from_baseline_nm < 12: + score += 15 + elif dist_from_baseline_nm < 24: + score += 8 - # 2. 다크 베셀 (최대 25점) + # 2. 다크 베셀 (최대 30점) — dark_suspicion_score 우선 if is_dark: - if gap_duration_min >= 60: - score += 25 - elif gap_duration_min >= 30: - score += 10 + if dark_suspicion_score >= 1: + # compute_dark_suspicion 이 산출한 패턴 기반 의심도 반영 + score += min(30, round(dark_suspicion_score * 0.3)) + else: + # fallback: gap 길이만 기준 + if gap_duration_min >= 720: + score += 25 + elif gap_duration_min >= 180: + score += 20 + elif gap_duration_min >= 60: + score += 15 + elif gap_duration_min >= 30: + score += 8 - # 3. 스푸핑 (최대 15점) + # 3. 스푸핑 (최대 15점) — 현재 중국 선박은 거의 0 (별도 PR 에서 산출 로직 재설계 예정) if spoofing_score > 0.7: score += 15 elif spoofing_score > 0.5: score += 8 - # 4. 허가 이력 (최대 20점) + # 4. 허가 이력 (최대 15점) — 이중계산 방지 if is_permitted is not None and not is_permitted: - score += 20 + # dark_suspicion_score 에 이미 무허가 +10 반영됨 → 축소 + score += 8 if dark_suspicion_score > 0 else 15 + + # 5. 반복 이력 (최대 10점) — dark_suspicion_score 미반영 케이스만 + if dark_suspicion_score == 0 and dark_history_24h > 0: + if dark_history_24h >= 5: + score += 10 + elif dark_history_24h >= 2: + score += 5 score = min(score, 100) diff --git a/prediction/algorithms/vessel_type_mapping.py b/prediction/algorithms/vessel_type_mapping.py new file mode 100644 index 0000000..0ca4d63 --- /dev/null +++ b/prediction/algorithms/vessel_type_mapping.py @@ -0,0 +1,27 @@ +"""한중어업협정 fishery_code → VesselType 매핑. + +파이프라인 미통과 선박(경량 분석 경로)은 AIS 샘플 부족으로 분류기가 UNKNOWN 을 반환한다. +등록선은 fishery_code 가 이미 확정이므로 이를 활용해 vessel_type 을 채운다. + +VesselType 값 확장: + 기존: TRAWL / PURSE / LONGLINE / TRAP / UNKNOWN + 신규: GILLNET (유자망) / CARGO (운반선) +""" +from typing import Optional + + +FISHERY_CODE_TO_VESSEL_TYPE = { + 'PT': 'TRAWL', # 쌍끌이 저인망 + 'PT-S': 'TRAWL', # 쌍끌이 부속선 + 'OT': 'TRAWL', # 단선 저인망 + 'GN': 'GILLNET', # 유자망 + 'PS': 'PURSE', # 대형선망/위망 + 'FC': 'CARGO', # 운반선 (조업 아님) +} + + +def fishery_code_to_vessel_type(fishery_code: Optional[str]) -> str: + """등록 어업 코드 → 선박 유형. 매칭 없으면 'UNKNOWN'.""" + if not fishery_code: + return 'UNKNOWN' + return FISHERY_CODE_TO_VESSEL_TYPE.get(fishery_code.upper(), 'UNKNOWN') diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 91a786b..8661d02 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -507,6 +507,7 @@ def run_analysis_cycle(): # ── 5.5 경량 분석 — 파이프라인 미통과 412* 선박 ── # vessel_store._tracks의 24h 누적 궤적을 직접 활용하여 dark/spoof 신호도 산출. from algorithms.risk import compute_lightweight_risk_score + from algorithms.vessel_type_mapping import fishery_code_to_vessel_type pipeline_mmsis = {c['mmsi'] for c in classifications} lightweight_mmsis = vessel_store.get_chinese_mmsis() - pipeline_mmsis @@ -607,17 +608,29 @@ def run_analysis_cycle(): if spoof_score > 0.5: lw_spoof += 1 + # dark_features 에 저장된 패턴 기반 점수 + 반복 이력을 리스크 산출에 직접 연결 + # (경량 경로가 45점 포화되던 원인 해소) risk_score, risk_level = compute_lightweight_risk_score( zone_info, sog, is_permitted=is_permitted, is_dark=dark, gap_duration_min=gap_min, spoofing_score=spoof_score, + dark_suspicion_score=int(dark_features.get('dark_suspicion_score', 0) or 0), + dist_from_baseline_nm=float(zone_info.get('dist_from_baseline_nm', 999.0) or 999.0), + dark_history_24h=int(dark_features.get('dark_history_24h', 0) or 0), ) + # 등록선은 fishery_code 로 vessel_type 채움 (미등록선은 UNKNOWN 유지) + registered_fc = ( + fleet_tracker.get_registered_fishery_code(mmsi) + if hasattr(fleet_tracker, 'get_registered_fishery_code') else None + ) + vessel_type = fishery_code_to_vessel_type(registered_fc) + # BD-09 오프셋은 중국 선박이므로 제외 (412* = 중국) results.append(AnalysisResult( mmsi=mmsi, timestamp=ts, - vessel_type='UNKNOWN', + vessel_type=vessel_type, confidence=0.0, fishing_pct=0.0, lat=float(lat) if lat is not None else None,