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

파일 보기

@ -317,6 +317,24 @@ class VesselStore:
"""Return static vessel info dict for the given MMSI, or empty dict if not found.""" """Return static vessel info dict for the given MMSI, or empty dict if not found."""
return self._static_info.get(mmsi, {}) 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 # Properties
# ------------------------------------------------------------------ # ------------------------------------------------------------------

파일 보기

@ -104,9 +104,9 @@ def run_analysis_cycle():
from algorithms.polygon_builder import detect_gear_groups, build_all_group_snapshots from algorithms.polygon_builder import detect_gear_groups, build_all_group_snapshots
company_vessels = fleet_tracker.get_company_vessels(vessel_dfs) 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( group_snapshots = build_all_group_snapshots(
vessel_dfs, vessel_store, company_vessels, vessel_store, company_vessels,
fleet_tracker._companies, fleet_tracker._companies,
) )
saved = kcgdb.save_group_snapshots(group_snapshots) saved = kcgdb.save_group_snapshots(group_snapshots)