Codex Lab 환경(iran-airstrike-replay-codex)에서 검증 완료된 어구 모선 자동 추론 + 검토 워크플로우 전체를 이식. ## Python (prediction/) - gear_parent_inference(1,428줄): 다층 점수 모델 (correlation + name + track + prior bonus) - gear_parent_episode(631줄): Episode 연속성 (Jaccard + 공간거리) - gear_name_rules: 모선 이름 정규화 + 4자 미만 필터 - scheduler: 추론 호출 단계 추가 (4.8) - fleet_tracker/kcgdb: SQL qualified_table() 동적화 - gear_correlation: timestamp 필드 추가 ## DB (database/migration/ 012~015) - 후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리 테이블 9개 + VIEW 2개 ## Backend (Java) - 12개 DTO/Controller (ParentInferenceWorkflowController 등) - GroupPolygonService: parent_resolution LEFT JOIN + 15개 API 메서드 ## Frontend - ParentReviewPanel: 모선 검토 대시보드 - vesselAnalysis: 10개 신규 API 함수 + 6개 타입 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
import unittest
|
|
import sys
|
|
import types
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
stub = types.ModuleType('pydantic_settings')
|
|
|
|
|
|
class BaseSettings:
|
|
def __init__(self, **kwargs):
|
|
for name, value in self.__class__.__dict__.items():
|
|
if name.isupper():
|
|
setattr(self, name, kwargs.get(name, value))
|
|
|
|
|
|
stub.BaseSettings = BaseSettings
|
|
sys.modules.setdefault('pydantic_settings', stub)
|
|
|
|
from algorithms.gear_parent_episode import (
|
|
GroupEpisodeInput,
|
|
EpisodeState,
|
|
build_episode_plan,
|
|
compute_prior_bonus_components,
|
|
continuity_score,
|
|
)
|
|
|
|
|
|
class GearParentEpisodeTest(unittest.TestCase):
|
|
def test_continuity_score_prefers_member_overlap_and_near_center(self):
|
|
current = GroupEpisodeInput(
|
|
group_key='ZHEDAIYU02394',
|
|
normalized_parent_name='ZHEDAIYU02394',
|
|
sub_cluster_id=1,
|
|
member_mmsis=['100', '200', '300'],
|
|
member_count=3,
|
|
center_lat=35.0,
|
|
center_lon=129.0,
|
|
)
|
|
previous = EpisodeState(
|
|
episode_id='ep-prev',
|
|
lineage_key='ZHEDAIYU02394',
|
|
group_key='ZHEDAIYU02394',
|
|
normalized_parent_name='ZHEDAIYU02394',
|
|
current_sub_cluster_id=0,
|
|
member_mmsis=['100', '200', '400'],
|
|
member_count=3,
|
|
center_lat=35.02,
|
|
center_lon=129.01,
|
|
last_snapshot_time=datetime.now(timezone.utc),
|
|
status='ACTIVE',
|
|
)
|
|
score, overlap_count, distance_nm = continuity_score(current, previous)
|
|
self.assertGreaterEqual(overlap_count, 2)
|
|
self.assertGreater(score, 0.45)
|
|
self.assertLess(distance_nm, 12.0)
|
|
|
|
def test_build_episode_plan_creates_merge_episode(self):
|
|
now = datetime.now(timezone.utc)
|
|
current = GroupEpisodeInput(
|
|
group_key='JINSHI',
|
|
normalized_parent_name='JINSHI',
|
|
sub_cluster_id=0,
|
|
member_mmsis=['a', 'b', 'c', 'd'],
|
|
member_count=4,
|
|
center_lat=35.0,
|
|
center_lon=129.0,
|
|
)
|
|
previous_a = EpisodeState(
|
|
episode_id='ep-a',
|
|
lineage_key='JINSHI',
|
|
group_key='JINSHI',
|
|
normalized_parent_name='JINSHI',
|
|
current_sub_cluster_id=1,
|
|
member_mmsis=['a', 'b'],
|
|
member_count=2,
|
|
center_lat=35.0,
|
|
center_lon=129.0,
|
|
last_snapshot_time=now - timedelta(minutes=5),
|
|
status='ACTIVE',
|
|
)
|
|
previous_b = EpisodeState(
|
|
episode_id='ep-b',
|
|
lineage_key='JINSHI',
|
|
group_key='JINSHI',
|
|
normalized_parent_name='JINSHI',
|
|
current_sub_cluster_id=2,
|
|
member_mmsis=['c', 'd'],
|
|
member_count=2,
|
|
center_lat=35.01,
|
|
center_lon=129.01,
|
|
last_snapshot_time=now - timedelta(minutes=5),
|
|
status='ACTIVE',
|
|
)
|
|
plan = build_episode_plan([current], {'JINSHI': [previous_a, previous_b]})
|
|
assignment = plan.assignments[current.key]
|
|
self.assertEqual(assignment.continuity_source, 'MERGE_NEW')
|
|
self.assertEqual(set(assignment.merged_from_episode_ids), {'ep-a', 'ep-b'})
|
|
self.assertEqual(plan.merged_episode_targets['ep-a'], assignment.episode_id)
|
|
self.assertEqual(plan.merged_episode_targets['ep-b'], assignment.episode_id)
|
|
|
|
def test_build_episode_plan_marks_split_continue_and_split_new(self):
|
|
now = datetime.now(timezone.utc)
|
|
previous = EpisodeState(
|
|
episode_id='ep-prev',
|
|
lineage_key='A01859',
|
|
group_key='A01859',
|
|
normalized_parent_name='A01859',
|
|
current_sub_cluster_id=0,
|
|
member_mmsis=['a', 'b', 'c', 'd'],
|
|
member_count=4,
|
|
center_lat=35.0,
|
|
center_lon=129.0,
|
|
last_snapshot_time=now - timedelta(minutes=5),
|
|
status='ACTIVE',
|
|
)
|
|
current_a = GroupEpisodeInput(
|
|
group_key='A01859',
|
|
normalized_parent_name='A01859',
|
|
sub_cluster_id=1,
|
|
member_mmsis=['a', 'b', 'c'],
|
|
member_count=3,
|
|
center_lat=35.0,
|
|
center_lon=129.0,
|
|
)
|
|
current_b = GroupEpisodeInput(
|
|
group_key='A01859',
|
|
normalized_parent_name='A01859',
|
|
sub_cluster_id=2,
|
|
member_mmsis=['c', 'd'],
|
|
member_count=2,
|
|
center_lat=35.02,
|
|
center_lon=129.02,
|
|
)
|
|
plan = build_episode_plan([current_a, current_b], {'A01859': [previous]})
|
|
sources = {plan.assignments[current_a.key].continuity_source, plan.assignments[current_b.key].continuity_source}
|
|
self.assertIn('SPLIT_CONTINUE', sources)
|
|
self.assertIn('SPLIT_NEW', sources)
|
|
|
|
def test_compute_prior_bonus_components_caps_total_bonus(self):
|
|
observed_at = datetime.now(timezone.utc)
|
|
bonuses = compute_prior_bonus_components(
|
|
observed_at=observed_at,
|
|
normalized_parent_name='JINSHI',
|
|
episode_id='ep-1',
|
|
candidate_mmsi='412333326',
|
|
episode_prior_stats={
|
|
('ep-1', '412333326'): {
|
|
'seen_count': 12,
|
|
'top1_count': 5,
|
|
'avg_score': 0.88,
|
|
'last_seen_at': observed_at - timedelta(hours=1),
|
|
},
|
|
},
|
|
lineage_prior_stats={
|
|
('JINSHI', '412333326'): {
|
|
'seen_count': 24,
|
|
'top1_count': 6,
|
|
'top3_count': 10,
|
|
'avg_score': 0.82,
|
|
'last_seen_at': observed_at - timedelta(hours=3),
|
|
},
|
|
},
|
|
label_prior_stats={
|
|
('JINSHI', '412333326'): {
|
|
'session_count': 4,
|
|
'last_labeled_at': observed_at - timedelta(days=1),
|
|
},
|
|
},
|
|
)
|
|
self.assertGreater(bonuses['episodePriorBonus'], 0.0)
|
|
self.assertGreater(bonuses['lineagePriorBonus'], 0.0)
|
|
self.assertGreater(bonuses['labelPriorBonus'], 0.0)
|
|
self.assertLessEqual(bonuses['priorBonusTotal'], 0.20)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|