fix: 분석 파이프라인 정확도 개선 + 캐시 증분 갱신 + TTS 프록시
- MIN_TRAJ_POINTS 100→20 (16척→684척, 파이프라인 병목 해소) - risk.py: SOG 급변 count를 위험도 점수에 반영 (+5/+10) - spoofing.py: BD09 오프셋 중국 MMSI(412*) 예외 (좌표계 노이즈 제거) - fishing_pattern.py: 마지막 조업 세그먼트 누락 버그 수정 - VesselAnalysisService: 인메모리 캐시 + 증분 갱신 (warmup 2h → incremental) - nginx: /api/gtts 프록시 추가 (Google TTS CORS 우회) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
eb6e51c8a0
커밋
7573c84e91
@ -1,49 +1,74 @@
|
|||||||
package gc.mda.kcg.domain.analysis;
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
import gc.mda.kcg.config.CacheConfig;
|
|
||||||
import gc.mda.kcg.domain.fleet.GroupPolygonService;
|
import gc.mda.kcg.domain.fleet.GroupPolygonService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.cache.Cache;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.cache.CacheManager;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class VesselAnalysisService {
|
public class VesselAnalysisService {
|
||||||
|
|
||||||
private final VesselAnalysisResultRepository repository;
|
private final VesselAnalysisResultRepository repository;
|
||||||
private final CacheManager cacheManager;
|
|
||||||
private final GroupPolygonService groupPolygonService;
|
private final GroupPolygonService groupPolygonService;
|
||||||
|
|
||||||
/**
|
private static final long CACHE_TTL_MS = 2 * 60 * 60_000L; // 2시간
|
||||||
* 최근 2시간 내 분석 결과 + 집계 통계를 반환한다.
|
|
||||||
* mmsi별 최신 1건만. Caffeine 캐시(TTL 5분) 적용.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public Map<String, Object> getLatestResultsWithStats() {
|
|
||||||
Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS);
|
|
||||||
if (cache != null) {
|
|
||||||
Cache.ValueWrapper wrapper = cache.get("data_with_stats");
|
|
||||||
if (wrapper != null) {
|
|
||||||
return (Map<String, Object>) wrapper.get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Instant since = Instant.now().minus(2, ChronoUnit.HOURS);
|
/** mmsi → 최신 분석 결과 (인메모리 캐시) */
|
||||||
Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
|
private final Map<String, VesselAnalysisResult> cache = new ConcurrentHashMap<>();
|
||||||
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {
|
private volatile Instant lastFetchTime = null;
|
||||||
latest.merge(r.getMmsi(), r, (old, cur) ->
|
private volatile long lastUpdatedAt = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 2시간 분석 결과 + 집계 통계.
|
||||||
|
* - 첫 호출(warmup): 2시간 전체 조회 → 캐시 구축
|
||||||
|
* - 이후: lastFetchTime 이후 증분만 조회 → 캐시 병합
|
||||||
|
* - 2시간 초과 항목은 evict
|
||||||
|
* - 값 갱신 시 TTL 타이머 초기화
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getLatestResultsWithStats() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
|
||||||
|
if (lastFetchTime == null || (System.currentTimeMillis() - lastUpdatedAt) > CACHE_TTL_MS) {
|
||||||
|
// warmup: 2시간 전체 조회
|
||||||
|
Instant since = now.minus(2, ChronoUnit.HOURS);
|
||||||
|
List<VesselAnalysisResult> rows = repository.findByAnalyzedAtAfter(since);
|
||||||
|
cache.clear();
|
||||||
|
for (VesselAnalysisResult r : rows) {
|
||||||
|
cache.merge(r.getMmsi(), r, (old, cur) ->
|
||||||
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
|
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<VesselAnalysisResult> 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;
|
||||||
|
}
|
||||||
|
|
||||||
// 집계 통계 — 같은 루프에서 계산
|
// 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;
|
int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0;
|
||||||
Set<Integer> clusterIds = new HashSet<>();
|
Set<Integer> clusterIds = new HashSet<>();
|
||||||
for (VesselAnalysisResult r : latest.values()) {
|
for (VesselAnalysisResult r : cache.values()) {
|
||||||
if (Boolean.TRUE.equals(r.getIsDark())) dark++;
|
if (Boolean.TRUE.equals(r.getIsDark())) dark++;
|
||||||
if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++;
|
if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++;
|
||||||
String level = r.getRiskLevel();
|
String level = r.getRiskLevel();
|
||||||
@ -62,11 +87,10 @@ public class VesselAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 어구 통계 — group_polygon_snapshots 기반
|
|
||||||
Map<String, Integer> gearStats = groupPolygonService.getGearStats();
|
Map<String, Integer> gearStats = groupPolygonService.getGearStats();
|
||||||
|
|
||||||
Map<String, Object> stats = new LinkedHashMap<>();
|
Map<String, Object> stats = new LinkedHashMap<>();
|
||||||
stats.put("total", latest.size());
|
stats.put("total", cache.size());
|
||||||
stats.put("dark", dark);
|
stats.put("dark", dark);
|
||||||
stats.put("spoofing", spoofing);
|
stats.put("spoofing", spoofing);
|
||||||
stats.put("critical", critical);
|
stats.put("critical", critical);
|
||||||
@ -77,21 +101,15 @@ public class VesselAnalysisService {
|
|||||||
stats.put("gearGroups", gearStats.get("gearGroups"));
|
stats.put("gearGroups", gearStats.get("gearGroups"));
|
||||||
stats.put("gearCount", gearStats.get("gearCount"));
|
stats.put("gearCount", gearStats.get("gearCount"));
|
||||||
|
|
||||||
List<VesselAnalysisDto> results = latest.values().stream()
|
List<VesselAnalysisDto> results = cache.values().stream()
|
||||||
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
|
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
|
||||||
.map(VesselAnalysisDto::from)
|
.map(VesselAnalysisDto::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
return Map.of(
|
||||||
"count", results.size(),
|
"count", results.size(),
|
||||||
"items", results,
|
"items", results,
|
||||||
"stats", stats
|
"stats", stats
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cache != null) {
|
|
||||||
cache.put("data_with_stats", response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,6 +94,16 @@ server {
|
|||||||
proxy_ssl_server_name on;
|
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
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|||||||
@ -92,6 +92,26 @@ def detect_fishing_segments(df_vessel: pd.DataFrame,
|
|||||||
})
|
})
|
||||||
in_fishing = False
|
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
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,13 @@ def compute_vessel_risk_score(
|
|||||||
if teleports:
|
if teleports:
|
||||||
score += 20
|
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)
|
gaps = detect_ais_gaps(df_vessel)
|
||||||
critical_gaps = [g for g in gaps if g['gap_min'] >= 60]
|
critical_gaps = [g for g in gaps if g['gap_min'] >= 60]
|
||||||
if critical_gaps:
|
if critical_gaps:
|
||||||
|
|||||||
@ -68,11 +68,13 @@ def compute_spoofing_score(df_vessel: pd.DataFrame) -> float:
|
|||||||
if jumps > 0:
|
if jumps > 0:
|
||||||
score += min(0.3, jumps / n * 5)
|
score += min(0.3, jumps / n * 5)
|
||||||
|
|
||||||
# BD09 오프셋 (중국 좌표 사용 의심)
|
# 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
|
mid_idx = len(df_vessel) // 2
|
||||||
row = df_vessel.iloc[mid_idx]
|
row = df_vessel.iloc[mid_idx]
|
||||||
offset = compute_bd09_offset(row['lat'], row['lon'])
|
offset = compute_bd09_offset(row['lat'], row['lon'])
|
||||||
if offset > 300: # 300m 이상
|
if offset > 300:
|
||||||
score += 0.3
|
score += 0.3
|
||||||
elif offset > 100:
|
elif offset > 100:
|
||||||
score += 0.1
|
score += 0.1
|
||||||
|
|||||||
@ -18,7 +18,7 @@ MIN_CLUSTER_SIZE = 5
|
|||||||
MMSI_DIGITS = 9
|
MMSI_DIGITS = 9
|
||||||
MAX_VESSEL_LENGTH = 300
|
MAX_VESSEL_LENGTH = 300
|
||||||
MAX_SOG_KNOTS = 30.0
|
MAX_SOG_KNOTS = 30.0
|
||||||
MIN_TRAJ_POINTS = 100
|
MIN_TRAJ_POINTS = 20
|
||||||
|
|
||||||
KR_BOUNDS = {
|
KR_BOUNDS = {
|
||||||
'lat_min': 32.0, 'lat_max': 39.0,
|
'lat_min': 32.0, 'lat_max': 39.0,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user