Merge pull request 'release: 2026-03-24.3 (어구그룹 탐지 수정)' (#181) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m48s

This commit is contained in:
htlee 2026-03-24 14:18:24 +09:00
커밋 89786f1ec3
3개의 변경된 파일85개의 추가작업 그리고 89개의 파일을 삭제

파일 보기

@ -108,17 +108,16 @@ def build_group_polygon(
def detect_gear_groups(
vessel_dfs: dict,
vessel_store,
now: Optional[datetime] = None,
) -> list[dict]:
"""어구 이름 패턴으로 어구그룹을 탐지한다.
프론트엔드 FleetClusterLayer.tsx gearGroupMap useMemo 로직 이관.
전체 AIS 선박(vessel_store._tracks)에서 어구 패턴을 탐지한다.
Args:
vessel_dfs: {mmsi: DataFrame} DataFrame은 lat, lon, sog, cog, timestamp 칼럼.
vessel_store: VesselStore get_vessel_info(mmsi) {name, ...}.
vessel_store: VesselStore get_all_latest_positions() + get_vessel_info().
now: 기준 시각 (None이면 UTC now).
Returns:
@ -127,30 +126,29 @@ def detect_gear_groups(
if now is None:
now = datetime.now(timezone.utc)
# 선박명 → mmsi 맵 (모선 탐색용)
# 전체 선박의 최신 위치 가져오기
all_positions = vessel_store.get_all_latest_positions()
# 선박명 → mmsi 맵 (모선 탐색용, 어구 패턴이 아닌 선박만)
name_to_mmsi: dict[str, str] = {}
for mmsi, df in vessel_dfs.items():
if df is None or len(df) == 0:
continue
info = vessel_store.get_vessel_info(mmsi)
name: str = (info or {}).get('name', '') or ''
name = name.strip()
for mmsi, pos in all_positions.items():
name = (pos.get('name') or '').strip()
if name and not GEAR_PATTERN.match(name):
name_to_mmsi[name] = mmsi
# 1단계: 같은 모선명 어구 수집 (60분 이내만)
raw_groups: dict[str, list[dict]] = {}
for mmsi, df in vessel_dfs.items():
if df is None or len(df) == 0:
for mmsi, pos in all_positions.items():
name = (pos.get('name') or '').strip()
if not name:
continue
last = df.iloc[-1]
ts = last.get('timestamp') if hasattr(last, 'get') else last['timestamp']
# timestamp → datetime 변환
# staleness 체크
ts = pos.get('timestamp')
if ts is not None:
if isinstance(ts, datetime):
last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
else:
# pandas Timestamp 또는 숫자(unix seconds)
try:
import pandas as pd
last_dt = pd.Timestamp(ts).to_pydatetime()
@ -158,15 +156,10 @@ def detect_gear_groups(
last_dt = last_dt.replace(tzinfo=timezone.utc)
except Exception:
continue
age_sec = (now - last_dt).total_seconds()
if age_sec > STALE_SEC:
continue
info = vessel_store.get_vessel_info(mmsi)
name = (info or {}).get('name', '') or ''
name = name.strip()
m = GEAR_PATTERN.match(name)
if not m:
continue
@ -175,10 +168,10 @@ def detect_gear_groups(
entry = {
'mmsi': mmsi,
'name': name,
'lat': float(last['lat']),
'lon': float(last['lon']),
'sog': float(last.get('sog', 0) if hasattr(last, 'get') else last['sog']),
'cog': float(last.get('cog', 0) if hasattr(last, 'get') else last['cog']),
'lat': pos['lat'],
'lon': pos['lon'],
'sog': pos.get('sog', 0),
'cog': pos.get('cog', 0),
}
raw_groups.setdefault(parent_name, []).append(entry)
@ -191,12 +184,10 @@ def detect_gear_groups(
anchor_lat: Optional[float] = None
anchor_lon: Optional[float] = None
if parent_mmsi and parent_mmsi in vessel_dfs:
parent_df = vessel_dfs[parent_mmsi]
if parent_df is not None and len(parent_df) > 0:
parent_last = parent_df.iloc[-1]
anchor_lat = float(parent_last['lat'])
anchor_lon = float(parent_last['lon'])
if parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi]
anchor_lat = parent_pos['lat']
anchor_lon = parent_pos['lon']
if anchor_lat is None and gears:
anchor_lat = gears[0]['lat']
@ -240,7 +231,6 @@ def detect_gear_groups(
def build_all_group_snapshots(
vessel_dfs: dict,
vessel_store,
company_vessels: dict[int, list[str]],
companies: dict[int, dict],
@ -250,8 +240,7 @@ def build_all_group_snapshots(
Shapely 미설치 리스트를 반환한다.
Args:
vessel_dfs: {mmsi: DataFrame}.
vessel_store: VesselStore get_vessel_info(mmsi).
vessel_store: VesselStore get_all_latest_positions() + get_vessel_info().
company_vessels: {company_id: [mmsi_list]}.
companies: {id: {name_cn, name_en}}.
@ -264,6 +253,7 @@ def build_all_group_snapshots(
now = datetime.now(timezone.utc)
snapshots: list[dict] = []
all_positions = vessel_store.get_all_latest_positions()
# ── FLEET 타입: company_vessels 순회 ──────────────────────────
for company_id, mmsi_list in company_vessels.items():
@ -275,20 +265,17 @@ def build_all_group_snapshots(
members: list[dict] = []
for mmsi in mmsi_list:
df = vessel_dfs.get(mmsi)
if df is None or len(df) == 0:
pos = all_positions.get(mmsi)
if not pos:
continue
last = df.iloc[-1]
lat = float(last['lat'])
lon = float(last['lon'])
sog_val = last.get('sog', 0) if hasattr(last, 'get') else last['sog']
cog_val = last.get('cog', 0) if hasattr(last, 'get') else last['cog']
sog = float(sog_val)
cog = float(cog_val)
lat = pos['lat']
lon = pos['lon']
sog = pos.get('sog', 0)
cog = pos.get('cog', 0)
points.append((lon, lat))
members.append({
'mmsi': mmsi,
'name': (vessel_store.get_vessel_info(mmsi) or {}).get('name', ''),
'name': pos.get('name', ''),
'lat': lat,
'lon': lon,
'sog': sog,
@ -321,7 +308,7 @@ def build_all_group_snapshots(
})
# ── GEAR 타입: detect_gear_groups 결과 순회 ───────────────────
gear_groups = detect_gear_groups(vessel_dfs, vessel_store, now=now)
gear_groups = detect_gear_groups(vessel_store, now=now)
for group in gear_groups:
parent_name: str = group['parent_name']
@ -332,12 +319,10 @@ def build_all_group_snapshots(
anchor_lat: Optional[float] = None
anchor_lon: Optional[float] = None
if parent_mmsi and parent_mmsi in vessel_dfs:
parent_df = vessel_dfs.get(parent_mmsi)
if parent_df is not None and len(parent_df) > 0:
p_last = parent_df.iloc[-1]
anchor_lat = float(p_last['lat'])
anchor_lon = float(p_last['lon'])
if parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi]
anchor_lat = parent_pos['lat']
anchor_lon = parent_pos['lon']
if anchor_lat is None and gear_members:
anchor_lat = gear_members[0]['lat']
@ -357,12 +342,9 @@ def build_all_group_snapshots(
# 폴리곤 points: 어구 좌표 + 모선 좌표
points = [(g['lon'], g['lat']) for g in gear_members]
if parent_mmsi and parent_mmsi in vessel_dfs:
parent_df = vessel_dfs.get(parent_mmsi)
if parent_df is not None and len(parent_df) > 0:
p_last = parent_df.iloc[-1]
p_lat = float(p_last['lat'])
p_lon = float(p_last['lon'])
if parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi]
p_lon, p_lat = parent_pos['lon'], parent_pos['lat']
if (p_lon, p_lat) not in points:
points.append((p_lon, p_lat))
@ -373,19 +355,15 @@ def build_all_group_snapshots(
# members JSONB 구성
members_out: list[dict] = []
# 모선 먼저
if parent_mmsi and parent_mmsi in vessel_dfs:
parent_df = vessel_dfs.get(parent_mmsi)
if parent_df is not None and len(parent_df) > 0:
p_last = parent_df.iloc[-1]
p_sog = float(p_last.get('sog', 0) if hasattr(p_last, 'get') else p_last['sog'])
p_cog = float(p_last.get('cog', 0) if hasattr(p_last, 'get') else p_last['cog'])
if parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi]
members_out.append({
'mmsi': parent_mmsi,
'name': parent_name,
'lat': float(p_last['lat']),
'lon': float(p_last['lon']),
'sog': p_sog,
'cog': p_cog,
'lat': parent_pos['lat'],
'lon': parent_pos['lon'],
'sog': parent_pos.get('sog', 0),
'cog': parent_pos.get('cog', 0),
'role': 'PARENT',
'isParent': True,
})

파일 보기

@ -317,6 +317,24 @@ class VesselStore:
"""Return static vessel info dict for the given MMSI, or empty dict if not found."""
return self._static_info.get(mmsi, {})
def get_all_latest_positions(self) -> dict[str, dict]:
"""모든 선박의 최신 위치 반환. {mmsi: {lat, lon, sog, cog, timestamp, name}}"""
result: dict[str, dict] = {}
for mmsi, df in self._tracks.items():
if df is None or len(df) == 0:
continue
last = df.iloc[-1]
info = self._static_info.get(mmsi, {})
result[mmsi] = {
'lat': float(last['lat']),
'lon': float(last['lon']),
'sog': float(last.get('sog', 0) or 0),
'cog': float(last.get('cog', 0) or 0),
'timestamp': last.get('timestamp'),
'name': info.get('name', ''),
}
return result
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------

파일 보기

@ -104,9 +104,9 @@ def run_analysis_cycle():
from algorithms.polygon_builder import detect_gear_groups, build_all_group_snapshots
company_vessels = fleet_tracker.get_company_vessels(vessel_dfs)
gear_groups = detect_gear_groups(vessel_dfs, vessel_store)
gear_groups = detect_gear_groups(vessel_store)
group_snapshots = build_all_group_snapshots(
vessel_dfs, vessel_store, company_vessels,
vessel_store, company_vessels,
fleet_tracker._companies,
)
saved = kcgdb.save_group_snapshots(group_snapshots)