kcg-monitoring/prediction/algorithms/fishing_pattern.py
htlee 83b3d80c6d feat: Python 어선 분류기 + 배포 설정 + 백엔드 모니터링 프록시
- prediction/: FastAPI 7단계 분류 파이프라인 + 6개 탐지 알고리즘
  - snpdb 궤적 조회 → 인메모리 캐시(13K척) → 분류 → kcgdb 저장
  - APScheduler 5분 주기, Python 3.9 호환
  - 버그 수정: @property last_bucket, SQL INTERVAL 바인딩, rollback, None 가드
  - 보안: DB 비밀번호 하드코딩 제거 → env 환경변수 필수
- deploy/kcg-prediction.service: systemd 서비스 (redis-211, 포트 8001)
- deploy.yml: prediction CI/CD 배포 단계 추가 (192.168.1.18:32023)
- backend: PredictionProxyController (health/status/trigger 프록시)
- backend: AppProperties predictionBaseUrl + AuthFilter 인증 예외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:07:40 +09:00

118 lines
4.1 KiB
Python

from __future__ import annotations
import pandas as pd
from algorithms.location import haversine_nm, classify_zone # noqa: F401 (haversine_nm re-exported for callers)
# Yan et al. (2022) 어구별 조업 속도 임계값
GEAR_SOG_THRESHOLDS: dict[str, tuple[float, float]] = {
'PT': (2.5, 4.5), # 쌍끌이저인망
'OT': (2.0, 4.0), # 단선저인망
'GN': (0.5, 2.5), # 자망·유망
'SQ': (0.0, 1.0), # 오징어채낚기
'TRAP': (0.3, 1.5), # 통발
'PS': (3.0, 6.0), # 선망
'TRAWL': (2.0, 4.5), # (alias)
'PURSE': (3.0, 6.0), # (alias)
'LONGLINE': (0.5, 2.5),
}
TRANSIT_SOG_MIN = 5.0
ANCHORED_SOG_MAX = 0.5
def classify_vessel_state(sog: float, cog_delta: float = 0.0,
gear_type: str = 'PT') -> str:
"""UCAF: 어구별 상태 분류."""
if sog <= ANCHORED_SOG_MAX:
return 'ANCHORED'
if sog >= TRANSIT_SOG_MIN:
return 'TRANSIT'
sog_min, sog_max = GEAR_SOG_THRESHOLDS.get(gear_type, (1.0, 5.0))
if sog_min <= sog <= sog_max:
return 'FISHING'
return 'UNKNOWN'
def compute_ucaf_score(df_vessel: pd.DataFrame, gear_type: str = 'PT') -> float:
"""UCAF 점수: 어구별 조업 상태 비율 (0~1)."""
if len(df_vessel) == 0:
return 0.0
sog_min, sog_max = GEAR_SOG_THRESHOLDS.get(gear_type, (1.0, 5.0))
in_range = df_vessel['sog'].between(sog_min, sog_max).sum()
return round(in_range / len(df_vessel), 4)
def compute_ucft_score(df_vessel: pd.DataFrame) -> float:
"""UCFT 점수: 조업 vs 항행 이진 신뢰도 (0~1)."""
if len(df_vessel) == 0:
return 0.0
fishing = (df_vessel['sog'].between(0.5, 5.0)).sum()
transit = (df_vessel['sog'] >= TRANSIT_SOG_MIN).sum()
total = fishing + transit
if total == 0:
return 0.0
return round(fishing / total, 4)
def detect_fishing_segments(df_vessel: pd.DataFrame,
window_min: int = 15,
gear_type: str = 'PT') -> list[dict]:
"""연속 조업 구간 추출."""
if len(df_vessel) < 2:
return []
segments: list[dict] = []
in_fishing = False
seg_start_idx = 0
records = df_vessel.to_dict('records')
for i, rec in enumerate(records):
sog = rec.get('sog', 0)
state = classify_vessel_state(sog, gear_type=gear_type)
if state == 'FISHING' and not in_fishing:
in_fishing = True
seg_start_idx = i
elif state != 'FISHING' and in_fishing:
start_ts = records[seg_start_idx].get('timestamp')
end_ts = rec.get('timestamp')
if start_ts and end_ts:
dur_sec = (pd.Timestamp(end_ts) - pd.Timestamp(start_ts)).total_seconds()
dur_min = dur_sec / 60
if dur_min >= window_min:
zone_info = classify_zone(
records[seg_start_idx].get('lat', 0),
records[seg_start_idx].get('lon', 0),
)
segments.append({
'start_idx': seg_start_idx,
'end_idx': i - 1,
'duration_min': round(dur_min, 1),
'zone': zone_info.get('zone', 'UNKNOWN'),
'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA',
})
in_fishing = False
return segments
def detect_trawl_uturn(df_vessel: pd.DataFrame,
uturn_threshold_deg: float = 150.0,
min_uturn_count: int = 3) -> dict:
"""U-turn 왕복 패턴 감지 (저인망 특징)."""
if len(df_vessel) < 2:
return {'uturn_count': 0, 'trawl_suspected': False}
uturn_count = 0
cog_vals = df_vessel['cog'].values
sog_vals = df_vessel['sog'].values
for i in range(1, len(cog_vals)):
delta = abs((cog_vals[i] - cog_vals[i - 1] + 180) % 360 - 180)
if delta >= uturn_threshold_deg and sog_vals[i] < TRANSIT_SOG_MIN:
uturn_count += 1
return {
'uturn_count': uturn_count,
'trawl_suspected': uturn_count >= min_uturn_count,
}