diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 57fc091..63c40c0 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -1,49 +1,74 @@ package gc.mda.kcg.domain.analysis; -import gc.mda.kcg.config.CacheConfig; import gc.mda.kcg.domain.fleet.GroupPolygonService; import lombok.RequiredArgsConstructor; -import org.springframework.cache.Cache; -import org.springframework.cache.CacheManager; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +@Slf4j @Service @RequiredArgsConstructor public class VesselAnalysisService { private final VesselAnalysisResultRepository repository; - private final CacheManager cacheManager; private final GroupPolygonService groupPolygonService; + private static final long CACHE_TTL_MS = 2 * 60 * 60_000L; // 2시간 + + /** mmsi → 최신 분석 결과 (인메모리 캐시) */ + private final Map cache = new ConcurrentHashMap<>(); + private volatile Instant lastFetchTime = null; + private volatile long lastUpdatedAt = 0; + /** - * 최근 2시간 내 분석 결과 + 집계 통계를 반환한다. - * mmsi별 최신 1건만. Caffeine 캐시(TTL 5분) 적용. + * 최근 2시간 분석 결과 + 집계 통계. + * - 첫 호출(warmup): 2시간 전체 조회 → 캐시 구축 + * - 이후: lastFetchTime 이후 증분만 조회 → 캐시 병합 + * - 2시간 초과 항목은 evict + * - 값 갱신 시 TTL 타이머 초기화 */ - @SuppressWarnings("unchecked") public Map getLatestResultsWithStats() { - Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS); - if (cache != null) { - Cache.ValueWrapper wrapper = cache.get("data_with_stats"); - if (wrapper != null) { - return (Map) wrapper.get(); + Instant now = Instant.now(); + + if (lastFetchTime == null || (System.currentTimeMillis() - lastUpdatedAt) > CACHE_TTL_MS) { + // warmup: 2시간 전체 조회 + Instant since = now.minus(2, ChronoUnit.HOURS); + List rows = repository.findByAnalyzedAtAfter(since); + cache.clear(); + for (VesselAnalysisResult r : rows) { + cache.merge(r.getMmsi(), r, (old, cur) -> + cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old); } + lastFetchTime = now; + lastUpdatedAt = System.currentTimeMillis(); + log.info("vessel analysis cache warmup: {} vessels from DB", cache.size()); + } else { + // 증분: lastFetchTime 이후만 조회 + List rows = repository.findByAnalyzedAtAfter(lastFetchTime); + if (!rows.isEmpty()) { + for (VesselAnalysisResult r : rows) { + cache.merge(r.getMmsi(), r, (old, cur) -> + cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old); + } + lastUpdatedAt = System.currentTimeMillis(); + log.debug("vessel analysis incremental merge: {} new rows", rows.size()); + } + lastFetchTime = now; } - Instant since = Instant.now().minus(2, ChronoUnit.HOURS); - Map latest = new LinkedHashMap<>(); - for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { - latest.merge(r.getMmsi(), r, (old, cur) -> - cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old); - } + // 2시간 초과 항목 evict + Instant cutoff = now.minus(2, ChronoUnit.HOURS); + cache.entrySet().removeIf(e -> e.getValue().getAnalyzedAt().isBefore(cutoff)); - // 집계 통계 — 같은 루프에서 계산 + // 집계 통계 int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0; Set clusterIds = new HashSet<>(); - for (VesselAnalysisResult r : latest.values()) { + for (VesselAnalysisResult r : cache.values()) { if (Boolean.TRUE.equals(r.getIsDark())) dark++; if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++; String level = r.getRiskLevel(); @@ -62,11 +87,10 @@ public class VesselAnalysisService { } } - // 어구 통계 — group_polygon_snapshots 기반 Map gearStats = groupPolygonService.getGearStats(); Map stats = new LinkedHashMap<>(); - stats.put("total", latest.size()); + stats.put("total", cache.size()); stats.put("dark", dark); stats.put("spoofing", spoofing); stats.put("critical", critical); @@ -77,21 +101,15 @@ public class VesselAnalysisService { stats.put("gearGroups", gearStats.get("gearGroups")); stats.put("gearCount", gearStats.get("gearCount")); - List results = latest.values().stream() + List results = cache.values().stream() .sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed()) .map(VesselAnalysisDto::from) .toList(); - Map response = Map.of( + return Map.of( "count", results.size(), "items", results, "stats", stats ); - - if (cache != null) { - cache.put("data_with_stats", response); - } - - return response; } } diff --git a/deploy/nginx-kcg.conf b/deploy/nginx-kcg.conf index a4c9821..05f0b60 100644 --- a/deploy/nginx-kcg.conf +++ b/deploy/nginx-kcg.conf @@ -94,6 +94,16 @@ server { proxy_ssl_server_name on; } + # ── Google TTS 프록시 (중국어 경고문 음성) ── + location /api/gtts { + rewrite ^/api/gtts(.*)$ /translate_tts$1 break; + proxy_pass https://translate.google.com; + proxy_set_header Host translate.google.com; + proxy_set_header Referer "https://translate.google.com/"; + proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + proxy_ssl_server_name on; + } + # gzip gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 2fbfb6d..98cde4c 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -4,8 +4,8 @@ import { FONT_MONO } from '../../styles/fonts'; import type { GeoJSON } from 'geojson'; import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; -import { fetchFleetCompanies } from '../../services/vesselAnalysis'; -import type { FleetCompany } from '../../services/vesselAnalysis'; +import { fetchFleetCompanies, fetchGroupHistory } from '../../services/vesselAnalysis'; +import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; export interface SelectedGearGroupData { @@ -50,6 +50,14 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[]; } | null>(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); + // 히스토리 애니메이션 — 12시간 실시간 타임라인 + const [historyData, setHistoryData] = useState(null); + const [, setHistoryGroupKey] = useState(null); + const [timelinePos, setTimelinePos] = useState(0); // 0~1 (12시간 내 위치) + const [isPlaying, setIsPlaying] = useState(true); + const animTimerRef = useRef>(); + const historyStartRef = useRef(0); // 12시간 전 epoch ms + const historyEndRef = useRef(0); // 현재 epoch ms const { current: mapRef } = useMap(); const registeredRef = useRef(false); const dataRef = useRef<{ shipMap: Map; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom }); @@ -58,6 +66,83 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS fetchFleetCompanies().then(setCompanies).catch(() => {}); }, []); + const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간 + const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생 + const TICK_MS = 50; // 50ms 간격 업데이트 + + const loadHistory = async (groupKey: string) => { + setHistoryGroupKey(groupKey); + setTimelinePos(0); + setIsPlaying(true); + const history = await fetchGroupHistory(groupKey, 12); + const sorted = history.reverse(); // 시간 오름차순 + const now = Date.now(); + historyStartRef.current = now - TIMELINE_DURATION_MS; + historyEndRef.current = now; + setHistoryData(sorted); + }; + + const closeHistory = useCallback(() => { + setHistoryData(null); + setHistoryGroupKey(null); + setTimelinePos(0); + setIsPlaying(true); + clearInterval(animTimerRef.current); + }, []); + + // 재생 타이머 — 50ms마다 timelinePos 진행 + useEffect(() => { + if (!historyData || !isPlaying) { + clearInterval(animTimerRef.current); + return; + } + const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); // 1틱당 진행량 + animTimerRef.current = setInterval(() => { + setTimelinePos(prev => { + const next = prev + step; + return next >= 1 ? 0 : next; // 순환 + }); + }, TICK_MS); + return () => clearInterval(animTimerRef.current); + }, [historyData, isPlaying]); + + // timelinePos → 현재 시각 + 가장 가까운 스냅샷 인덱스 + const currentTimeMs = historyStartRef.current + timelinePos * TIMELINE_DURATION_MS; + const currentSnapIdx = useMemo(() => { + if (!historyData || historyData.length === 0) return -1; + let best = 0; + let bestDiff = Infinity; + for (let i = 0; i < historyData.length; i++) { + const t = new Date(historyData[i].snapshotTime).getTime(); + const diff = Math.abs(t - currentTimeMs); + if (diff < bestDiff) { bestDiff = diff; best = i; } + } + // 5분(300초) 이내 스냅샷만 유효 + return bestDiff < 300_000 ? best : -1; + }, [historyData, currentTimeMs]); + + // 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용) + const snapshotRanges = useMemo(() => { + if (!historyData) return []; + return historyData.map(h => { + const t = new Date(h.snapshotTime).getTime(); + return (t - historyStartRef.current) / TIMELINE_DURATION_MS; + }); + }, [historyData]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (historyData) closeHistory(); + setSelectedGearGroup(null); + setExpandedFleet(null); + setExpandedGearGroup(null); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [historyData, closeHistory]); + // ── 맵 폴리곤 클릭/호버 이벤트 등록 useEffect(() => { const map = mapRef?.getMap(); @@ -98,6 +183,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS if (m.lon > maxLng) maxLng = m.lon; } if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + loadHistory(String(cid)); }; // 통합 클릭 핸들러: 선단+어구 모든 폴리곤 겹침 판정 @@ -179,6 +265,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS if (m.lon > maxLng) maxLng = m.lon; } if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + loadHistory(name); }; const onGearClick = onPolygonClick; @@ -220,10 +307,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // stale closure 방지 dataRef.current = { shipMap, groupPolygons, onFleetZoom }; - // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) + // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) — 히스토리 모드에서는 null useEffect(() => { - if (!selectedGearGroup) { + if (!selectedGearGroup || historyData) { onSelectedGearChange?.(null); + if (historyData) return; // 히스토리 모드: 선택은 유지하되 부모 강조만 숨김 return; } const allGroups = groupPolygons @@ -252,12 +340,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS gears: gears.map(toShip), groupName: selectedGearGroup, }); - }, [selectedGearGroup, groupPolygons, onSelectedGearChange]); + }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]); - // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) + // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null useEffect(() => { - if (expandedFleet === null) { + if (expandedFleet === null || historyData) { onSelectedFleetChange?.(null); + if (historyData) return; return; } const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet); @@ -282,7 +371,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`, }); - }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange]); + }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]); // API 기반 어구 그룹 분류 const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? []; @@ -385,6 +474,80 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] }; }, [pickerHoveredGroup, groupPolygons]); + // ── 히스토리 애니메이션 GeoJSON ── + const EMPTY_HIST_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; + + const centerTrailGeoJson = useMemo((): GeoJSON => { + if (!historyData) return EMPTY_HIST_FC; + const coords = historyData.map(h => [h.centerLon, h.centerLat]); + return { + type: 'FeatureCollection', + features: [ + { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }, + ...historyData.map(h => ({ + type: 'Feature' as const, properties: {}, + geometry: { type: 'Point' as const, coordinates: [h.centerLon, h.centerLat] }, + })), + ], + }; + }, [historyData]); + + const memberTrailsGeoJson = useMemo((): GeoJSON => { + if (!historyData) return EMPTY_HIST_FC; + const tracks = new Map(); + for (const snap of historyData) { + for (const m of snap.members) { + const arr = tracks.get(m.mmsi) ?? []; + arr.push([m.lon, m.lat]); + tracks.set(m.mmsi, arr); + } + } + const features: GeoJSON.Feature[] = []; + for (const [, coords] of tracks) { + if (coords.length < 2) continue; + features.push({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }); + } + return { type: 'FeatureCollection', features }; + }, [historyData]); + + // 현재 또는 마지막 유효 스냅샷 (신호없음 구간에서 이전 데이터 유지) + const effectiveSnapIdx = useMemo(() => { + if (!historyData || historyData.length === 0) return -1; + if (currentSnapIdx >= 0) return currentSnapIdx; + // 현재 시각 이전의 가장 가까운 스냅샷 + for (let i = historyData.length - 1; i >= 0; i--) { + if (new Date(historyData[i].snapshotTime).getTime() <= currentTimeMs) return i; + } + return -1; + }, [historyData, currentSnapIdx, currentTimeMs]); + + const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0; // 신호없음이지만 이전 데이터 유지 + + const animPolygonGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap?.polygon) return EMPTY_HIST_FC; + return { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0 }, geometry: snap.polygon }], + }; + }, [historyData, effectiveSnapIdx, isStale]); + + // 현재 프레임의 멤버 위치 (가상 아이콘) + const animMembersGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap) return EMPTY_HIST_FC; + return { + type: 'FeatureCollection', + features: snap.members.map(m => ({ + type: 'Feature' as const, + properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, stale: isStale ? 1 : 0 }, + geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] }, + })), + }; + }, [historyData, effectiveSnapIdx, isStale]); + // 선단 목록 (멤버 수 내림차순) const fleetList = useMemo(() => { if (!groupPolygons) return []; @@ -440,6 +603,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS } if (minLat === Infinity) return; onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + loadHistory(parentName); }, [groupPolygons, onFleetZoom]); // 패널 스타일 (AnalysisStatsPanel 패턴) @@ -534,8 +698,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS /> - {/* 선택된 어구 그룹 하이라이트 폴리곤 */} - {selectedGearGroup && (() => { + {/* 선택된 어구 그룹 하이라이트 폴리곤 — 히스토리 모드에서는 숨김 */} + {selectedGearGroup && !historyData && (() => { const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -578,8 +742,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS /> - {/* 가상 선박 마커 (API members 기반 — 삼각형 아이콘 + 방향 + 줌 스케일) */} - + {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */} + + + + )} + {historyData && ( + + + + + )} + {historyData && ( + + + + + )} + {/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */} + {historyData && ( + + + + + )} + + {/* 히스토리 재생 컨트롤러 */} + {historyData && (() => { + const curTime = new Date(currentTimeMs); + const timeStr = curTime.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); + const hasSnap = currentSnapIdx >= 0; + return ( +
+ {/* 프로그레스 바 — 갭 표시 */} +
+ {/* 스냅샷 존재 구간 표시 */} + {snapshotRanges.map((pos, i) => ( +
+ ))} + {/* 현재 위치 */} +
+
+ {/* 컨트롤 행 */} +
+ + + {timeStr} + + {!hasSnap && 신호없음} + { setIsPlaying(false); setTimelinePos(Number(e.target.value) / 1000); }} + style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} + title="히스토리 타임라인" aria-label="히스토리 타임라인" + /> + + {historyData.length}건 + + +
+
+ ); + })()} + {/* 선단 목록 패널 */}
{/* ── 선단 현황 섹션 ── */} diff --git a/prediction/algorithms/fishing_pattern.py b/prediction/algorithms/fishing_pattern.py index c2815ec..64201b6 100644 --- a/prediction/algorithms/fishing_pattern.py +++ b/prediction/algorithms/fishing_pattern.py @@ -92,6 +92,26 @@ def detect_fishing_segments(df_vessel: pd.DataFrame, }) in_fishing = False + # 트랙 끝까지 조업 중이면 마지막 세그먼트 추가 + if in_fishing and len(records) > seg_start_idx: + start_ts = records[seg_start_idx].get('timestamp') + end_ts = records[-1].get('timestamp') + if start_ts and end_ts: + dur_sec = (pd.Timestamp(end_ts) - pd.Timestamp(start_ts)).total_seconds() + dur_min = dur_sec / 60 + if dur_min >= window_min: + zone_info = classify_zone( + records[seg_start_idx].get('lat', 0), + records[seg_start_idx].get('lon', 0), + ) + segments.append({ + 'start_idx': seg_start_idx, + 'end_idx': len(records) - 1, + 'duration_min': round(dur_min, 1), + 'zone': zone_info.get('zone', 'UNKNOWN'), + 'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA', + }) + return segments diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 4fe7036..75f0e15 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) # 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일 GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$') MAX_DIST_DEG = 0.15 # ~10NM -STALE_SEC = 3600 # 60분 +STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) FLEET_BUFFER_DEG = 0.02 GEAR_BUFFER_DEG = 0.01 MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외) diff --git a/prediction/algorithms/risk.py b/prediction/algorithms/risk.py index d0c58b2..a5938f5 100644 --- a/prediction/algorithms/risk.py +++ b/prediction/algorithms/risk.py @@ -54,6 +54,13 @@ def compute_vessel_risk_score( if teleports: score += 20 + from algorithms.spoofing import count_speed_jumps + jumps = count_speed_jumps(df_vessel) + if jumps >= 3: + score += 10 + elif jumps >= 1: + score += 5 + gaps = detect_ais_gaps(df_vessel) critical_gaps = [g for g in gaps if g['gap_min'] >= 60] if critical_gaps: diff --git a/prediction/algorithms/spoofing.py b/prediction/algorithms/spoofing.py index e2ec081..a75db08 100644 --- a/prediction/algorithms/spoofing.py +++ b/prediction/algorithms/spoofing.py @@ -68,13 +68,15 @@ def compute_spoofing_score(df_vessel: pd.DataFrame) -> float: if jumps > 0: score += min(0.3, jumps / n * 5) - # BD09 오프셋 (중국 좌표 사용 의심) - mid_idx = len(df_vessel) // 2 - row = df_vessel.iloc[mid_idx] - offset = compute_bd09_offset(row['lat'], row['lon']) - if offset > 300: # 300m 이상 - score += 0.3 - elif offset > 100: - score += 0.1 + # BD09 오프셋 — 중국 선박(412*)은 좌표계 차이로 항상 ~300m이므로 제외 + mmsi_str = str(df_vessel.iloc[0].get('mmsi', '')) if 'mmsi' in df_vessel.columns else '' + if not mmsi_str.startswith('412'): + mid_idx = len(df_vessel) // 2 + row = df_vessel.iloc[mid_idx] + offset = compute_bd09_offset(row['lat'], row['lon']) + if offset > 300: + score += 0.3 + elif offset > 100: + score += 0.1 return round(min(score, 1.0), 4) diff --git a/prediction/pipeline/constants.py b/prediction/pipeline/constants.py index 4f07866..83a22e4 100644 --- a/prediction/pipeline/constants.py +++ b/prediction/pipeline/constants.py @@ -18,7 +18,7 @@ MIN_CLUSTER_SIZE = 5 MMSI_DIGITS = 9 MAX_VESSEL_LENGTH = 300 MAX_SOG_KNOTS = 30.0 -MIN_TRAJ_POINTS = 100 +MIN_TRAJ_POINTS = 20 KR_BOUNDS = { 'lat_min': 32.0, 'lat_max': 39.0,