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:
부모
a3de69772a
커밋
fb72be89a1
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user