fix: 위험도 점수 수역 가산 + 클러스터 그리드 셀 방식 전환

- risk.py: 특정어업수역(ZONE_I~IV) 내 미허가 어선 +25점 가산
- fleet.py: DBSCAN → 고정 그리드 셀(5NM) 클러스터링 (체인 효과 차단)
  - max_cluster_size=20으로 거대 클러스터 방지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-20 17:38:49 +09:00
부모 16805a1cf0
커밋 d13baf302f
2개의 변경된 파일29개의 추가작업 그리고 22개의 파일을 삭제

파일 보기

@ -10,37 +10,40 @@ logger = logging.getLogger(__name__)
def detect_group_clusters( def detect_group_clusters(
vessel_snapshots: list[dict], vessel_snapshots: list[dict],
spatial_eps_nm: float = 3.0, cell_size_nm: float = 5.0,
time_eps_hours: float = 2.0,
min_vessels: int = 3, min_vessels: int = 3,
max_cluster_size: int = 20,
) -> dict[int, list[dict]]: ) -> dict[int, list[dict]]:
"""DBSCAN 시공간 클러스터링으로 집단 탐지.""" """고정 그리드 셀 기반 클러스터링 — DBSCAN 체인 효과 방지.
cell_size_nm 격자로 공간을 분할하여 같은 셀에 속하는 선박을 그룹핑.
체인 효과 없이 max_cluster_size 제한으로 거대 클러스터 방지.
"""
if len(vessel_snapshots) < min_vessels: if len(vessel_snapshots) < min_vessels:
return {} return {}
try: # 위도 1도 ≈ 60NM, 경도 1도 ≈ 60*cos(lat) NM
from sklearn.cluster import DBSCAN # 중위도(35도) 기준 경도 1도 ≈ 49NM
except ImportError: cell_lat = cell_size_nm / 60.0
logger.warning('sklearn not available for DBSCAN clustering') cell_lng = cell_size_nm / 49.0
return {}
lat_rad = [math.radians(v['lat']) * EARTH_RADIUS_NM for v in vessel_snapshots] # 격자 셀별 선박 그룹핑
lon_rad = [math.radians(v['lon']) * EARTH_RADIUS_NM for v in vessel_snapshots] cells: dict[tuple[int, int], list[dict]] = {}
for v in vessel_snapshots:
# 시간을 NM 단위로 정규화 cell_key = (int(v['lat'] / cell_lat), int(v['lon'] / cell_lng))
timestamps = [pd.Timestamp(v['timestamp']).timestamp() for v in vessel_snapshots] cells.setdefault(cell_key, []).append(v)
t_min = min(timestamps)
time_nm = [(t - t_min) / 3600 * 10 / time_eps_hours for t in timestamps]
X = np.array(list(zip(lat_rad, lon_rad, time_nm)))
db = DBSCAN(eps=spatial_eps_nm, min_samples=min_vessels, metric='euclidean').fit(X)
clusters: dict[int, list[dict]] = {} clusters: dict[int, list[dict]] = {}
for idx, label in enumerate(db.labels_): cluster_id = 0
if label == -1: for cell_vessels in cells.values():
if len(cell_vessels) < min_vessels:
continue continue
clusters.setdefault(int(label), []).append(vessel_snapshots[idx]) # 셀 내 선박을 max_cluster_size 단위로 분할
for i in range(0, len(cell_vessels), max_cluster_size):
batch = cell_vessels[i:i + max_cluster_size]
if len(batch) >= min_vessels:
clusters[cluster_id] = batch
cluster_id += 1
return clusters return clusters

파일 보기

@ -32,6 +32,10 @@ def compute_vessel_risk_score(
score += 40 score += 40
elif zone == 'CONTIGUOUS_ZONE': elif zone == 'CONTIGUOUS_ZONE':
score += 10 score += 10
elif zone.startswith('ZONE_'):
# 특정어업수역 내 — 무허가면 가산
if is_permitted is not None and not is_permitted:
score += 25
# 2. 조업 행위 (최대 30점) # 2. 조업 행위 (최대 30점)
segs = detect_fishing_segments(df_vessel) segs = detect_fishing_segments(df_vessel)