diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java b/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java index 13fe75b..4e724e7 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java @@ -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 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> handleBadRequest(IllegalArgumentException e) { log.warn("Area search bad request: {}", e.getMessage()); diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/dto/VesselContactRequest.java b/src/main/java/gc/mda/signal_batch/domain/gis/dto/VesselContactRequest.java new file mode 100644 index 0000000..d896868 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/dto/VesselContactRequest.java @@ -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 coordinates; + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/dto/VesselContactResponse.java b/src/main/java/gc/mda/signal_batch/domain/gis/dto/VesselContactResponse.java new file mode 100644 index 0000000..b3e2d8b --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/dto/VesselContactResponse.java @@ -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 contacts; + + @Schema(description = "관련 선박의 전체 기간 항적 (CompactVesselTrack)") + private List 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 cachedDates; + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java b/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java index 50be136..5cb6eb2 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java @@ -142,17 +142,23 @@ public class AreaSearchService { } private void validatePolygon(SearchPolygon polygon) { - List coords = polygon.getCoordinates(); + validatePolygon(polygon.getId(), polygon.getCoordinates()); + } + + /** + * 폴리곤 유효성 검증 (package-private, VesselContactService에서 재사용). + */ + void validatePolygon(String polygonId, List 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 collectTargetDates(LocalDateTime startTime, LocalDateTime endTime) { + List 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 coordinates) { + Polygon toJtsPolygon(List 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 tracks) { + STRtree buildSpatialIndex(Map tracks) { STRtree tree = new STRtree(); for (Map.Entry entry : tracks.entrySet()) { CompactVesselTrack track = entry.getValue(); diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/service/VesselContactService.java b/src/main/java/gc/mda/signal_batch/domain/gis/service/VesselContactService.java new file mode 100644 index 0000000..568b6d1 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/VesselContactService.java @@ -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 targetDates = areaSearchService.collectTargetDates( + request.getStartTime(), request.getEndTime()); + if (targetDates.isEmpty()) { + return buildEmptyResponse(request, targetDates, startMs); + } + + Map mergedTracks = areaSearchService.mergeMultipleDays(targetDates); + if (mergedTracks.isEmpty()) { + return buildEmptyResponse(request, targetDates, startMs); + } + + // 3. sigSrcCd 필터 + String targetSigSrcCd = request.getSigSrcCd(); + Map filtered = new HashMap<>(); + for (Map.Entry 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 candidates = spatialIndex.query(mbr); + + long minDurationSec = request.getMinContactDurationMinutes() * 60L; + double maxDistanceMeters = request.getMaxContactDistanceMeters(); + + Map> insidePositions = new HashMap<>(); + for (String vesselId : candidates) { + CompactVesselTrack track = filtered.get(vesselId); + if (track == null || track.getGeometry() == null) continue; + + List 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 vesselIds = new ArrayList<>(insidePositions.keySet()); + List contactPairs = new ArrayList<>(); + Set involvedVessels = new HashSet<>(); + + for (int i = 0; i < vesselIds.size(); i++) { + String idA = vesselIds.get(i); + List 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 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 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 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 collectInsidePositions( + CompactVesselTrack track, PreparedGeometry prepared) { + List geometry = track.getGeometry(); + List timestamps = track.getTimestamps(); + List 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 detectContacts( + String idA, List posA, + String idB, List posB, + CompactVesselTrack trackA, CompactVesselTrack trackB, + long minDurationSec, double maxDistanceMeters) { + + // Step 1: Two-pointer 매칭 (거리 임계값 없이 모두 수집) + List matched = twoPointerMatch(posA, posB); + if (matched.isEmpty()) return Collections.emptyList(); + + // Step 2: 연속 세그먼트 분리 (갭 > MAX_GAP) + List> segments = splitByGap(matched); + + // Step 3: 세그먼트별 평균 거리 판정 + List results = new ArrayList<>(); + for (List 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 twoPointerMatch( + List posA, List posB) { + List 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> splitByGap(List matched) { + List> segments = new ArrayList<>(); + List 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 segment, + String idA, List insidePosA, + String idB, List 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 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 insidePositions, + long contactStart, long contactEnd) { + // 접촉 구간에 해당하는 포인트만 필터 + List 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 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 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; + } + } +}