feat(prediction): G-02 금어기 + G-03 미등록 어구 탐지 추가 (A-4)
V029 fishery_permit_cn 스키마를 입력으로 하여 보류 중이던 G-02/G-03 판정 함수를 신설. classify_gear_violations() 시그니처에 permit_periods, registered_fishery_code, observation_ts 매개변수 추가. - G-02 (CLOSED_SEASON_FISHING, score 18): 관측 시각이 fishing_period_1/2 허가 기간 밖이면 금어기 조업 - G-03 (UNREGISTERED_GEAR, score 12): 감지 어구가 fishery_code 허용 어구 집합(PT→TRAWL/PT-S, GN→GILLNET, PS→PURSE, OT→TRAWL, FC→금지)에 없음 - fleet_tracker: _parse_period_range() 'YYYY/MM/DD - YYYY/MM/DD' 파서 + get_permit_periods() + get_registered_fishery_code() - violation_classifier: CLOSED_SEASON_FISHING / UNREGISTERED_GEAR judgment → ILLEGAL_GEAR 카테고리 매핑 데이터 부재(permit_periods 빈 값, fishery_code 미등록) 시 판정 보류 → False. 검증 목표: 1시간 내 G-02/G-03 각 ≥ 1건
This commit is contained in:
부모
64df7b180c
커밋
1a065840bd
@ -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:
|
||||
|
||||
@ -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]]:
|
||||
|
||||
@ -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')
|
||||
|
||||
# 위험 행동 (다른 위반 없이 고위험)
|
||||
|
||||
@ -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']
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user