feat: 비정상 접촉 선박 탐색 API (POST /api/v2/tracks/vessel-contacts)

인메모리 캐시 기반으로 폴리곤 내 일정 시간/거리 이내 선박 쌍 탐색.
Two-pointer 시간 동기화 + 평균 거리 기반 접촉 판정 + 환적 의심 지표.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
LHT 2026-02-11 11:53:50 +09:00
부모 a3de69772a
커밋 fb72be89a1
5개의 변경된 파일855개의 추가작업 그리고 8개의 파일을 삭제

파일 보기

@ -2,7 +2,10 @@ package gc.mda.signal_batch.domain.gis.controller;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse;
import gc.mda.signal_batch.domain.gis.dto.VesselContactRequest;
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse;
import gc.mda.signal_batch.domain.gis.service.AreaSearchService;
import gc.mda.signal_batch.domain.gis.service.VesselContactService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
@ -27,6 +30,7 @@ import java.util.Map;
public class AreaSearchController {
private final AreaSearchService areaSearchService;
private final VesselContactService vesselContactService;
@PostMapping("/area-search")
@Operation(
@ -117,6 +121,91 @@ public class AreaSearchController {
return ResponseEntity.ok(areaSearchService.search(request));
}
@PostMapping("/vessel-contacts")
@Operation(
summary = "비정상 접촉 선박 탐색",
description = """
인메모리 캐시(D-1~D-7) 활용하여 지정 폴리곤 영역 내에서
일정 시간 이상, 일정 거리 이내에 머문 선박 쌍을 탐색합니다.
**접촉 판정 조건:**
- 선박 모두 폴리곤 **내부** 있을 때만 접촉으로 간주
- 대상: sigSrcCd 필터 (기본 "000001") 선박끼리만 비교
- 접촉 구간의 **평균 거리** <= maxContactDistanceMeters
- 접촉 지속 시간 >= minContactDurationMinutes
**파라미터 범위:**
- minContactDurationMinutes: 30분 ~ 360분 (6시간)
- maxContactDistanceMeters: 50m ~ 5,000m (5km)
**알고리즘:**
- Two-pointer 시간 동기화 (허용 오차 10분)
- 연속 세그먼트 분리 ( > 20분 분리)
- 세그먼트별 평균 거리로 판정 (일시적 초과 허용)
**다중 매칭:** A, B, C 3척이 모두 기준 충족 AB, AC, BC 3쌍 각각 반환
**환적 의심 지표:**
- lowSpeedContact: 선박 < 3 knots (정박/표류 접촉)
- differentVesselTypes: 이종 선박 접촉 (어선화물선 )
- differentNationalities: 외국선 접촉
- nightTimeContact: 야간 접촉 (22:00~06:00 KST)
**제약사항:**
- 캐시된 날짜 범위만 조회 가능 (D-1 ~ D-7, 오늘 제외)
- 폴리곤 좌표는 닫힌 형태 (첫점 == 끝점)
- 캐시 미준비 503 반환
"""
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "탐색 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = VesselContactResponse.class)
)),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = "{\"error\": \"최소 접촉 지속 시간은 30분 이상이어야 합니다\"}")
)),
@ApiResponse(responseCode = "503", description = "캐시 미준비 (LOADING 상태)",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = "{\"error\": \"캐시가 아직 준비되지 않았습니다 (상태: LOADING)\"}")
))
})
public ResponseEntity<VesselContactResponse> searchVesselContacts(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "비정상 접촉 선박 탐색 요청",
required = true,
content = @Content(
schema = @Schema(implementation = VesselContactRequest.class),
examples = @ExampleObject(
name = "접촉 탐색 예시",
value = """
{
"startTime": "2026-02-01T00:00:00",
"endTime": "2026-02-07T23:59:59",
"polygon": {
"id": "zone_A", "name": "동해 남부 해역",
"coordinates": [[129.0,34.5],[130.0,34.5],[130.0,35.5],[129.0,35.5],[129.0,34.5]]
},
"minContactDurationMinutes": 60,
"maxContactDistanceMeters": 1000
}
"""
)
)
)
@Valid @RequestBody VesselContactRequest request) {
log.info("Vessel contact search request: polygon={}, duration={}min, distance={}m, timeRange={} ~ {}",
request.getPolygon().getId(), request.getMinContactDurationMinutes(),
request.getMaxContactDistanceMeters(), request.getStartTime(), request.getEndTime());
return ResponseEntity.ok(vesselContactService.search(request));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
log.warn("Area search bad request: {}", e.getMessage());

파일 보기

@ -0,0 +1,74 @@
package gc.mda.signal_batch.domain.gis.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "비정상 접촉 선박 탐색 요청")
public class VesselContactRequest {
@NotNull(message = "시작 시간은 필수입니다")
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
@Schema(description = "조회 시작 시간", example = "2026-02-01T00:00:00", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime startTime;
@NotNull(message = "종료 시간은 필수입니다")
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
@Schema(description = "조회 종료 시간", example = "2026-02-07T23:59:59", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime endTime;
@NotNull(message = "폴리곤은 필수입니다")
@Valid
@Schema(description = "탐색 대상 폴리곤 영역", requiredMode = Schema.RequiredMode.REQUIRED)
private SearchPolygon polygon;
@NotNull(message = "최소 접촉 지속 시간은 필수입니다")
@Min(value = 30, message = "최소 접촉 지속 시간은 30분 이상이어야 합니다")
@Max(value = 360, message = "최소 접촉 지속 시간은 360분(6시간) 이하여야 합니다")
@Schema(description = "최소 접촉 지속 시간 (분, 30~360)", example = "60", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer minContactDurationMinutes;
@NotNull(message = "최대 접촉 판정 거리는 필수입니다")
@DecimalMin(value = "50.0", message = "최대 접촉 판정 거리는 50m 이상이어야 합니다")
@DecimalMax(value = "5000.0", message = "최대 접촉 판정 거리는 5000m(5km) 이하여야 합니다")
@Schema(description = "최대 접촉 판정 거리 (미터, 50~5000)", example = "1000", requiredMode = Schema.RequiredMode.REQUIRED)
private Double maxContactDistanceMeters;
@Schema(description = "대상 선박 신호소스 코드 (기본: 000001)", example = "000001", defaultValue = "000001")
@Builder.Default
private String sigSrcCd = "000001";
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "탐색 대상 폴리곤 영역")
public static class SearchPolygon {
@Schema(description = "클라이언트 지정 영역 식별자", example = "zone_A")
private String id;
@Schema(description = "영역 표시명", example = "동해 남부 해역")
private String name;
@NotNull(message = "폴리곤 좌표는 필수입니다")
@Size(min = 4, message = "폴리곤은 최소 4개 좌표 필요 (첫점==끝점)")
@Schema(description = "폴리곤 좌표 배열 [[lon,lat],...] 첫점과 끝점이 동일해야 함",
example = "[[128.5,34.0],[129.5,34.0],[129.5,35.0],[128.5,35.0],[128.5,34.0]]",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<double[]> coordinates;
}
}

파일 보기

@ -0,0 +1,160 @@
package gc.mda.signal_batch.domain.gis.dto;
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "비정상 접촉 선박 탐색 응답")
public class VesselContactResponse {
@Schema(description = "접촉 선박 쌍 목록")
private List<VesselContactPair> contacts;
@Schema(description = "관련 선박의 전체 기간 항적 (CompactVesselTrack)")
private List<CompactVesselTrack> tracks;
@Schema(description = "탐색 요약 정보")
private VesselContactSummary summary;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "접촉 선박 쌍 상세 정보")
public static class VesselContactPair {
// 접촉 시간 정보
@Schema(description = "접촉 시작 시각 (Unix 초)", example = "1738368000")
private Long contactStartTimestamp;
@Schema(description = "접촉 종료 시각 (Unix 초)", example = "1738375200")
private Long contactEndTimestamp;
@Schema(description = "접촉 지속 시간 (분)", example = "120")
private Long contactDurationMinutes;
// 접촉 거리 정보
@Schema(description = "기간 내 최소 거리 (미터)", example = "45.2")
private Double minDistanceMeters;
@Schema(description = "기간 내 평균 거리 (미터)", example = "320.5")
private Double avgDistanceMeters;
@Schema(description = "기간 내 최대 거리 (미터)", example = "890.3")
private Double maxDistanceMeters;
// 접촉 위치 정보
@Schema(description = "접촉 구간 중심점 [경도, 위도]", example = "[129.0, 34.5]")
private double[] contactCenterPoint;
// 측정 정보
@Schema(description = "접촉 판정에 사용된 측정 포인트 수", example = "24")
private Integer contactPointCount;
// 선박 정보
@Schema(description = "선박 1 정보")
private VesselContactInfo vessel1;
@Schema(description = "선박 2 정보")
private VesselContactInfo vessel2;
// 환적 의심 지표
@Schema(description = "환적 의심 지표 (프론트엔드 렌더링 보조)")
private TransshipmentIndicators indicators;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "접촉 선박 개별 정보")
public static class VesselContactInfo {
@Schema(description = "선박 고유 ID (sigSrcCd_targetId)", example = "000001_440113620")
private String vesselId;
@Schema(description = "선박명", example = "SAM SUNG 2HO")
private String vesselName;
@Schema(description = "AIS ship type 코드", example = "74")
private String shipType;
@Schema(description = "선종 분류 코드 (000020:어선, 000023:화물선 등)", example = "000023")
private String shipKindCode;
@Schema(description = "국적 MID 코드 (MMSI 앞 3자리)", example = "440")
private String nationalCode;
@Schema(description = "통합선박 ID", example = "440113620___440113620_")
private String integrationTargetId;
// 폴리곤 체류 정보
@Schema(description = "폴리곤 내 첫 시각 (Unix 초)", example = "1738360000")
private Long insidePolygonStartTs;
@Schema(description = "폴리곤 내 마지막 시각 (Unix 초)", example = "1738400000")
private Long insidePolygonEndTs;
@Schema(description = "폴리곤 내 체류 시간 (분)", example = "667")
private Long insidePolygonDurationMinutes;
// 접촉 구간 추정 속도
@Schema(description = "접촉 구간 추정 평균 속도 (knots, 좌표간 거리/시간 기반)", example = "1.2")
private Double estimatedAvgSpeedKnots;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "환적 의심 지표 — 불법 환적(STS) 탐지 보조 데이터")
public static class TransshipmentIndicators {
@Schema(description = "양 선박 모두 저속 접촉 (< 3 knots, 정박/표류 중 접촉 → 화물 이전 가능성)", example = "true")
private Boolean lowSpeedContact;
@Schema(description = "서로 다른 선종 (어선↔화물선 등 → 환적 가능성↑)", example = "true")
private Boolean differentVesselTypes;
@Schema(description = "서로 다른 국적 (외국선 간 접촉)", example = "false")
private Boolean differentNationalities;
@Schema(description = "야간 접촉 (22:00~06:00 KST → 은밀성↑)", example = "false")
private Boolean nightTimeContact;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "비정상 접촉 탐색 요약 정보")
public static class VesselContactSummary {
@Schema(description = "발견된 접촉 쌍 수", example = "3")
private Integer totalContactPairs;
@Schema(description = "접촉에 관련된 고유 선박 수", example = "5")
private Integer totalVesselsInvolved;
@Schema(description = "sigSrcCd 필터 후 폴리곤 내 전체 선박 수", example = "42")
private Integer totalVesselsInPolygon;
@Schema(description = "처리 소요 시간 (ms)", example = "2340")
private Long processingTimeMs;
@Schema(description = "폴리곤 ID", example = "zone_A")
private String polygonId;
@Schema(description = "조회에 사용된 캐시 날짜 목록", example = "[\"2026-02-01\", \"2026-02-02\"]")
private List<String> cachedDates;
}
}

파일 보기

@ -142,17 +142,23 @@ public class AreaSearchService {
}
private void validatePolygon(SearchPolygon polygon) {
List<double[]> coords = polygon.getCoordinates();
validatePolygon(polygon.getId(), polygon.getCoordinates());
}
/**
* 폴리곤 유효성 검증 (package-private, VesselContactService에서 재사용).
*/
void validatePolygon(String polygonId, List<double[]> coords) {
if (coords == null || coords.size() < 4) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "'은 최소 4개 좌표가 필요합니다 (첫점==끝점)");
"폴리곤 '" + polygonId + "'은 최소 4개 좌표가 필요합니다 (첫점==끝점)");
}
double[] first = coords.get(0);
double[] last = coords.get(coords.size() - 1);
if (first[0] != last[0] || first[1] != last[1]) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "'의 첫점과 끝점이 동일해야 합니다");
"폴리곤 '" + polygonId + "'의 첫점과 끝점이 동일해야 합니다");
}
// JTS로 유효성 검사
@ -160,19 +166,19 @@ public class AreaSearchService {
Polygon jtsPolygon = toJtsPolygon(coords);
if (!jtsPolygon.isValid()) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "'이 유효하지 않습니다 (자기 교차 등)");
"폴리곤 '" + polygonId + "'이 유효하지 않습니다 (자기 교차 등)");
}
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "' 변환 실패: " + e.getMessage());
"폴리곤 '" + polygonId + "' 변환 실패: " + e.getMessage());
}
}
// 날짜 수집
private List<LocalDate> collectTargetDates(LocalDateTime startTime, LocalDateTime endTime) {
List<LocalDate> collectTargetDates(LocalDateTime startTime, LocalDateTime endTime) {
LocalDate today = LocalDate.now();
LocalDate startDate = startTime.toLocalDate();
LocalDate endDate = endTime.toLocalDate();
@ -204,7 +210,7 @@ public class AreaSearchService {
.collect(Collectors.toList());
}
private Polygon toJtsPolygon(List<double[]> coordinates) {
Polygon toJtsPolygon(List<double[]> coordinates) {
Coordinate[] coords = new Coordinate[coordinates.size()];
for (int i = 0; i < coordinates.size(); i++) {
double[] c = coordinates.get(i);
@ -278,7 +284,7 @@ public class AreaSearchService {
// STRtree 빌드
private STRtree buildSpatialIndex(Map<String, CompactVesselTrack> tracks) {
STRtree buildSpatialIndex(Map<String, CompactVesselTrack> tracks) {
STRtree tree = new STRtree();
for (Map.Entry<String, CompactVesselTrack> entry : tracks.entrySet()) {
CompactVesselTrack track = entry.getValue();

파일 보기

@ -0,0 +1,518 @@
package gc.mda.signal_batch.domain.gis.service;
import gc.mda.signal_batch.domain.gis.dto.VesselContactRequest;
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse;
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse.*;
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.*;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.index.strtree.STRtree;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class VesselContactService {
private final AreaSearchService areaSearchService;
private final DailyTrackCacheManager cacheManager;
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
private static final double EARTH_RADIUS_M = 6_371_000.0;
private static final double EARTH_RADIUS_NM = 3_440.065;
/** 시간 동기화 허용 오차 (10분, 데이터 해상도 ~5분에 맞춤) */
private static final long SYNC_TOLERANCE_SEC = 600;
/** 연속 세그먼트 분리 기준 갭 (20분) */
private static final long MAX_GAP_SEC = 1200;
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
public VesselContactResponse search(VesselContactRequest request) {
long startMs = System.currentTimeMillis();
// 1. 입력 검증
validateRequest(request);
// 2. 캐시 데이터 수집 + 다일 병합
List<LocalDate> targetDates = areaSearchService.collectTargetDates(
request.getStartTime(), request.getEndTime());
if (targetDates.isEmpty()) {
return buildEmptyResponse(request, targetDates, startMs);
}
Map<String, CompactVesselTrack> mergedTracks = areaSearchService.mergeMultipleDays(targetDates);
if (mergedTracks.isEmpty()) {
return buildEmptyResponse(request, targetDates, startMs);
}
// 3. sigSrcCd 필터
String targetSigSrcCd = request.getSigSrcCd();
Map<String, CompactVesselTrack> filtered = new HashMap<>();
for (Map.Entry<String, CompactVesselTrack> entry : mergedTracks.entrySet()) {
if (targetSigSrcCd.equals(entry.getValue().getSigSrcCd())) {
filtered.put(entry.getKey(), entry.getValue());
}
}
if (filtered.isEmpty()) {
return buildEmptyResponse(request, targetDates, startMs);
}
// 4. JTS Polygon + PreparedGeometry
VesselContactRequest.SearchPolygon poly = request.getPolygon();
Polygon jtsPolygon = areaSearchService.toJtsPolygon(poly.getCoordinates());
PreparedGeometry prepared = PreparedGeometryFactory.prepare(jtsPolygon);
// 5. STRtree 후보 필터링 + 폴리곤 내부 포인트 수집
STRtree spatialIndex = areaSearchService.buildSpatialIndex(filtered);
Envelope mbr = jtsPolygon.getEnvelopeInternal();
@SuppressWarnings("unchecked")
List<String> candidates = spatialIndex.query(mbr);
long minDurationSec = request.getMinContactDurationMinutes() * 60L;
double maxDistanceMeters = request.getMaxContactDistanceMeters();
Map<String, List<InsidePosition>> insidePositions = new HashMap<>();
for (String vesselId : candidates) {
CompactVesselTrack track = filtered.get(vesselId);
if (track == null || track.getGeometry() == null) continue;
List<InsidePosition> inside = collectInsidePositions(track, prepared);
if (!inside.isEmpty()) {
insidePositions.put(vesselId, inside);
}
}
int totalVesselsInPolygon = insidePositions.size();
log.info("Vessel contact: sigSrcCd={}, filtered={}, insidePolygon={}, dates={}",
targetSigSrcCd, filtered.size(), totalVesselsInPolygon, targetDates.size());
// 6. 시간 범위 겹침 사전 필터 + 선박 쌍별 접촉 판정
List<String> vesselIds = new ArrayList<>(insidePositions.keySet());
List<VesselContactPair> contactPairs = new ArrayList<>();
Set<String> involvedVessels = new HashSet<>();
for (int i = 0; i < vesselIds.size(); i++) {
String idA = vesselIds.get(i);
List<InsidePosition> posA = insidePositions.get(idA);
long minTsA = posA.get(0).timestamp;
long maxTsA = posA.get(posA.size() - 1).timestamp;
for (int j = i + 1; j < vesselIds.size(); j++) {
String idB = vesselIds.get(j);
List<InsidePosition> posB = insidePositions.get(idB);
long minTsB = posB.get(0).timestamp;
long maxTsB = posB.get(posB.size() - 1).timestamp;
// 시간 겹침 사전 필터 (minContactDuration 반영)
long overlap = Math.min(maxTsA, maxTsB) - Math.max(minTsA, minTsB);
if (overlap < minDurationSec) continue;
// Two-pointer 접촉 판정
List<VesselContactPair> pairs = detectContacts(
idA, posA, idB, posB,
filtered.get(idA), filtered.get(idB),
minDurationSec, maxDistanceMeters);
if (!pairs.isEmpty()) {
contactPairs.addAll(pairs);
involvedVessels.add(idA);
involvedVessels.add(idB);
}
}
}
// 7. 관련 선박 트랙 수집
List<CompactVesselTrack> resultTracks = involvedVessels.stream()
.map(mergedTracks::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
long elapsedMs = System.currentTimeMillis() - startMs;
log.info("Vessel contact completed: pairs={}, vessels={}, elapsed={}ms",
contactPairs.size(), involvedVessels.size(), elapsedMs);
return VesselContactResponse.builder()
.contacts(contactPairs)
.tracks(resultTracks)
.summary(VesselContactSummary.builder()
.totalContactPairs(contactPairs.size())
.totalVesselsInvolved(involvedVessels.size())
.totalVesselsInPolygon(totalVesselsInPolygon)
.processingTimeMs(elapsedMs)
.polygonId(poly.getId())
.cachedDates(targetDates.stream()
.map(LocalDate::toString)
.collect(Collectors.toList()))
.build())
.build();
}
// 입력 검증
private void validateRequest(VesselContactRequest request) {
if (request.getStartTime().isAfter(request.getEndTime())) {
throw new IllegalArgumentException("시작 시간이 종료 시간보다 나중입니다");
}
DailyTrackCacheManager.CacheStatus cacheStatus = cacheManager.getStatus();
if (cacheStatus == DailyTrackCacheManager.CacheStatus.LOADING
|| cacheStatus == DailyTrackCacheManager.CacheStatus.NOT_STARTED) {
throw new AreaSearchService.CacheNotReadyException(
"캐시가 아직 준비되지 않았습니다 (상태: " + cacheStatus + ")");
}
VesselContactRequest.SearchPolygon polygon = request.getPolygon();
areaSearchService.validatePolygon(polygon.getId(), polygon.getCoordinates());
}
// 폴리곤 내부 포인트 수집
private List<InsidePosition> collectInsidePositions(
CompactVesselTrack track, PreparedGeometry prepared) {
List<double[]> geometry = track.getGeometry();
List<String> timestamps = track.getTimestamps();
List<InsidePosition> inside = new ArrayList<>();
for (int i = 0; i < geometry.size(); i++) {
double[] coord = geometry.get(i);
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
if (prepared.contains(point)) {
long ts = parseTimestamp(timestamps, i);
inside.add(new InsidePosition(ts, coord[0], coord[1]));
}
}
// 시간순 정렬 보장
inside.sort(Comparator.comparingLong(p -> p.timestamp));
return inside;
}
// Two-pointer 접촉 판정
private List<VesselContactPair> detectContacts(
String idA, List<InsidePosition> posA,
String idB, List<InsidePosition> posB,
CompactVesselTrack trackA, CompactVesselTrack trackB,
long minDurationSec, double maxDistanceMeters) {
// Step 1: Two-pointer 매칭 (거리 임계값 없이 모두 수집)
List<MatchedPoint> matched = twoPointerMatch(posA, posB);
if (matched.isEmpty()) return Collections.emptyList();
// Step 2: 연속 세그먼트 분리 ( > MAX_GAP)
List<List<MatchedPoint>> segments = splitByGap(matched);
// Step 3: 세그먼트별 평균 거리 판정
List<VesselContactPair> results = new ArrayList<>();
for (List<MatchedPoint> segment : segments) {
if (segment.size() < 2) continue;
long duration = segment.get(segment.size() - 1).timestamp - segment.get(0).timestamp;
if (duration < minDurationSec) continue;
double avgDist = segment.stream().mapToDouble(p -> p.distanceMeters).average().orElse(0);
if (avgDist > maxDistanceMeters) continue;
// 접촉 확정 VesselContactPair 생성
results.add(buildContactPair(segment, idA, posA, idB, posB, trackA, trackB));
}
return results;
}
private List<MatchedPoint> twoPointerMatch(
List<InsidePosition> posA, List<InsidePosition> posB) {
List<MatchedPoint> matched = new ArrayList<>();
int pA = 0, pB = 0;
while (pA < posA.size() && pB < posB.size()) {
InsidePosition a = posA.get(pA);
InsidePosition b = posB.get(pB);
long diff = Math.abs(a.timestamp - b.timestamp);
if (diff <= SYNC_TOLERANCE_SEC) {
double dist = haversineMeters(a.lat, a.lon, b.lat, b.lon);
long ts = Math.min(a.timestamp, b.timestamp) + diff / 2; // 중간 시각
matched.add(new MatchedPoint(ts, dist, a, b));
pA++;
pB++;
} else if (a.timestamp < b.timestamp) {
pA++;
} else {
pB++;
}
}
return matched;
}
private List<List<MatchedPoint>> splitByGap(List<MatchedPoint> matched) {
List<List<MatchedPoint>> segments = new ArrayList<>();
List<MatchedPoint> current = new ArrayList<>();
for (MatchedPoint point : matched) {
if (!current.isEmpty()
&& (point.timestamp - current.get(current.size() - 1).timestamp) > MAX_GAP_SEC) {
segments.add(current);
current = new ArrayList<>();
}
current.add(point);
}
if (!current.isEmpty()) {
segments.add(current);
}
return segments;
}
// 결과 빌드
private VesselContactPair buildContactPair(
List<MatchedPoint> segment,
String idA, List<InsidePosition> insidePosA,
String idB, List<InsidePosition> insidePosB,
CompactVesselTrack trackA, CompactVesselTrack trackB) {
long contactStart = segment.get(0).timestamp;
long contactEnd = segment.get(segment.size() - 1).timestamp;
long durationMin = (contactEnd - contactStart) / 60;
DoubleSummaryStatistics distStats = segment.stream()
.mapToDouble(p -> p.distanceMeters)
.summaryStatistics();
// 접촉 중심점 계산
double centerLon = segment.stream().mapToDouble(p -> (p.posA.lon + p.posB.lon) / 2).average().orElse(0);
double centerLat = segment.stream().mapToDouble(p -> (p.posA.lat + p.posB.lat) / 2).average().orElse(0);
// 선박의 접촉 구간 inside 포인트로 추정 속도 계산
double speedA = estimateAvgSpeed(insidePosA, contactStart, contactEnd);
double speedB = estimateAvgSpeed(insidePosB, contactStart, contactEnd);
VesselContactInfo infoA = buildVesselInfo(idA, trackA, insidePosA, speedA);
VesselContactInfo infoB = buildVesselInfo(idB, trackB, insidePosB, speedB);
TransshipmentIndicators indicators = buildIndicators(infoA, infoB, speedA, speedB, contactStart, contactEnd);
return VesselContactPair.builder()
.contactStartTimestamp(contactStart)
.contactEndTimestamp(contactEnd)
.contactDurationMinutes(durationMin)
.minDistanceMeters(Math.round(distStats.getMin() * 10.0) / 10.0)
.avgDistanceMeters(Math.round(distStats.getAverage() * 10.0) / 10.0)
.maxDistanceMeters(Math.round(distStats.getMax() * 10.0) / 10.0)
.contactCenterPoint(new double[]{
Math.round(centerLon * 1_000_000.0) / 1_000_000.0,
Math.round(centerLat * 1_000_000.0) / 1_000_000.0})
.contactPointCount(segment.size())
.vessel1(infoA)
.vessel2(infoB)
.indicators(indicators)
.build();
}
private VesselContactInfo buildVesselInfo(
String vesselId, CompactVesselTrack track,
List<InsidePosition> insidePositions, double estimatedSpeed) {
long startTs = insidePositions.get(0).timestamp;
long endTs = insidePositions.get(insidePositions.size() - 1).timestamp;
long durationMin = (endTs - startTs) / 60;
return VesselContactInfo.builder()
.vesselId(vesselId)
.vesselName(track.getShipName())
.shipType(track.getShipType())
.shipKindCode(track.getShipKindCode())
.nationalCode(track.getNationalCode())
.integrationTargetId(track.getIntegrationTargetId())
.insidePolygonStartTs(startTs)
.insidePolygonEndTs(endTs)
.insidePolygonDurationMinutes(durationMin)
.estimatedAvgSpeedKnots(Math.round(estimatedSpeed * 100.0) / 100.0)
.build();
}
private TransshipmentIndicators buildIndicators(
VesselContactInfo infoA, VesselContactInfo infoB,
double speedA, double speedB,
long contactStart, long contactEnd) {
boolean lowSpeed = speedA < 3.0 && speedB < 3.0;
boolean diffTypes = !Objects.equals(infoA.getShipKindCode(), infoB.getShipKindCode())
&& infoA.getShipKindCode() != null && infoB.getShipKindCode() != null;
boolean diffNationality = !Objects.equals(infoA.getNationalCode(), infoB.getNationalCode())
&& infoA.getNationalCode() != null && infoB.getNationalCode() != null;
boolean nightTime = isNightTimeContact(contactStart, contactEnd);
return TransshipmentIndicators.builder()
.lowSpeedContact(lowSpeed)
.differentVesselTypes(diffTypes)
.differentNationalities(diffNationality)
.nightTimeContact(nightTime)
.build();
}
/**
* 접촉 구간이 22:00~06:00 KST에 포함되는지 판단.
*/
private boolean isNightTimeContact(long contactStartSec, long contactEndSec) {
Instant startInstant = Instant.ofEpochSecond(contactStartSec);
Instant endInstant = Instant.ofEpochSecond(contactEndSec);
ZonedDateTime startKst = startInstant.atZone(KST);
ZonedDateTime endKst = endInstant.atZone(KST);
// 접촉 구간 모든 날짜에 대해 야간 시간대 겹침 체크
LocalDate day = startKst.toLocalDate();
LocalDate lastDay = endKst.toLocalDate().plusDays(1);
while (!day.isAfter(lastDay)) {
// 해당 날짜의 야간: 전날 22:00 ~ 당일 06:00
ZonedDateTime nightStart = day.atTime(LocalTime.of(22, 0)).atZone(KST).minusDays(1);
ZonedDateTime nightEnd = day.atTime(LocalTime.of(6, 0)).atZone(KST);
// 당일 22:00 ~ 다음날 06:00
ZonedDateTime nightStart2 = day.atTime(LocalTime.of(22, 0)).atZone(KST);
ZonedDateTime nightEnd2 = day.plusDays(1).atTime(LocalTime.of(6, 0)).atZone(KST);
if (isOverlapping(startKst, endKst, nightStart, nightEnd)
|| isOverlapping(startKst, endKst, nightStart2, nightEnd2)) {
return true;
}
day = day.plusDays(1);
}
return false;
}
private boolean isOverlapping(ZonedDateTime s1, ZonedDateTime e1,
ZonedDateTime s2, ZonedDateTime e2) {
return s1.isBefore(e2) && s2.isBefore(e1);
}
// 추정 속도 계산
/**
* 접촉 구간 폴리곤 내부 포인트 거리/시간으로 평균 속도(knots) 추정.
*/
private double estimateAvgSpeed(List<InsidePosition> insidePositions,
long contactStart, long contactEnd) {
// 접촉 구간에 해당하는 포인트만 필터
List<InsidePosition> contactPoints = new ArrayList<>();
for (InsidePosition pos : insidePositions) {
if (pos.timestamp >= contactStart && pos.timestamp <= contactEnd) {
contactPoints.add(pos);
}
}
if (contactPoints.size() < 2) return 0.0;
double totalDistNm = 0;
for (int i = 1; i < contactPoints.size(); i++) {
InsidePosition prev = contactPoints.get(i - 1);
InsidePosition curr = contactPoints.get(i);
totalDistNm += haversineNm(prev.lat, prev.lon, curr.lat, curr.lon);
}
long firstTs = contactPoints.get(0).timestamp;
long lastTs = contactPoints.get(contactPoints.size() - 1).timestamp;
double totalHours = (lastTs - firstTs) / 3600.0;
return totalHours > 0 ? totalDistNm / totalHours : 0.0;
}
// Haversine 거리 계산
private double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_M * c;
}
private double haversineNm(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_NM * c;
}
private long parseTimestamp(List<String> timestamps, int index) {
if (timestamps == null || index >= timestamps.size()) return 0L;
try {
return Long.parseLong(timestamps.get(index));
} catch (NumberFormatException e) {
return 0L;
}
}
// 응답
private VesselContactResponse buildEmptyResponse(
VesselContactRequest request, List<LocalDate> targetDates, long startMs) {
long elapsedMs = System.currentTimeMillis() - startMs;
return VesselContactResponse.builder()
.contacts(Collections.emptyList())
.tracks(Collections.emptyList())
.summary(VesselContactSummary.builder()
.totalContactPairs(0)
.totalVesselsInvolved(0)
.totalVesselsInPolygon(0)
.processingTimeMs(elapsedMs)
.polygonId(request.getPolygon().getId())
.cachedDates(targetDates.stream()
.map(LocalDate::toString)
.collect(Collectors.toList()))
.build())
.build();
}
// 내부 데이터 클래스
private static class InsidePosition {
final long timestamp;
final double lon;
final double lat;
InsidePosition(long timestamp, double lon, double lat) {
this.timestamp = timestamp;
this.lon = lon;
this.lat = lat;
}
}
private static class MatchedPoint {
final long timestamp;
final double distanceMeters;
final InsidePosition posA;
final InsidePosition posB;
MatchedPoint(long timestamp, double distanceMeters,
InsidePosition posA, InsidePosition posB) {
this.timestamp = timestamp;
this.distanceMeters = distanceMeters;
this.posA = posA;
this.posB = posB;
}
}
}