diff --git a/prediction/algorithms/gear_violation.py b/prediction/algorithms/gear_violation.py index 623e60f..56d6705 100644 --- a/prediction/algorithms/gear_violation.py +++ b/prediction/algorithms/gear_violation.py @@ -2,14 +2,15 @@ 어구 위반 G코드 분류 프레임워크 (DAR-03) G-01: 허가수역 외 조업 (zone-gear mismatch) +G-02: 금어기 조업 (fishing outside permit period) +G-03: 미등록/허가외 어구 (detected gear ≠ registered fishery_code) G-04: MMSI 조작 의심 (gear signal on/off cycling) G-05: 어구 인위적 이동 (fixed gear drift > threshold) G-06: 쌍끌이 공조 조업 (pair trawl — from pair_trawl.py) - -G-02 (금어기), G-03 (미등록 어구)은 외부 데이터 필요하여 보류. """ import math import logging +from datetime import datetime from typing import Optional import pandas as pd @@ -18,10 +19,27 @@ logger = logging.getLogger(__name__) # G-code score weights G01_SCORE = 15 # 비허가 수역 조업 +G02_SCORE = 18 # 금어기 조업 +G03_SCORE = 12 # 미등록/허가외 어구 G04_SCORE = 10 # MMSI 조작 의심 G05_SCORE = 5 # 고정어구 인위적 이동 G06_SCORE = 20 # 쌍끌이 공조 탐지 +# G-03: 허가 업종코드 → 허용 어구 유형 매핑 (fishery_permit_cn.fishery_code 기준) +# PT = 2척식저인망(쌍끌이 본선), PT-S = 부속선 → trawl/pair_trawl +# GN = 자망 → gillnet +# PS = 선망(둘러치기) → purse_seine +# OT = 외끌이저인망 → trawl +# FC = 운반선 → 조업 금지 +FISHERY_CODE_ALLOWED_GEAR: dict[str, set[str]] = { + 'PT': {'PT', 'TRAWL', 'PT-S'}, + 'PT-S': {'PT', 'TRAWL', 'PT-S'}, + 'GN': {'GN', 'GNS', 'GND', 'GILLNET'}, + 'PS': {'PS', 'PURSE'}, + 'OT': {'OT', 'TRAWL'}, + 'FC': set(), +} + # G-04 thresholds SIGNAL_CYCLING_GAP_MIN = 30 # minutes SIGNAL_CYCLING_MIN_COUNT = 2 @@ -131,6 +149,41 @@ def _detect_gear_drift( } +def _is_in_closed_season( + ts: Optional[datetime], + permit_periods: Optional[list[tuple[datetime, datetime]]], +) -> bool: + """허가 조업 기간 밖이면 금어기 조업 (G-02) 으로 판정. + + permit_periods 가 비어 있으면 데이터 부재로 판정 불가 → False. + ts 가 None 이면 판정 불가 → False. + """ + if not permit_periods or ts is None: + return False + try: + ts_naive = ts.replace(tzinfo=None) if ts.tzinfo is not None else ts + except AttributeError: + return False + return not any(start <= ts_naive <= end for start, end in permit_periods) + + +def _is_unregistered_gear( + detected_gear: Optional[str], + registered_fishery_code: Optional[str], +) -> bool: + """감지된 어구가 허가 업종코드의 허용 어구에 포함되지 않으면 G-03. + + 둘 중 하나라도 없으면 판정 불가 → False (데이터 부족). + """ + if not detected_gear or not registered_fishery_code: + return False + detected_norm = detected_gear.upper().strip() + allowed = FISHERY_CODE_ALLOWED_GEAR.get(registered_fishery_code.upper().strip()) + if allowed is None: + return False # 미지의 업종코드 — 판정 보류 + return detected_norm not in allowed + + def classify_gear_violations( mmsi: str, gear_type: str, @@ -140,6 +193,9 @@ def classify_gear_violations( is_permitted: bool, gear_episodes: Optional[list[dict]] = None, gear_positions: Optional[list[tuple[float, float]]] = None, + permit_periods: Optional[list[tuple[datetime, datetime]]] = None, + registered_fishery_code: Optional[str] = None, + observation_ts: Optional[datetime] = None, ) -> dict: """어구 위반 G코드 분류 메인 함수 (DAR-03). @@ -197,6 +253,52 @@ def classify_gear_violations( mmsi, zone, gear_type, allowed_gears, ) + # ── G-02: 금어기 조업 ──────────────────────────────────────── + if permit_periods: + try: + in_closed = _is_in_closed_season(observation_ts, permit_periods) + except Exception as exc: + logger.error('G-02 평가 실패 [mmsi=%s]: %s', mmsi, exc) + in_closed = False + if in_closed: + g_codes.append('G-02') + score += G02_SCORE + evidence['G-02'] = { + 'observed_at': observation_ts.isoformat() if observation_ts else None, + 'permit_periods': [ + [s.isoformat(), e.isoformat()] for s, e in permit_periods + ], + } + if not judgment: + judgment = 'CLOSED_SEASON_FISHING' + logger.debug('G-02 탐지 [mmsi=%s] ts=%s', mmsi, observation_ts) + + # ── G-03: 미등록/허가외 어구 ────────────────────────────────── + if registered_fishery_code: + try: + unregistered = _is_unregistered_gear(gear_type, registered_fishery_code) + except Exception as exc: + logger.error('G-03 평가 실패 [mmsi=%s]: %s', mmsi, exc) + unregistered = False + if unregistered: + g_codes.append('G-03') + score += G03_SCORE + evidence['G-03'] = { + 'detected_gear': gear_type, + 'registered_fishery_code': registered_fishery_code, + 'allowed_gears': sorted( + FISHERY_CODE_ALLOWED_GEAR.get( + registered_fishery_code.upper().strip(), set() + ) + ), + } + if not judgment: + judgment = 'UNREGISTERED_GEAR' + logger.debug( + 'G-03 탐지 [mmsi=%s] detected=%s registered=%s', + mmsi, gear_type, registered_fishery_code, + ) + # ── G-04: MMSI 조작 의심 (고정어구 신호 on/off 반복) ─────────── if gear_episodes is not None and gear_type in FIXED_GEAR_TYPES: try: diff --git a/prediction/fleet_tracker.py b/prediction/fleet_tracker.py index a81d3e9..7efed02 100644 --- a/prediction/fleet_tracker.py +++ b/prediction/fleet_tracker.py @@ -35,6 +35,32 @@ _NAME_STRIP_SUFFIX = re.compile( _NAME_STRIP_CHARS = re.compile(r'[\s\-_./,()\[\]·•\u3000]+') +_PERIOD_RANGE_PATTERN = re.compile( + r'(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})\s*[-~~]\s*(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})' +) + + +def _parse_period_range(raw: str) -> Optional[tuple[datetime, datetime]]: + """fishing_period 'YYYY/MM/DD - YYYY/MM/DD' 포맷을 파싱. + + 실패 시 None. 구분자는 / - . 모두 허용, 연결자는 - ~ ~ 허용. + """ + if not raw: + return None + m = _PERIOD_RANGE_PATTERN.search(raw) + if not m: + return None + try: + y1, m1, d1, y2, m2, d2 = (int(x) for x in m.groups()) + start = datetime(y1, m1, d1, 0, 0, 0) + end = datetime(y2, m2, d2, 23, 59, 59) + if end < start: + return None + return (start, end) + except (ValueError, TypeError): + return None + + def _normalize_vessel_name(name: Optional[str]) -> str: """선박명을 매칭용으로 정규화. @@ -73,7 +99,7 @@ class FleetTracker: # 현재 연도 허가만 조회 (연단위 갱신 정책, permit_year NULL은 legacy 허용) cur.execute( f"""SELECT id, company_id, permit_no, name_cn, name_en, tonnage, - gear_code, fleet_role, pair_vessel_id, mmsi + gear_code, fleet_role, pair_vessel_id, mmsi, fishery_code FROM {FLEET_VESSELS} WHERE permit_year = EXTRACT(YEAR FROM now())::int OR permit_year IS NULL""" @@ -97,6 +123,7 @@ class FleetTracker: 'fleet_role': r[7], 'pair_vessel_id': r[8], 'mmsi': r[9], + 'fishery_code': r[10], } self._vessels[vid] = v if r[3]: @@ -175,6 +202,61 @@ class FleetTracker: logger.warning('get_gear_episodes 실패 [mmsi=%s]: %s', mmsi, exc) return [] + def get_registered_fishery_code(self, mmsi: str) -> Optional[str]: + """fleet_vessels 에 등록된 선박의 fishery_code (PT/PT-S/GN/PS/OT/FC). + + V029 이후 fishery_code 컬럼을 우선 참조. legacy gear_code(C21 등) 는 별도 경로. + """ + vid = self._mmsi_to_vid.get(mmsi) + if vid is None: + return None + v = self._vessels.get(vid, {}) + return v.get('fishery_code') or None + + def get_permit_periods( + self, mmsi: str, conn, year: Optional[int] = None, + ) -> list[tuple[datetime, datetime]]: + """선박의 허가 조업 기간을 파싱하여 [(start, end), ...] 반환. + + fishery_permit_cn.fishing_period_1/2 의 'YYYY/MM/DD - YYYY/MM/DD' 포맷을 파싱. + '-' (미사용) 또는 파싱 실패 시 해당 구간 생략. G-02 금어기 판정에 사용. + """ + vid = self._mmsi_to_vid.get(mmsi) + if vid is None: + return [] + v = self._vessels.get(vid) + if not v: + return [] + permit_no = v.get('permit_no') + target_year = year or datetime.now().year + if not permit_no: + return [] + + try: + cur = conn.cursor() + cur.execute( + """SELECT fishing_period_1, fishing_period_2 + FROM kcg.fishery_permit_cn + WHERE permit_year = %s AND permit_no = %s""", + (target_year, permit_no), + ) + row = cur.fetchone() + cur.close() + except Exception as exc: + logger.warning('get_permit_periods DB 실패 [mmsi=%s]: %s', mmsi, exc) + return [] + if not row: + return [] + + periods: list[tuple[datetime, datetime]] = [] + for raw in row: + if not raw or raw.strip() in ('-', ''): + continue + parsed = _parse_period_range(raw) + if parsed: + periods.append(parsed) + return periods + def get_gear_positions( self, mmsi: str, df_vessel: Optional[pd.DataFrame] = None, ) -> list[tuple[float, float]]: diff --git a/prediction/output/violation_classifier.py b/prediction/output/violation_classifier.py index 379a5f3..97a428d 100644 --- a/prediction/output/violation_classifier.py +++ b/prediction/output/violation_classifier.py @@ -58,9 +58,12 @@ def classify_violations(result: dict) -> list[str]: if transship: violations.append('ILLEGAL_TRANSSHIP') - # 어구 불법 (gear_judgment은 classify_gear_violations()로 채워짐: G-01/G-04/G-05/G-06) + # 어구 불법 (gear_judgment은 classify_gear_violations()로 채워짐: G-01~G-06) gear_judgment = result.get('gear_judgment', '') or '' - if gear_judgment in ('NO_PERMIT', 'GEAR_MISMATCH', 'ZONE_VIOLATION', 'SEASON_VIOLATION', 'PAIR_TRAWL'): + if gear_judgment in ( + 'NO_PERMIT', 'GEAR_MISMATCH', 'ZONE_VIOLATION', 'SEASON_VIOLATION', + 'PAIR_TRAWL', 'CLOSED_SEASON_FISHING', 'UNREGISTERED_GEAR', + ): violations.append('ILLEGAL_GEAR') # 위험 행동 (다른 위반 없이 고위험) diff --git a/prediction/scheduler.py b/prediction/scheduler.py index abe0e49..de27e68 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -363,6 +363,7 @@ def run_analysis_cycle(): gear_episodes: list = [] gear_positions: list = [] + permit_periods: list = [] if gear in ('GN', 'TRAP', 'FYK', 'FPO', 'GNS', 'GND'): try: with kcgdb.get_conn() as gv_conn: @@ -371,12 +372,32 @@ def run_analysis_cycle(): except Exception as e: logger.debug('gear episode/pos 조회 실패 [%s]: %s', mmsi, e) + # G-02/G-03 입력: 허가 조업 기간 + 등록 업종코드 (등록 매칭된 선박만 대상) + registered_fishery_code = fleet_tracker.get_registered_fishery_code(mmsi) + if registered_fishery_code: + try: + with kcgdb.get_conn() as gp_conn: + permit_periods = fleet_tracker.get_permit_periods(mmsi, gp_conn) + except Exception as e: + logger.debug('permit_periods 조회 실패 [%s]: %s', mmsi, e) + + observation_ts = ts if isinstance(ts, datetime) else None + if observation_ts is None and ts is not None: + try: + import pandas as pd + observation_ts = pd.to_datetime(ts).to_pydatetime() + except Exception: + observation_ts = None + gv = classify_gear_violations( mmsi=mmsi, gear_type=gear, zone_info=zone_info, df_vessel=df_v, pair_result=pair_result, is_permitted=is_permitted, gear_episodes=gear_episodes or None, gear_positions=gear_positions or None, + permit_periods=permit_periods or None, + registered_fishery_code=registered_fishery_code, + observation_ts=observation_ts, ) g_codes = gv['g_codes'] gear_judgment = gv['gear_judgment']