Init
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
class PcpCoverageSchemeServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<PcpCoverageSchemeServiceApplication>(*args)
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.client
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.netty.http.client.HttpClient
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class BallisticsClient(
|
||||
webClientBuilderProvider: ObjectProvider<WebClient.Builder>,
|
||||
private val properties: CoverageSchemeProperties
|
||||
) : CoverageBallisticsClient {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
|
||||
private fun client(): WebClient =
|
||||
webClientBuilder
|
||||
.baseUrl(properties.ballisticsServiceUrl)
|
||||
.clientConnector(
|
||||
ReactorClientHttpConnector(
|
||||
HttpClient.create().responseTimeout(Duration.ofSeconds(properties.webClientResponseTimeoutSeconds))
|
||||
)
|
||||
)
|
||||
.codecs { codecs -> codecs.defaultCodecs().maxInMemorySize(properties.webClientInMemorySizeMb * 1024 * 1024) }
|
||||
.build()
|
||||
|
||||
override fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO> =
|
||||
client()
|
||||
.post()
|
||||
.uri("/api/obj-view/mpl-square")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(req)
|
||||
.retrieve()
|
||||
.bodyToFlux(SquareViewParamDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
override fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO> =
|
||||
client()
|
||||
.post()
|
||||
.uri("/api/obj-view/mpl-point")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(req)
|
||||
.retrieve()
|
||||
.bodyToFlux(PointViewParamDTO::class.java)
|
||||
.collectList()
|
||||
.doOnError { error ->
|
||||
logger.warn(
|
||||
"Failed to fetch mpl point from ballistics-service: satellites={}, interval=[{} - {}], error={}",
|
||||
req.satellites,
|
||||
req.timeStart,
|
||||
req.timeStop,
|
||||
error.message
|
||||
)
|
||||
}
|
||||
.onErrorMap { error ->
|
||||
CustomErrorException("Не удалось получить mpl-point из баллистики: ${error.message}")
|
||||
}
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
override fun flightLine(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime): List<FlightLineDTO> =
|
||||
client()
|
||||
.get()
|
||||
.uri { uriBuilder ->
|
||||
uriBuilder
|
||||
.path("/api/satellites/{satelliteId}/flight-line")
|
||||
.queryParam("time_start", timeStart)
|
||||
.queryParam("time_stop", timeStop)
|
||||
.build(satelliteId)
|
||||
}
|
||||
.retrieve()
|
||||
.bodyToFlux(FlightLineDTO::class.java)
|
||||
.collectList()
|
||||
.doOnError { error ->
|
||||
logger.warn(
|
||||
"Failed to fetch flight line from ballistics-service: satelliteId={}, interval=[{} - {}], error={}",
|
||||
satelliteId,
|
||||
timeStart,
|
||||
timeStop,
|
||||
error.message
|
||||
)
|
||||
}
|
||||
.onErrorMap { error ->
|
||||
CustomErrorException("Не удалось получить трассу полета для КА $satelliteId: ${error.message}")
|
||||
}
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
override fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List<OrbPointDTO> =
|
||||
client()
|
||||
.post()
|
||||
.uri("/api/satellites/{satelliteId}/extract-time", satelliteId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToFlux(OrbPointDTO::class.java)
|
||||
.collectList()
|
||||
.doOnError { error ->
|
||||
logger.warn(
|
||||
"Failed to fetch orbital points from ballistics-service: satelliteId={}, interval=[{} - {}], stepMs={}, error={}",
|
||||
satelliteId,
|
||||
request.time,
|
||||
request.timeStop,
|
||||
request.stepMs,
|
||||
error.message
|
||||
)
|
||||
}
|
||||
.onErrorMap { error ->
|
||||
CustomErrorException("Не удалось получить точки орбиты для КА $satelliteId: ${error.message}")
|
||||
}
|
||||
.block()
|
||||
?: emptyList()
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.client
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.netty.http.client.HttpClient
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
|
||||
import java.time.Duration
|
||||
|
||||
@Service
|
||||
class ComplexMissionService(
|
||||
webClientBuilderProvider: ObjectProvider<WebClient.Builder>,
|
||||
private val properties: CoverageSchemeProperties
|
||||
) : CoverageSatelliteClient {
|
||||
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
|
||||
private fun client(): WebClient =
|
||||
webClientBuilder
|
||||
.baseUrl(properties.satelliteCatalogServiceUrl)
|
||||
.clientConnector(
|
||||
ReactorClientHttpConnector(
|
||||
HttpClient.create().responseTimeout(Duration.ofSeconds(properties.webClientResponseTimeoutSeconds))
|
||||
)
|
||||
)
|
||||
.codecs { codecs -> codecs.defaultCodecs().maxInMemorySize(properties.webClientInMemorySizeMb * 1024 * 1024) }
|
||||
.build()
|
||||
|
||||
override fun satellite(satelliteId: Long): SatelliteInfoDTO =
|
||||
client()
|
||||
.get()
|
||||
.uri("/api/satellites/{satelliteId}", satelliteId)
|
||||
.retrieve()
|
||||
.bodyToMono(SatelliteDTO::class.java)
|
||||
.map { satellite ->
|
||||
SatelliteInfoDTO(
|
||||
noradId = satellite.id,
|
||||
name = satellite.name,
|
||||
red = satellite.visualization.red,
|
||||
green = satellite.visualization.green,
|
||||
blue = satellite.visualization.blue,
|
||||
captureAngle = satellite.observationProfile?.captureAngle ?: 1.5,
|
||||
scanTLE = satellite.scanTle,
|
||||
maxSurveyDurationSeconds = satellite.observationProfile?.durationMaxSeconds,
|
||||
mmiSeconds = satellite.observationProfile?.mmiSeconds,
|
||||
code = satellite.code,
|
||||
typeCode = satellite.typeCode,
|
||||
active = satellite.active
|
||||
)
|
||||
}
|
||||
.onErrorMap { error ->
|
||||
CustomErrorException("Не удалось получить параметры КА $satelliteId: ${error.message}")
|
||||
}
|
||||
.block()
|
||||
?: throw CustomErrorException("Не удалось получить параметры КА $satelliteId: пустой ответ satellite-catalog-service")
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.client
|
||||
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface CoverageBallisticsClient {
|
||||
fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO>
|
||||
fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO>
|
||||
fun flightLine(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime): List<FlightLineDTO>
|
||||
fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List<OrbPointDTO>
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.client
|
||||
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
|
||||
|
||||
interface CoverageSatelliteClient {
|
||||
fun satellite(satelliteId: Long): SatelliteInfoDTO
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
|
||||
@ConfigurationProperties("coverage-scheme")
|
||||
data class CoverageSchemeProperties(
|
||||
val ballisticsServiceUrl: String = "http://localhost:7003",
|
||||
val complexMissionServiceUrl: String = "http://localhost:7002",
|
||||
val satelliteCatalogServiceUrl: String = complexMissionServiceUrl,
|
||||
val defaultRollStepDegrees: Double = 5.0,
|
||||
val defaultMinimumTechnologyOverlap: Double = 0.05,
|
||||
val defaultAllowPartialCoverage: Boolean = true,
|
||||
val boundaryTouchBuffer: Double = 0.05,
|
||||
val continuityGapTolerance: Double = 0.000001,
|
||||
val defaultOrientationToleranceDegrees: Double = 15.0,
|
||||
val maxRollSamplesPerWindow: Int = 7,
|
||||
val flightLinePaddingSeconds: Long = 120,
|
||||
val webClientResponseTimeoutSeconds: Long = 30,
|
||||
val webClientInMemorySizeMb: Int = 20,
|
||||
val fallbackSwathWidthDegrees: Double = 1.0,
|
||||
val fallbackShiftPerRollDegree: Double = 0.02,
|
||||
val defaultMinimumTimeBetweenModesSeconds: Long = 0
|
||||
)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
|
||||
|
||||
class CustomErrorException(message: String) : RuntimeException(message)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
|
||||
|
||||
class CustomValidationException(message: String) : RuntimeException(message)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(CustomValidationException::class)
|
||||
fun handleValidation(ex: CustomValidationException): ResponseEntity<Map<String, String>> =
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (ex.message ?: "validation error")))
|
||||
|
||||
@ExceptionHandler(CustomErrorException::class)
|
||||
fun handleCustomError(ex: CustomErrorException): ResponseEntity<Map<String, String>> =
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (ex.message ?: "error")))
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
fun handleMethodArgumentNotValid(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
|
||||
val message = ex.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "validation error"
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to message))
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI
|
||||
import io.swagger.v3.oas.models.info.Info
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* Базовая OpenAPI-конфигурация для REST API сервиса схем покрытия.
|
||||
*/
|
||||
@Configuration
|
||||
class OpenApiConfig {
|
||||
@Bean
|
||||
fun coverageSchemeOpenApi(): OpenAPI {
|
||||
return OpenAPI()
|
||||
.info(
|
||||
Info()
|
||||
.title("PCP Coverage Scheme Service API")
|
||||
.description("REST API сервиса расчета схем беспрерывного покрытия PCP.")
|
||||
.version("v1"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.service.CoverageSchemeCalculationService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/coverage-schemes")
|
||||
@Tag(name = "Coverage Schemes", description = "Расчет схем беспрерывного покрытия полигона контурами режимов съемки")
|
||||
class CoverageSchemeController(
|
||||
private val coverageSchemeCalculationService: CoverageSchemeCalculationService
|
||||
) {
|
||||
|
||||
@PostMapping("/calculate")
|
||||
@Operation(summary = "Рассчитать схему беспрерывного покрытия полигона")
|
||||
fun calculate(
|
||||
@Valid @RequestBody request: CoverageSchemeCalculateRequestDTO
|
||||
) = coverageSchemeCalculationService.calculate(request)
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.model
|
||||
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.geom.Point
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class CoverageCandidateMode(
|
||||
val window: CoverageObservationWindow,
|
||||
val rollDegrees: Double,
|
||||
val contour: Geometry,
|
||||
val targetCoverage: Geometry,
|
||||
val anchor: Point,
|
||||
val centerShift: Double,
|
||||
val generationDirectionDegrees: Double,
|
||||
val startBoundary: String,
|
||||
val sequenceSeed: Int
|
||||
) {
|
||||
|
||||
val timeStart: LocalDateTime get() = window.timeStart
|
||||
val timeEnd: LocalDateTime get() = window.timeEnd
|
||||
val branch: RevolutionSign get() = window.branch
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.model
|
||||
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class CoverageObservationWindow(
|
||||
val satelliteId: Long,
|
||||
val timeStart: LocalDateTime,
|
||||
val timeEnd: LocalDateTime,
|
||||
val branch: RevolutionSign,
|
||||
val rollMinDegrees: Double,
|
||||
val rollMaxDegrees: Double
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.model
|
||||
|
||||
import org.locationtech.jts.geom.Coordinate
|
||||
import org.locationtech.jts.geom.Point
|
||||
|
||||
data class CoverageWindowGeometry(
|
||||
val centerLine: List<Coordinate>,
|
||||
val leftBoundary: List<Coordinate>,
|
||||
val rightBoundary: List<Coordinate>,
|
||||
val anchor: Point,
|
||||
val directionDegrees: Double,
|
||||
val averageSwathWidth: Double
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.model
|
||||
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
|
||||
data class SelectedCoverageMode(
|
||||
val candidate: CoverageCandidateMode,
|
||||
val sequence: Int,
|
||||
val newCoverage: Geometry,
|
||||
val overlapWithCovered: Geometry,
|
||||
val continuityOverlapArea: Double,
|
||||
val score: Double,
|
||||
val selectionReason: String
|
||||
)
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.service
|
||||
|
||||
import ballistics.flightLine.PointOnEarthCalculator
|
||||
import ballistics.types.EarthType
|
||||
import ballistics.types.Orientation
|
||||
import ballistics.types.OrbitalPoint
|
||||
import ballistics.types.THBLPoint
|
||||
import ballistics.types.WorkCSType
|
||||
import ballistics.utils.fromDateTime
|
||||
import ballistics.utils.math.Vector3D
|
||||
import org.locationtech.jts.geom.Coordinate
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.geom.GeometryFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Service
|
||||
class CoverageSchemeBuilderService(
|
||||
private val ballisticsClient: CoverageBallisticsClient,
|
||||
private val satelliteClient: CoverageSatelliteClient,
|
||||
private val geometrySupport: GeometrySupport
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val geometryFactory = GeometryFactory()
|
||||
|
||||
fun buildCoverageScheme(
|
||||
observations: List<CoverageObservationDTO>,
|
||||
rollStepDegrees: Double? = null,
|
||||
longitudeModel: GeometrySupport.LongitudeModel? = null
|
||||
): List<SurveySlotDTO> {
|
||||
val satelliteParametersBySatellite = HashMap<Long, SatelliteSurveyParameters>()
|
||||
val selectedBranch = selectDominantBranch(observations)
|
||||
val selectedObservations = observations.filter { it.observation.revSignBegin == selectedBranch }
|
||||
|
||||
logger.info(
|
||||
"Selected observation branch for coverage scheme: branch={}, ascCount={}, descCount={}, selectedCount={}",
|
||||
selectedBranch,
|
||||
observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC },
|
||||
observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC },
|
||||
selectedObservations.size
|
||||
)
|
||||
|
||||
val preparedObservations = selectedObservations.flatMap { observation ->
|
||||
prepareObservation(observation, satelliteParametersBySatellite, longitudeModel)
|
||||
}.sortedWith(
|
||||
compareBy<PreparedObservation>(
|
||||
{ it.leftLongitude },
|
||||
{ if (selectedBranch == RevolutionSign.DESC) it.observation.observation.gammaMax else 0.0},
|
||||
{ it.segmentTimeBegin },
|
||||
{ it.segmentTimeEnd },
|
||||
{ it.observation.observation.noradId }
|
||||
)
|
||||
)
|
||||
|
||||
if (preparedObservations.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val selectedContours = ArrayList<SelectedContour>(preparedObservations.size)
|
||||
var previousContour: SelectedContour? = null
|
||||
var requiredRollSign: Int? = null
|
||||
var coveredTargetArea: Geometry = geometryFactory.createPolygon()
|
||||
|
||||
preparedObservations.forEach { observation ->
|
||||
val selectedContour = selectContour(observation, previousContour, requiredRollSign) ?: return@forEach
|
||||
if (!shouldAddContour(selectedContour, coveredTargetArea)) {
|
||||
return@forEach
|
||||
}
|
||||
selectedContours += selectedContour
|
||||
previousContour = selectedContour
|
||||
coveredTargetArea = if (coveredTargetArea.isEmpty) {
|
||||
selectedContour.targetCoverage
|
||||
} else {
|
||||
geometrySupport.safeUnion(coveredTargetArea, selectedContour.targetCoverage)
|
||||
}
|
||||
if (requiredRollSign == null) {
|
||||
requiredRollSign = rollSign(selectedContour.roll)
|
||||
}
|
||||
}
|
||||
|
||||
return selectedContours.map { selectedContour ->
|
||||
val observation = selectedContour.observation.observation.observation
|
||||
val viewPoint = viewParams(
|
||||
PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit),
|
||||
selectedContour.observation.orbitalPoints.first().toOrbitalPoint(),
|
||||
selectedContour.roll
|
||||
)
|
||||
SurveySlotDTO(
|
||||
satelliteId = observation.noradId,
|
||||
tn = selectedContour.observation.segmentTimeBegin,
|
||||
tk = selectedContour.observation.segmentTimeEnd,
|
||||
roll = selectedContour.roll,
|
||||
contour = geometrySupport.toWkt(selectedContour.contour),
|
||||
revolution = observation.revolutionBegin,
|
||||
revolutionSign = observation.revSignBegin,
|
||||
latitude = viewPoint?.lat?.toDegrees() ?: 0.0,
|
||||
longitude = normalizeLongitude(
|
||||
viewPoint?.long?.toDegrees() ?: 0.0,
|
||||
selectedContour.observation.longitudeModel
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareObservation(
|
||||
observation: CoverageObservationDTO,
|
||||
satelliteParametersBySatellite: MutableMap<Long, SatelliteSurveyParameters>,
|
||||
longitudeModel: GeometrySupport.LongitudeModel?
|
||||
): List<PreparedObservation> {
|
||||
val resolvedLongitudeModel = longitudeModel
|
||||
?: observation.intersectionContourWkts
|
||||
.firstOrNull { it.isNotBlank() }
|
||||
?.let(geometrySupport::resolveLongitudeModel)
|
||||
?: GeometrySupport.LongitudeModel.POSITIVE_360
|
||||
val intersectionContour = observation.intersectionContourWkts
|
||||
.filter { it.isNotBlank() }
|
||||
.map { geometrySupport.parseAndNormalizeTarget(it, resolvedLongitudeModel) }
|
||||
.reduceOrNull(geometrySupport::safeUnion)
|
||||
|
||||
if (intersectionContour == null || intersectionContour.isEmpty) {
|
||||
logger.debug(
|
||||
"Skip coverage scheme slot because target intersection contour is empty: satelliteId={}, interval=[{} - {}]",
|
||||
observation.observation.noradId,
|
||||
observation.observation.timeBegin,
|
||||
observation.observation.timeEnd
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val satelliteParameters = satelliteParametersBySatellite.getOrPut(observation.observation.noradId) {
|
||||
satelliteClient.satellite(observation.observation.noradId).toSurveyParameters()
|
||||
}
|
||||
|
||||
return splitObservationIntervals(observation, satelliteParameters)
|
||||
.mapNotNull { segment ->
|
||||
val orbitalPoints = ballisticsClient.exactTime(
|
||||
observation.observation.noradId,
|
||||
ExactTimePositionRequestDTO(
|
||||
time = segment.timeBegin,
|
||||
timeStop = segment.timeEnd,
|
||||
stepMs = contourStepMs(segment.timeBegin, segment.timeEnd)
|
||||
)
|
||||
).sortedBy { it.time }
|
||||
|
||||
if (orbitalPoints.isEmpty()) {
|
||||
logger.debug(
|
||||
"Skip coverage scheme slot because orbital points are empty: satelliteId={}, interval=[{} - {}]",
|
||||
observation.observation.noradId,
|
||||
segment.timeBegin,
|
||||
segment.timeEnd
|
||||
)
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
PreparedObservation(
|
||||
observation = observation,
|
||||
intersectionContour = intersectionContour,
|
||||
orbitalPoints = orbitalPoints,
|
||||
capture = satelliteParameters.captureAngle,
|
||||
leftLongitude = intersectionContour.envelopeInternal.minX,
|
||||
longitudeModel = resolvedLongitudeModel,
|
||||
segmentTimeBegin = segment.timeBegin,
|
||||
segmentTimeEnd = segment.timeEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectContour(
|
||||
observation: PreparedObservation,
|
||||
previousContour: SelectedContour?,
|
||||
requiredRollSign: Int?
|
||||
): SelectedContour? {
|
||||
val branch = observation.observation.observation.revSignBegin
|
||||
val defaultRoll = resolveCompatibleRoll(
|
||||
candidate = defaultRoll(branch, observation),
|
||||
branch = branch,
|
||||
observation = observation,
|
||||
requiredRollSign = requiredRollSign
|
||||
) ?: run {
|
||||
logger.warn(
|
||||
"Skip coverage scheme slot because interval does not support required roll sign: satelliteId={}, interval=[{} - {}], requiredSign={}",
|
||||
observation.observation.observation.noradId,
|
||||
observation.observation.observation.timeBegin,
|
||||
observation.observation.observation.timeEnd,
|
||||
requiredRollSign
|
||||
)
|
||||
return null
|
||||
}
|
||||
val defaultContour = buildContour(observation, defaultRoll) ?: return null
|
||||
|
||||
if (previousContour == null) {
|
||||
return defaultContour
|
||||
}
|
||||
|
||||
if (!intersects(observation.intersectionContour, previousContour.contour)) {
|
||||
return defaultContour
|
||||
}
|
||||
|
||||
val rollFromIntersection = resolveIntersectingRoll(observation, previousContour)
|
||||
?.let { candidate ->
|
||||
resolveCompatibleRoll(
|
||||
candidate = candidate,
|
||||
branch = branch,
|
||||
observation = observation,
|
||||
requiredRollSign = requiredRollSign
|
||||
)
|
||||
}
|
||||
?: return defaultContour
|
||||
return buildContour(observation, rollFromIntersection) ?: defaultContour
|
||||
}
|
||||
|
||||
private fun buildContour(observation: PreparedObservation, roll: Double): SelectedContour? {
|
||||
val contour = contour(observation.orbitalPoints, roll, observation.capture, observation.intersectionContour) ?: return null
|
||||
if (contour.isEmpty) {
|
||||
return null
|
||||
}
|
||||
val targetCoverage = geometrySupport.safeIntersection(contour, observation.intersectionContour)
|
||||
return SelectedContour(
|
||||
observation = observation,
|
||||
roll = roll,
|
||||
contour = contour,
|
||||
targetCoverage = targetCoverage,
|
||||
overlapAnchor = overlapAnchorPoint(
|
||||
branch = observation.observation.observation.revSignBegin,
|
||||
points = observation.orbitalPoints,
|
||||
roll = roll,
|
||||
capture = observation.capture,
|
||||
longitudeModel = observation.longitudeModel
|
||||
) ?: contour.coordinates.firstOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun contour(points: List<OrbPointDTO>, roll: Double, capture: Double, target: Geometry): Geometry? {
|
||||
return try {
|
||||
val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
|
||||
val rightCoordinates = ArrayList<Coordinate>(points.size)
|
||||
val leftCoordinates = ArrayList<Coordinate>(points.size)
|
||||
|
||||
points.map { it.toOrbitalPoint() }.forEach { point ->
|
||||
val rightPoint = viewParams(pointOnEarthCalculator, point, roll + capture) ?: THBLPoint(0.0, 0.0, 0.0, 0.0)
|
||||
rightCoordinates += Coordinate(rightPoint.long.toDegrees(), rightPoint.lat.toDegrees(), 0.0)
|
||||
|
||||
val leftPoint = viewParams(pointOnEarthCalculator, point, roll - capture) ?: THBLPoint(0.0, 0.0, 0.0, 0.0)
|
||||
leftCoordinates += Coordinate(leftPoint.long.toDegrees(), leftPoint.lat.toDegrees(), 0.0)
|
||||
}
|
||||
|
||||
val alignedRightCoordinates = geometrySupport.alignTrackCoordinatesToTarget(rightCoordinates, target)
|
||||
val alignedLeftCoordinates = geometrySupport.alignTrackCoordinatesToTarget(leftCoordinates, target)
|
||||
val shellCoordinates = ArrayList<Coordinate>(rightCoordinates.size + leftCoordinates.size + 1)
|
||||
shellCoordinates.addAll(alignedRightCoordinates)
|
||||
for (index in alignedLeftCoordinates.indices.reversed()) {
|
||||
shellCoordinates += alignedLeftCoordinates[index]
|
||||
}
|
||||
if (shellCoordinates.isNotEmpty()) {
|
||||
shellCoordinates += Coordinate(shellCoordinates.first().x, shellCoordinates.first().y, 0.0)
|
||||
}
|
||||
if (shellCoordinates.size < 4) {
|
||||
return null
|
||||
}
|
||||
|
||||
val shell = geometryFactory.createLinearRing(shellCoordinates.toTypedArray())
|
||||
geometryFactory.createPolygon(shell).buffer(0.0)
|
||||
} catch (error: Exception) {
|
||||
logger.warn("Failed to build coverage scheme contour", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewParams(
|
||||
pointOnEarthCalculator: PointOnEarthCalculator,
|
||||
point: OrbitalPoint,
|
||||
gamma: Double
|
||||
): THBLPoint? =
|
||||
pointOnEarthCalculator.pointOnEarth(point, Orientation(0.0, gamma * PI / 180.0, 0.0))
|
||||
|
||||
private fun selectDominantBranch(observations: List<CoverageObservationDTO>) =
|
||||
if (observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC } >=
|
||||
observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC }
|
||||
) {
|
||||
space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC
|
||||
} else {
|
||||
space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC
|
||||
}
|
||||
|
||||
private fun intersects(first: Geometry, second: Geometry): Boolean =
|
||||
!geometrySupport.safeIntersection(first, second).isEmpty
|
||||
|
||||
private fun defaultRoll(branch: RevolutionSign, observation: PreparedObservation): Double =
|
||||
when (branch) {
|
||||
RevolutionSign.ASC -> max(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
|
||||
RevolutionSign.DESC -> min(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
|
||||
}
|
||||
|
||||
private fun resolveCompatibleRoll(
|
||||
candidate: Double,
|
||||
branch: RevolutionSign,
|
||||
observation: PreparedObservation,
|
||||
requiredRollSign: Int?
|
||||
): Double? {
|
||||
if (requiredRollSign == null || rollSign(candidate) == requiredRollSign) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
val gammaMin = min(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
|
||||
val gammaMax = max(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
|
||||
|
||||
return when (requiredRollSign) {
|
||||
1 -> when {
|
||||
gammaMin > 0.0 && branch == RevolutionSign.DESC -> gammaMin
|
||||
gammaMax > 0.0 -> gammaMax
|
||||
else -> null
|
||||
}
|
||||
-1 -> when {
|
||||
gammaMax < 0.0 && branch == RevolutionSign.ASC -> gammaMax
|
||||
gammaMin < 0.0 -> gammaMin
|
||||
else -> null
|
||||
}
|
||||
else -> if (gammaMin <= 0.0 && gammaMax >= 0.0) 0.0 else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveIntersectingRoll(
|
||||
observation: PreparedObservation,
|
||||
previousContour: SelectedContour
|
||||
): Double? {
|
||||
val previousContourCoordinates = previousContour.contour.coordinates
|
||||
if (previousContourCoordinates.size < 4) {
|
||||
logger.warn(
|
||||
"Unable to resolve intersection roll because previous contour geometry is too short: previousSatelliteId={}, currentSatelliteId={}, points={}",
|
||||
previousContour.observation.observation.observation.noradId,
|
||||
observation.observation.observation.noradId,
|
||||
previousContourCoordinates.size
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
val rightmostStartPoint = listOf(
|
||||
previousContourCoordinates.first(),
|
||||
previousContourCoordinates[previousContourCoordinates.size - 2]
|
||||
).maxByOrNull { it.x } ?: return null
|
||||
|
||||
val mplPoints = ballisticsClient.mplPoint(
|
||||
ObjViewRequestDTO(
|
||||
satellites = listOf(observation.observation.observation.noradId),
|
||||
timeStart = observation.segmentTimeBegin.minusSeconds(200),
|
||||
timeStop = observation.segmentTimeEnd.plusSeconds(200),
|
||||
obj = ObjDTO(
|
||||
id = "coverage-scheme-overlap",
|
||||
position = PositionDTO(
|
||||
lat = rightmostStartPoint.y,
|
||||
long = normalizeLongitude(rightmostStartPoint.x, observation.longitudeModel),
|
||||
height = 0.0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (mplPoints.size != 1) {
|
||||
logger.warn(
|
||||
"Intersection roll lookup returned unexpected mplPoint count, fallback to no-intersection behavior: currentSatelliteId={}, interval=[{} - {}], mplPointCount={}",
|
||||
observation.observation.observation.noradId,
|
||||
observation.segmentTimeBegin,
|
||||
observation.segmentTimeEnd,
|
||||
mplPoints.size
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
val mplGamma = mplPoints.single().gamma
|
||||
val currentPoint = observation.orbitalPoints.firstOrNull()?.toOrbitalPoint() ?: return null
|
||||
val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
|
||||
val captureHalf = observation.capture
|
||||
val gammaCandidates = listOf(mplGamma - captureHalf, mplGamma + captureHalf)
|
||||
val longitudeReference = rightmostStartPoint.x
|
||||
|
||||
return gammaCandidates
|
||||
.mapNotNull { gamma ->
|
||||
pointOnEarthCalculator.pointOnEarth(
|
||||
currentPoint,
|
||||
Orientation(0.0, gamma * PI / 180.0, 0.0)
|
||||
)?.let { pointOnEarth ->
|
||||
var adjustedLongitude = pointOnEarth.long.toDegrees()
|
||||
while (adjustedLongitude - longitudeReference > 180.0) adjustedLongitude -= 360.0
|
||||
while (adjustedLongitude - longitudeReference < -180.0) adjustedLongitude += 360.0
|
||||
gamma to adjustedLongitude
|
||||
}
|
||||
}
|
||||
.maxByOrNull { (_, longitude) -> longitude }
|
||||
?.first
|
||||
}
|
||||
|
||||
private fun shouldAddContour(candidate: SelectedContour, coveredTargetArea: Geometry): Boolean {
|
||||
if (candidate.targetCoverage.isEmpty || candidate.targetCoverage.area <= 0.0) {
|
||||
logger.info(
|
||||
"Skip coverage scheme slot because candidate does not intersect required target area: satelliteId={}, interval=[{} - {}]",
|
||||
candidate.observation.observation.observation.noradId,
|
||||
candidate.observation.segmentTimeBegin,
|
||||
candidate.observation.segmentTimeEnd
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (coveredTargetArea.isEmpty) {
|
||||
return true
|
||||
}
|
||||
|
||||
val overlapArea = geometrySupport.safeIntersection(candidate.targetCoverage, coveredTargetArea).area
|
||||
val overlapRatio = overlapArea / candidate.targetCoverage.area
|
||||
if (overlapRatio > 0.9) {
|
||||
logger.info(
|
||||
"Skip coverage scheme slot because candidate overlaps existing coverage too much: satelliteId={}, interval=[{} - {}], overlapRatio={}",
|
||||
candidate.observation.observation.observation.noradId,
|
||||
candidate.observation.segmentTimeBegin,
|
||||
candidate.observation.segmentTimeEnd,
|
||||
overlapRatio
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun overlapAnchorPoint(
|
||||
branch: RevolutionSign,
|
||||
points: List<OrbPointDTO>,
|
||||
roll: Double,
|
||||
capture: Double,
|
||||
longitudeModel: GeometrySupport.LongitudeModel
|
||||
): Coordinate? {
|
||||
val firstPoint = points.firstOrNull()?.toOrbitalPoint() ?: return null
|
||||
val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
|
||||
val boundaryPoint = when (branch) {
|
||||
RevolutionSign.ASC -> viewParams(pointOnEarthCalculator, firstPoint, roll - capture)
|
||||
RevolutionSign.DESC -> viewParams(pointOnEarthCalculator, firstPoint, roll + capture)
|
||||
} ?: return null
|
||||
return Coordinate(
|
||||
normalizeLongitude(boundaryPoint.long.toDegrees(), longitudeModel),
|
||||
boundaryPoint.lat.toDegrees(),
|
||||
0.0
|
||||
)
|
||||
}
|
||||
|
||||
private fun splitObservationIntervals(
|
||||
observation: CoverageObservationDTO,
|
||||
satelliteParameters: SatelliteSurveyParameters
|
||||
): List<ObservationSegment> {
|
||||
val maxDurationSeconds = satelliteParameters.maxSurveyDurationSeconds.coerceAtLeast(1L)
|
||||
val mmiSeconds = satelliteParameters.mmiSeconds.coerceAtLeast(0L)
|
||||
val segments = mutableListOf<ObservationSegment>()
|
||||
var segmentStart = observation.observation.timeBegin
|
||||
|
||||
while (segmentStart.isBefore(observation.observation.timeEnd)) {
|
||||
val segmentEnd = minOf(
|
||||
segmentStart.plusSeconds(maxDurationSeconds),
|
||||
observation.observation.timeEnd
|
||||
)
|
||||
if (!segmentEnd.isAfter(segmentStart)) {
|
||||
break
|
||||
}
|
||||
segments += ObservationSegment(segmentStart, segmentEnd)
|
||||
if (!segmentEnd.isBefore(observation.observation.timeEnd)) {
|
||||
break
|
||||
}
|
||||
segmentStart = segmentEnd.plusSeconds(mmiSeconds)
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
private fun contourStepMs(timeBegin: LocalDateTime, timeEnd: LocalDateTime): Long {
|
||||
val durationMs = max(1L, Duration.between(timeBegin, timeEnd).toMillis())
|
||||
return max(1L, durationMs / 5L)
|
||||
}
|
||||
|
||||
private fun rollSign(roll: Double): Int = when {
|
||||
roll > 0.0 -> 1
|
||||
roll < 0.0 -> -1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun normalizeLongitude(longitude: Double, longitudeModel: GeometrySupport.LongitudeModel): Double =
|
||||
when (longitudeModel) {
|
||||
GeometrySupport.LongitudeModel.STANDARD_180 -> {
|
||||
var normalized = longitude
|
||||
while (normalized > 180.0) normalized -= 360.0
|
||||
while (normalized <= -180.0) normalized += 360.0
|
||||
normalized
|
||||
}
|
||||
GeometrySupport.LongitudeModel.POSITIVE_360 -> {
|
||||
var normalized = longitude
|
||||
while (normalized < 0.0) normalized += 360.0
|
||||
while (normalized >= 360.0) normalized -= 360.0
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
private fun OrbPointDTO.toOrbitalPoint() = OrbitalPoint(
|
||||
t = fromDateTime(time),
|
||||
vit = revolution.toInt(),
|
||||
r = Vector3D(x, y, z),
|
||||
v = Vector3D(vx, vy, vz)
|
||||
)
|
||||
|
||||
private fun Double.toDegrees(): Double = this * 180.0 / PI
|
||||
|
||||
private data class PreparedObservation(
|
||||
val observation: CoverageObservationDTO,
|
||||
val intersectionContour: Geometry,
|
||||
val orbitalPoints: List<OrbPointDTO>,
|
||||
val capture: Double,
|
||||
val leftLongitude: Double,
|
||||
val longitudeModel: GeometrySupport.LongitudeModel,
|
||||
val segmentTimeBegin: LocalDateTime,
|
||||
val segmentTimeEnd: LocalDateTime
|
||||
)
|
||||
|
||||
private data class SelectedContour(
|
||||
val observation: PreparedObservation,
|
||||
val roll: Double,
|
||||
val contour: Geometry,
|
||||
val targetCoverage: Geometry,
|
||||
val overlapAnchor: Coordinate?
|
||||
)
|
||||
|
||||
private data class SatelliteSurveyParameters(
|
||||
val captureAngle: Double,
|
||||
val maxSurveyDurationSeconds: Long,
|
||||
val mmiSeconds: Long
|
||||
)
|
||||
|
||||
private data class ObservationSegment(
|
||||
val timeBegin: LocalDateTime,
|
||||
val timeEnd: LocalDateTime
|
||||
)
|
||||
|
||||
private fun SatelliteInfoDTO.toSurveyParameters() = SatelliteSurveyParameters(
|
||||
captureAngle = captureAngle,
|
||||
maxSurveyDurationSeconds = maxSurveyDurationSeconds ?: 300L,
|
||||
mmiSeconds = mmiSeconds ?: 10L
|
||||
)
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.service
|
||||
|
||||
import org.locationtech.jts.geom.Coordinate
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.geom.GeometryCollection
|
||||
import org.locationtech.jts.geom.GeometryFactory
|
||||
import org.locationtech.jts.geom.MultiPolygon
|
||||
import org.locationtech.jts.geom.Polygon
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO
|
||||
|
||||
@Service
|
||||
class CoverageSchemeCalculationService(
|
||||
private val ballisticsClient: CoverageBallisticsClient,
|
||||
private val coverageSchemeBuilderService: CoverageSchemeBuilderService,
|
||||
private val geometrySupport: GeometrySupport,
|
||||
private val properties: CoverageSchemeProperties
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val geometryFactory = GeometryFactory()
|
||||
|
||||
fun calculate(request: CoverageSchemeCalculateRequestDTO): CoverageSchemeResponseDTO {
|
||||
validateRequest(request)
|
||||
val longitudeModel = geometrySupport.resolveLongitudeModel(request.polygonWkt)
|
||||
val target = geometrySupport.parseAndNormalizeTarget(request.polygonWkt, longitudeModel)
|
||||
val views = ballisticsClient.mplSquare(
|
||||
ObjViewRequestDTO(
|
||||
satellites = request.satelliteIds,
|
||||
timeStart = request.timeStart,
|
||||
timeStop = request.timeEnd,
|
||||
obj = ObjDTO(
|
||||
id = "coverage-scheme-target",
|
||||
contourWKT = request.polygonWkt
|
||||
),
|
||||
sunAngleMin = request.sunAngle
|
||||
)
|
||||
)
|
||||
.sortedWith(
|
||||
compareBy<SquareViewParamDTO>(
|
||||
{ it.noradId },
|
||||
{ it.timeBegin },
|
||||
{ it.timeEnd },
|
||||
{ it.revolutionBegin },
|
||||
{ it.revolutionEnd },
|
||||
{ it.gammaMin },
|
||||
{ it.gammaMax }
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Получено ${views.size} моментов наблюдений")
|
||||
|
||||
val observations = views
|
||||
.map { observation ->
|
||||
CoverageObservationDTO(
|
||||
observation = observation,
|
||||
intersectionContourWkts = buildIntersectionContourWkts(target, observation, longitudeModel)
|
||||
)
|
||||
}
|
||||
val coverageScheme = coverageSchemeBuilderService.buildCoverageScheme(
|
||||
observations = observations,
|
||||
rollStepDegrees = request.rollStepDegrees,
|
||||
longitudeModel = longitudeModel
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Coverage scheme calculated: observations={}, slots={}, satellites={}",
|
||||
observations.size,
|
||||
coverageScheme.size,
|
||||
request.satelliteIds.size
|
||||
)
|
||||
|
||||
return CoverageSchemeResponseDTO(
|
||||
targetPolygonWkt = geometrySupport.toWkt(target),
|
||||
observations = observations,
|
||||
coverageScheme = coverageScheme
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildIntersectionContourWkts(
|
||||
target: Geometry,
|
||||
observation: SquareViewParamDTO,
|
||||
longitudeModel: GeometrySupport.LongitudeModel
|
||||
): List<String> {
|
||||
val paddedStart = observation.timeBegin.minusSeconds(properties.flightLinePaddingSeconds)
|
||||
val paddedEnd = observation.timeEnd.plusSeconds(properties.flightLinePaddingSeconds)
|
||||
val flightLine = ballisticsClient.flightLine(observation.noradId, paddedStart, paddedEnd)
|
||||
.map { normalizeFlightLine(it, longitudeModel) }
|
||||
.sortedBy { it.time }
|
||||
|
||||
if (flightLine.size < 2) {
|
||||
logger.debug(
|
||||
"Skip strip intersection because flight line is too short: satelliteId={}, interval=[{} - {}], points={}",
|
||||
observation.noradId,
|
||||
observation.timeBegin,
|
||||
observation.timeEnd,
|
||||
flightLine.size
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val strip = buildStripGeometry(target, flightLine)
|
||||
if (strip.isEmpty) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val intersection = geometrySupport.safeIntersection(strip, target)
|
||||
return extractPolygons(intersection)
|
||||
.map(geometrySupport::toWkt)
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun normalizeFlightLine(
|
||||
point: FlightLineDTO,
|
||||
longitudeModel: GeometrySupport.LongitudeModel
|
||||
) = FlightLineDTO(
|
||||
time = point.time,
|
||||
revolution = point.revolution,
|
||||
lat = point.lat,
|
||||
long = normalizeLongitude(point.long, longitudeModel),
|
||||
latLeft = point.latLeft,
|
||||
longLeft = normalizeLongitude(point.longLeft, longitudeModel),
|
||||
latInnerLeft = point.latInnerLeft,
|
||||
longInnerLeft = normalizeLongitude(point.longInnerLeft, longitudeModel),
|
||||
latInnerRight = point.latInnerRight,
|
||||
longInnerRight = normalizeLongitude(point.longInnerRight, longitudeModel),
|
||||
latRight = point.latRight,
|
||||
longRight = normalizeLongitude(point.longRight, longitudeModel),
|
||||
revSign = point.revSign
|
||||
)
|
||||
|
||||
private fun normalizeLongitude(
|
||||
longitude: Double,
|
||||
longitudeModel: GeometrySupport.LongitudeModel
|
||||
): Double = when (longitudeModel) {
|
||||
GeometrySupport.LongitudeModel.STANDARD_180 -> normalizeToStandardBand(longitude)
|
||||
GeometrySupport.LongitudeModel.POSITIVE_360 -> normalizeToPositiveBand(longitude)
|
||||
}
|
||||
|
||||
private fun normalizeToStandardBand(longitude: Double): Double {
|
||||
var normalized = longitude % 360.0
|
||||
if (normalized <= -180.0) {
|
||||
normalized += 360.0
|
||||
}
|
||||
if (normalized > 180.0) {
|
||||
normalized -= 360.0
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun normalizeToPositiveBand(longitude: Double): Double {
|
||||
var normalized = longitude % 360.0
|
||||
if (normalized < 0.0) {
|
||||
normalized += 360.0
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun buildStripGeometry(target: Geometry, flightLine: List<FlightLineDTO>): Geometry {
|
||||
val outerLeftBoundary = geometrySupport.alignTrackCoordinatesToTarget(
|
||||
flightLine.map { Coordinate(it.longLeft, it.latLeft, 0.0) },
|
||||
target
|
||||
)
|
||||
val outerRightBoundary = geometrySupport.alignTrackCoordinatesToTarget(
|
||||
flightLine.map { Coordinate(it.longRight, it.latRight, 0.0) },
|
||||
target
|
||||
)
|
||||
|
||||
if (outerLeftBoundary.size < 2 || outerRightBoundary.size < 2) {
|
||||
return geometryFactory.createPolygon()
|
||||
}
|
||||
|
||||
val innerLeftBoundary = geometrySupport.alignTrackCoordinatesToTarget(
|
||||
flightLine.map { Coordinate(it.longInnerLeft, it.latInnerLeft, 0.0) },
|
||||
target
|
||||
)
|
||||
val innerRightBoundary = geometrySupport.alignTrackCoordinatesToTarget(
|
||||
flightLine.map { Coordinate(it.longInnerRight, it.latInnerRight, 0.0) },
|
||||
target
|
||||
)
|
||||
|
||||
val geometries = listOfNotNull(
|
||||
buildRibbonPolygon(outerLeftBoundary, innerLeftBoundary, flightLine),
|
||||
buildRibbonPolygon(innerRightBoundary, outerRightBoundary, flightLine)
|
||||
)
|
||||
if (geometries.isEmpty()) {
|
||||
return geometryFactory.createPolygon()
|
||||
}
|
||||
return geometries.drop(1).fold<Polygon, Geometry>(geometries.first()) { merged, geometry ->
|
||||
geometrySupport.safeUnion(merged, geometry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRibbonPolygon(
|
||||
leftBoundary: List<Coordinate>,
|
||||
rightBoundary: List<Coordinate>,
|
||||
flightLine: List<FlightLineDTO>
|
||||
): Polygon? {
|
||||
val shell = toRingCoordinates(leftBoundary, rightBoundary) ?: return null
|
||||
return runCatching {
|
||||
geometryFactory.createPolygon(shell.toTypedArray())
|
||||
}.getOrElse { error ->
|
||||
logger.warn(
|
||||
"Failed to build strip segment polygon: firstPointTime={}, points={}, error={}",
|
||||
flightLine.firstOrNull()?.time,
|
||||
flightLine.size,
|
||||
error.message
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun toRingCoordinates(leftBoundary: List<Coordinate>, rightBoundary: List<Coordinate>): List<Coordinate>? {
|
||||
if (leftBoundary.size < 2 || rightBoundary.size < 2) {
|
||||
return null
|
||||
}
|
||||
val ring = ArrayList<Coordinate>(leftBoundary.size + rightBoundary.size + 1)
|
||||
leftBoundary.forEach { ring += Coordinate(it) }
|
||||
rightBoundary.asReversed().forEach { ring += Coordinate(it) }
|
||||
if (ring.size < 4) {
|
||||
return null
|
||||
}
|
||||
ring += Coordinate(ring.first())
|
||||
|
||||
val distinctVertices = ring
|
||||
.dropLast(1)
|
||||
.map { it.x to it.y }
|
||||
.distinct()
|
||||
return ring.takeIf { distinctVertices.size >= 3 }
|
||||
}
|
||||
|
||||
private fun extractPolygons(geometry: Geometry): List<Polygon> {
|
||||
if (geometry.isEmpty) return emptyList()
|
||||
return when (geometry) {
|
||||
is Polygon -> listOf(geometry)
|
||||
is MultiPolygon -> (0 until geometry.numGeometries)
|
||||
.map { geometry.getGeometryN(it) as Polygon }
|
||||
is GeometryCollection -> (0 until geometry.numGeometries)
|
||||
.flatMap { extractPolygons(geometry.getGeometryN(it)) }
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateRequest(request: CoverageSchemeCalculateRequestDTO) {
|
||||
val minimumTechnologyOverlap = request.minimumTechnologyOverlap
|
||||
if (request.satelliteIds.isEmpty()) {
|
||||
throw CustomValidationException("satelliteIds must not be empty")
|
||||
}
|
||||
if (!request.timeStart.isBefore(request.timeEnd)) {
|
||||
throw CustomValidationException("timeStart must be before timeEnd")
|
||||
}
|
||||
if (minimumTechnologyOverlap != null && minimumTechnologyOverlap < 0.0) {
|
||||
throw CustomValidationException("minimumTechnologyOverlap must be non-negative")
|
||||
}
|
||||
}
|
||||
}
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
package space.nstart.pcp.pcp_coverage_scheme_service.service
|
||||
|
||||
import org.locationtech.jts.geom.Coordinate
|
||||
import org.locationtech.jts.geom.CoordinateSequenceFilter
|
||||
import org.locationtech.jts.geom.Envelope
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.geom.GeometryCollection
|
||||
import org.locationtech.jts.geom.GeometryFactory
|
||||
import org.locationtech.jts.geom.LineString
|
||||
import org.locationtech.jts.geom.LinearRing
|
||||
import org.locationtech.jts.geom.MultiPolygon
|
||||
import org.locationtech.jts.geom.Point
|
||||
import org.locationtech.jts.geom.Polygon
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.locationtech.jts.io.WKTWriter
|
||||
import org.locationtech.jts.operation.buffer.BufferOp
|
||||
import org.locationtech.jts.operation.buffer.BufferParameters
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomValidationException
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
@Component
|
||||
class GeometrySupport {
|
||||
|
||||
enum class LongitudeModel {
|
||||
STANDARD_180,
|
||||
POSITIVE_360
|
||||
}
|
||||
|
||||
data class StripOrientation(
|
||||
val axisX: Double,
|
||||
val axisY: Double,
|
||||
val normalX: Double,
|
||||
val normalY: Double
|
||||
)
|
||||
|
||||
private val geometryFactory = GeometryFactory()
|
||||
private val wktReader = WKTReader(geometryFactory)
|
||||
private val wktWriter = WKTWriter()
|
||||
|
||||
fun resolveLongitudeModel(wkt: String): LongitudeModel {
|
||||
if (wkt.isBlank()) {
|
||||
throw CustomValidationException("polygonWkt must not be blank")
|
||||
}
|
||||
val geometry = try {
|
||||
wktReader.read(wkt)
|
||||
} catch (ex: Exception) {
|
||||
throw CustomValidationException("Некорректный WKT полигона: ${ex.message}")
|
||||
}
|
||||
return resolveLongitudeModel(geometry)
|
||||
}
|
||||
|
||||
fun parseAndNormalizeTarget(wkt: String, longitudeModel: LongitudeModel? = null): Geometry {
|
||||
if (wkt.isBlank()) {
|
||||
throw CustomValidationException("polygonWkt must not be blank")
|
||||
}
|
||||
val geometry = try {
|
||||
wktReader.read(wkt)
|
||||
} catch (ex: Exception) {
|
||||
throw CustomValidationException("Некорректный WKT полигона: ${ex.message}")
|
||||
}
|
||||
val normalized = normalizeToLongitudeModel(geometry, longitudeModel ?: resolveLongitudeModel(geometry))
|
||||
if (normalized !is Polygon && normalized !is MultiPolygon) {
|
||||
throw CustomValidationException("Поддерживаются только Polygon или MultiPolygon")
|
||||
}
|
||||
return ensureValid(normalized)
|
||||
}
|
||||
|
||||
fun toWkt(geometry: Geometry): String =
|
||||
if (geometry.isEmpty) "" else wktWriter.write(ensureValid(geometry))
|
||||
|
||||
fun safeIntersection(left: Geometry, right: Geometry): Geometry =
|
||||
safeOverlay(left, right) { first, second -> first.intersection(second) }
|
||||
|
||||
fun safeDifference(left: Geometry, right: Geometry): Geometry =
|
||||
safeOverlay(left, right) { first, second -> first.difference(second) }
|
||||
|
||||
fun safeUnion(left: Geometry, right: Geometry): Geometry =
|
||||
safeOverlay(left, right) { first, second -> first.union(second) }
|
||||
|
||||
fun centroid(geometry: Geometry): Point =
|
||||
if (geometry.isEmpty) point(0.0, 0.0) else geometry.centroid
|
||||
|
||||
fun point(x: Double, y: Double): Point =
|
||||
geometryFactory.createPoint(Coordinate(x, y))
|
||||
|
||||
fun buildStripOrientation(directionDegrees: Double): StripOrientation {
|
||||
val radians = Math.toRadians(directionDegrees)
|
||||
val axisX = cos(radians)
|
||||
val axisY = sin(radians)
|
||||
return StripOrientation(
|
||||
axisX = axisX,
|
||||
axisY = axisY,
|
||||
normalX = -axisY,
|
||||
normalY = axisX
|
||||
)
|
||||
}
|
||||
|
||||
fun projectionRange(geometry: Geometry, orientation: StripOrientation): Pair<Double, Double> {
|
||||
if (geometry.isEmpty) return 0.0 to 0.0
|
||||
val values = geometry.coordinates.map { project(it.x, it.y, orientation.axisX, orientation.axisY) }
|
||||
return (values.minOrNull() ?: 0.0) to (values.maxOrNull() ?: 0.0)
|
||||
}
|
||||
|
||||
fun projectPoint(point: Point, orientation: StripOrientation): Double =
|
||||
project(point.x, point.y, orientation.axisX, orientation.axisY)
|
||||
|
||||
fun createCorridor(centerLine: List<Coordinate>, width: Double, centerShift: Double = 0.0): Polygon {
|
||||
val shifted = shiftCoordinates(centerLine, buildStripOrientation(directionDegrees(centerLine)), centerShift)
|
||||
val line = when {
|
||||
shifted.isEmpty() -> geometryFactory.createLineString(arrayOf(Coordinate(0.0, 0.0), Coordinate(0.0, 0.0)))
|
||||
shifted.size == 1 -> geometryFactory.createLineString(arrayOf(shifted.first(), shifted.first()))
|
||||
else -> geometryFactory.createLineString(shifted.toTypedArray())
|
||||
}
|
||||
val buffered = BufferOp.bufferOp(
|
||||
line,
|
||||
max(width / 2.0, 0.000001),
|
||||
BufferParameters(8, BufferParameters.CAP_FLAT, BufferParameters.JOIN_ROUND, BufferParameters.DEFAULT_MITRE_LIMIT)
|
||||
)
|
||||
return ensureValid(buffered) as Polygon
|
||||
}
|
||||
|
||||
fun shiftPoint(point: Point, orientation: StripOrientation, distance: Double): Point =
|
||||
this.point(
|
||||
point.x + orientation.normalX * distance,
|
||||
point.y + orientation.normalY * distance
|
||||
)
|
||||
|
||||
fun directionDegrees(coordinates: List<Coordinate>): Double {
|
||||
if (coordinates.size < 2) return 0.0
|
||||
val normalized = normalizeTrackCoordinates(coordinates)
|
||||
val first = normalized.first()
|
||||
val last = normalized.last()
|
||||
return Math.toDegrees(atan2(last.y - first.y, last.x - first.x))
|
||||
}
|
||||
|
||||
fun normalizeTrackCoordinates(coordinates: List<Coordinate>): List<Coordinate> {
|
||||
if (coordinates.isEmpty()) return emptyList()
|
||||
val normalized = mutableListOf(Coordinate(coordinates.first()))
|
||||
var previousX = coordinates.first().x
|
||||
coordinates.drop(1).forEach { coordinate ->
|
||||
var adjustedX = coordinate.x
|
||||
while (adjustedX - previousX > 180.0) adjustedX -= 360.0
|
||||
while (adjustedX - previousX < -180.0) adjustedX += 360.0
|
||||
normalized += Coordinate(adjustedX, coordinate.y, coordinate.z)
|
||||
previousX = adjustedX
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun alignTrackCoordinatesToTarget(coordinates: List<Coordinate>, target: Geometry): List<Coordinate> {
|
||||
val normalized = normalizeTrackCoordinates(coordinates)
|
||||
if (normalized.isEmpty()) return normalized
|
||||
val referenceX = target.envelopeInternal.centre().x
|
||||
val averageX = normalized.map { it.x }.average()
|
||||
val shiftMultiplier = kotlin.math.round((referenceX - averageX) / 360.0)
|
||||
val shift = shiftMultiplier * 360.0
|
||||
if (shift == 0.0) return normalized
|
||||
return normalized.map { coordinate ->
|
||||
Coordinate(coordinate.x + shift, coordinate.y, coordinate.z)
|
||||
}
|
||||
}
|
||||
|
||||
fun distance(first: Coordinate, second: Coordinate): Double {
|
||||
val dx = second.x - first.x
|
||||
val dy = second.y - first.y
|
||||
return kotlin.math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
fun boundaryTouchArea(coverage: Geometry, target: Geometry, orientation: StripOrientation, buffer: Double): Double {
|
||||
val bands = boundaryBands(target, buffer)
|
||||
return bands.maxOf { (_, band) -> safeIntersection(coverage, band).area }
|
||||
}
|
||||
|
||||
fun frontierGeometry(target: Geometry, covered: Geometry): Geometry {
|
||||
if (covered.isEmpty) return ensureValid(target.boundary.buffer(0.0001))
|
||||
val uncovered = safeDifference(target, covered)
|
||||
if (uncovered.isEmpty) return geometryFactory.createPolygon()
|
||||
return ensureValid(safeIntersection(covered.buffer(0.0001), uncovered.buffer(0.0001)))
|
||||
}
|
||||
|
||||
fun uncoveredFrontierGeometry(target: Geometry, covered: Geometry): Geometry {
|
||||
val uncovered = safeDifference(target, covered)
|
||||
if (uncovered.isEmpty) return geometryFactory.createPolygon()
|
||||
return ensureValid(safeIntersection(uncovered, frontierGeometry(target, covered).buffer(0.0001)))
|
||||
}
|
||||
|
||||
fun startBoundaryLabel(coverage: Geometry, target: Geometry, orientation: StripOrientation): String {
|
||||
val bands = boundaryBands(target, 0.05)
|
||||
return bands.maxByOrNull { (_, band) -> safeIntersection(coverage, band).area }?.first ?: "INTERIOR"
|
||||
}
|
||||
|
||||
fun shiftCoordinates(centerLine: List<Coordinate>, orientation: StripOrientation, distance: Double): List<Coordinate> =
|
||||
centerLine.map { coordinate ->
|
||||
Coordinate(
|
||||
coordinate.x + orientation.normalX * distance,
|
||||
coordinate.y + orientation.normalY * distance,
|
||||
coordinate.z
|
||||
)
|
||||
}
|
||||
|
||||
private fun boundaryBands(target: Geometry, buffer: Double): List<Pair<String, Geometry>> {
|
||||
val envelope = target.envelopeInternal
|
||||
val dx = max(buffer, (envelope.width * 0.02).coerceAtLeast(0.0001))
|
||||
val dy = max(buffer, (envelope.height * 0.02).coerceAtLeast(0.0001))
|
||||
return listOf(
|
||||
"WEST" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.minX + dx, envelope.minY - dy, envelope.maxY + dy)),
|
||||
"EAST" to geometryFactory.toGeometry(Envelope(envelope.maxX - dx, envelope.maxX + dx, envelope.minY - dy, envelope.maxY + dy)),
|
||||
"SOUTH" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.maxX + dx, envelope.minY - dy, envelope.minY + dy)),
|
||||
"NORTH" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.maxX + dx, envelope.maxY - dy, envelope.maxY + dy))
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureValid(geometry: Geometry): Geometry =
|
||||
if (geometry.isValid) geometry else geometry.buffer(0.0)
|
||||
|
||||
private fun safeOverlay(left: Geometry, right: Geometry, operation: (Geometry, Geometry) -> Geometry): Geometry =
|
||||
try {
|
||||
ensureValid(operation(ensureValid(left), ensureValid(right)))
|
||||
} catch (_: Exception) {
|
||||
ensureValid(operation(left.buffer(0.0), right.buffer(0.0)))
|
||||
}
|
||||
|
||||
private fun project(x: Double, y: Double, axisX: Double, axisY: Double): Double = x * axisX + y * axisY
|
||||
|
||||
private fun resolveLongitudeModel(geometry: Geometry): LongitudeModel {
|
||||
val longitudes = geometry.coordinates.map { normalizeToStandardLongitude(it.x) }
|
||||
val negativeMin = longitudes.filter { it < 0.0 }.minOrNull()
|
||||
val positiveMax = longitudes.filter { it >= 0.0 }.maxOrNull()
|
||||
val crossesZeroMeridian = negativeMin != null && positiveMax != null && positiveMax - negativeMin < 180.0
|
||||
return if (crossesZeroMeridian) LongitudeModel.STANDARD_180 else LongitudeModel.POSITIVE_360
|
||||
}
|
||||
|
||||
private fun normalizeToLongitudeModel(geometry: Geometry, longitudeModel: LongitudeModel): Geometry {
|
||||
val normalized = when (longitudeModel) {
|
||||
LongitudeModel.STANDARD_180 -> shiftLongitudesToStandardBand(geometry.copy())
|
||||
LongitudeModel.POSITIVE_360 -> shiftNegativeLongitudesTo360(geometry.copy())
|
||||
}
|
||||
return when (normalized) {
|
||||
is Polygon -> normalizePolygon(normalized, longitudeModel)
|
||||
is MultiPolygon -> normalizeMultiPolygon(normalized, longitudeModel)
|
||||
else -> normalized
|
||||
}.let { ensureValid(it) }
|
||||
}
|
||||
|
||||
private fun shiftNegativeLongitudesTo360(geometry: Geometry): Geometry {
|
||||
geometry.apply(object : CoordinateSequenceFilter {
|
||||
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
|
||||
val lon = seq.getX(i)
|
||||
if (lon < 0.0) {
|
||||
seq.setOrdinate(i, 0, lon + 360.0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isDone(): Boolean = false
|
||||
override fun isGeometryChanged(): Boolean = true
|
||||
})
|
||||
geometry.geometryChanged()
|
||||
return geometry
|
||||
}
|
||||
|
||||
private fun shiftLongitudesToStandardBand(geometry: Geometry): Geometry {
|
||||
geometry.apply(object : CoordinateSequenceFilter {
|
||||
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
|
||||
seq.setOrdinate(i, 0, normalizeToStandardLongitude(seq.getX(i)))
|
||||
}
|
||||
|
||||
override fun isDone(): Boolean = false
|
||||
override fun isGeometryChanged(): Boolean = true
|
||||
})
|
||||
geometry.geometryChanged()
|
||||
return geometry
|
||||
}
|
||||
|
||||
private fun normalizePolygon(polygon: Polygon, longitudeModel: LongitudeModel): Geometry {
|
||||
val shell = unwrapRing(polygon.exteriorRing)
|
||||
val holes = Array(polygon.numInteriorRing) { index -> unwrapRing(polygon.getInteriorRingN(index)) }
|
||||
val normalized = ensureValid(polygon.factory.createPolygon(shell, holes))
|
||||
return when (longitudeModel) {
|
||||
LongitudeModel.STANDARD_180 -> shiftIntoStandardBand(normalized)
|
||||
LongitudeModel.POSITIVE_360 -> shiftIntoPositiveBand(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeMultiPolygon(multiPolygon: MultiPolygon, longitudeModel: LongitudeModel): Geometry {
|
||||
val polygons = mutableListOf<Polygon>()
|
||||
for (index in 0 until multiPolygon.numGeometries) {
|
||||
collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon, longitudeModel), polygons)
|
||||
}
|
||||
return when (polygons.size) {
|
||||
0 -> geometryFactory.createPolygon()
|
||||
1 -> polygons.first()
|
||||
else -> geometryFactory.createMultiPolygon(polygons.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectPolygons(geometry: Geometry, polygons: MutableList<Polygon>) {
|
||||
when (geometry) {
|
||||
is Polygon -> polygons += geometry
|
||||
is MultiPolygon -> {
|
||||
for (index in 0 until geometry.numGeometries) {
|
||||
polygons += geometry.getGeometryN(index) as Polygon
|
||||
}
|
||||
}
|
||||
is GeometryCollection -> {
|
||||
for (index in 0 until geometry.numGeometries) {
|
||||
collectPolygons(geometry.getGeometryN(index), polygons)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shiftIntoPositiveBand(geometry: Geometry): Geometry {
|
||||
val minX = geometry.coordinates.minOfOrNull { it.x } ?: return geometry
|
||||
if (minX >= 0.0) return geometry
|
||||
val shift = 360.0 * (floor(-minX / 360.0) + 1.0)
|
||||
return shiftLongitude(geometry, shift)
|
||||
}
|
||||
|
||||
private fun shiftIntoStandardBand(geometry: Geometry): Geometry {
|
||||
val centerX = geometry.envelopeInternal.centre().x
|
||||
val shift = -360.0 * floor((centerX + 180.0) / 360.0)
|
||||
return shiftLongitude(geometry, shift)
|
||||
}
|
||||
|
||||
private fun shiftLongitude(geometry: Geometry, delta: Double): Geometry {
|
||||
if (delta == 0.0) return geometry
|
||||
val shifted = geometry.copy()
|
||||
shifted.apply(object : CoordinateSequenceFilter {
|
||||
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
|
||||
seq.setOrdinate(i, 0, seq.getX(i) + delta)
|
||||
}
|
||||
|
||||
override fun isDone(): Boolean = false
|
||||
override fun isGeometryChanged(): Boolean = true
|
||||
})
|
||||
shifted.geometryChanged()
|
||||
return shifted
|
||||
}
|
||||
|
||||
private fun unwrapRing(ring: LineString): LinearRing {
|
||||
val source = ring.coordinates
|
||||
if (source.isEmpty()) return ring.factory.createLinearRing()
|
||||
val unwrapped = ArrayList<Coordinate>(source.size)
|
||||
var previousX = source.first().x
|
||||
unwrapped.add(Coordinate(source.first().x, source.first().y, source.first().z))
|
||||
for (index in 1 until source.size - 1) {
|
||||
val current = source[index]
|
||||
var adjustedX = current.x
|
||||
while (adjustedX - previousX > 180.0) adjustedX -= 360.0
|
||||
while (adjustedX - previousX < -180.0) adjustedX += 360.0
|
||||
unwrapped += Coordinate(adjustedX, current.y, current.z)
|
||||
previousX = adjustedX
|
||||
}
|
||||
val first = unwrapped.first()
|
||||
unwrapped += Coordinate(first.x, first.y, first.z)
|
||||
return ring.factory.createLinearRing(unwrapped.toTypedArray())
|
||||
}
|
||||
|
||||
private fun normalizeToStandardLongitude(longitude: Double): Double {
|
||||
var normalized = longitude
|
||||
while (normalized > 180.0) normalized -= 360.0
|
||||
while (normalized <= -180.0) normalized += 360.0
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
spring:
|
||||
application:
|
||||
name: pcp-coverage-scheme-service
|
||||
profiles:
|
||||
default: local
|
||||
config:
|
||||
import: "configserver:"
|
||||
cloud:
|
||||
config:
|
||||
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
|
||||
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
|
||||
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
|
||||
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
|
||||
Reference in New Issue
Block a user