Init
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
package space.nstart.pcp.slots_service
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.kafka.annotation.EnableKafka
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@EnableKafka
|
||||
@EnableScheduling
|
||||
@SpringBootApplication
|
||||
class SlotsServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<SlotsServiceApplication>(*args)
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package space.nstart.pcp.slots_service.configuration
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Duration
|
||||
|
||||
@Component("bookedSlotsTimeoutProperties")
|
||||
@ConfigurationProperties(prefix = "pcp.slots.status.v1")
|
||||
data class BookedSlotsTimeoutProperties(
|
||||
var timeoutCheckInterval: Duration = Duration.ofHours(1),
|
||||
var timeoutThreshold: Duration = Duration.ofHours(1)
|
||||
)
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package space.nstart.pcp.slots_service.configuration
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.validation.FieldError
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.support.WebExchangeBindException
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
class CustomValidationException(message: String) : RuntimeException(message)
|
||||
class CustomErrorException(message: String) : RuntimeException(message)
|
||||
|
||||
|
||||
@ControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(CustomValidationException::class)
|
||||
fun handleValidation(ex: CustomValidationException): ResponseEntity<Map<String, String>> {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(mapOf("error" to ex.message!!))
|
||||
}
|
||||
@ExceptionHandler(CustomErrorException::class)
|
||||
fun handleError(ex: CustomErrorException): ResponseEntity<Map<String, String>> {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(mapOf("error" to ex.message!!))
|
||||
}
|
||||
@ExceptionHandler(WebExchangeBindException::class)
|
||||
fun handleValidationExceptions(ex: WebExchangeBindException): Mono<ResponseEntity<Map<String, String>>> {
|
||||
val errors = mutableMapOf<String, String>()
|
||||
ex.bindingResult.fieldErrors.forEach { error ->
|
||||
errors[error.field] = error.defaultMessage ?: "Validation error"
|
||||
}
|
||||
return Mono.just(ResponseEntity.badRequest().body(errors))
|
||||
}
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
|
||||
val errors = mutableMapOf<String, String>()
|
||||
ex.bindingResult.allErrors.forEach { error ->
|
||||
val fieldName = (error as FieldError).field
|
||||
val errorMessage = error.defaultMessage
|
||||
errors[fieldName] = errorMessage ?: "Validation error"
|
||||
}
|
||||
return ResponseEntity.badRequest().body(errors)
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package space.nstart.pcp.slots_service.configuration
|
||||
|
||||
import org.apache.kafka.clients.admin.AdminClientConfig
|
||||
import org.apache.kafka.clients.admin.NewTopic
|
||||
import org.apache.kafka.clients.consumer.ConsumerConfig
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||
import org.apache.kafka.clients.producer.ProducerConfig
|
||||
import org.apache.kafka.common.header.Header
|
||||
import org.apache.kafka.common.serialization.StringDeserializer
|
||||
import org.apache.kafka.common.serialization.StringSerializer
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
|
||||
import org.springframework.kafka.core.ConsumerFactory
|
||||
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
|
||||
import org.springframework.kafka.core.DefaultKafkaProducerFactory
|
||||
import org.springframework.kafka.core.KafkaAdmin
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.core.ProducerFactory
|
||||
import org.springframework.kafka.listener.adapter.RecordFilterStrategy
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||
@Configuration
|
||||
class KafkaConfig {
|
||||
companion object {
|
||||
private const val HEADER_NAME = "type"
|
||||
}
|
||||
|
||||
@Value("\${app.kafka.topics.booked-slots}")
|
||||
private lateinit var bookedSlotsTopic: String
|
||||
|
||||
@Value("\${spring.kafka.bootstrap-servers}")
|
||||
private lateinit var bootstrapServers: String
|
||||
|
||||
@Value("\${spring.kafka.consumer.group-id}")
|
||||
private lateinit var groupId: String
|
||||
|
||||
@Bean
|
||||
fun bookedSlotsTopic(): NewTopic = NewTopic(bookedSlotsTopic, 1, 1)
|
||||
|
||||
@Bean
|
||||
fun kafkaAdmin(): KafkaAdmin =
|
||||
KafkaAdmin(
|
||||
mutableMapOf<String, Any>(
|
||||
AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers
|
||||
)
|
||||
)
|
||||
|
||||
@Bean
|
||||
fun consumerFactory(): ConsumerFactory<String, Any> =
|
||||
DefaultKafkaConsumerFactory(
|
||||
mutableMapOf<String, Any>(
|
||||
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||
ConsumerConfig.GROUP_ID_CONFIG to groupId,
|
||||
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "latest",
|
||||
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
|
||||
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
|
||||
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java
|
||||
)
|
||||
)
|
||||
|
||||
@Bean
|
||||
fun producerFactory(): ProducerFactory<String, Any> =
|
||||
DefaultKafkaProducerFactory(
|
||||
mutableMapOf<String, Any>(
|
||||
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
||||
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java
|
||||
)
|
||||
)
|
||||
|
||||
@Bean
|
||||
fun kafkaTemplate(): KafkaTemplate<String, Any> = KafkaTemplate(producerFactory())
|
||||
|
||||
@Bean
|
||||
fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory<String, Any> =
|
||||
ConcurrentKafkaListenerContainerFactory<String, Any>().apply {
|
||||
setConsumerFactory(consumerFactory())
|
||||
}
|
||||
|
||||
@Bean("satelliteDeletedFilter")
|
||||
fun satelliteDeletedFilter(): RecordFilterStrategy<String, String> =
|
||||
RecordFilterStrategy { consumerRecord: ConsumerRecord<String, String> ->
|
||||
val eventTypeHeader: Header? = consumerRecord.headers().lastHeader(HEADER_NAME)
|
||||
if (eventTypeHeader == null) {
|
||||
return@RecordFilterStrategy true
|
||||
}
|
||||
|
||||
val headerValue = String(eventTypeHeader.value(), StandardCharsets.UTF_8)
|
||||
PcpKafkaEvent.SatelliteDeletedEvent.name != headerValue
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package space.nstart.pcp.slots_service.configuration
|
||||
|
||||
import jakarta.validation.Validation
|
||||
import jakarta.validation.Validator
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class ValidationConfig {
|
||||
@Bean
|
||||
fun validator(): Validator {
|
||||
return Validation.buildDefaultValidatorFactory().validator
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package space.nstart.pcp.slots_service.configuration
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.netty.http.client.HttpClient
|
||||
import java.time.Duration
|
||||
|
||||
@Configuration
|
||||
class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
fun webClientBuilder(): WebClient.Builder {
|
||||
val httpClient = HttpClient.create()
|
||||
.responseTimeout(Duration.ofSeconds(230))
|
||||
|
||||
return WebClient.builder()
|
||||
.clientConnector(ReactorClientHttpConnector(httpClient))
|
||||
.codecs { configurer ->
|
||||
configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
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.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotBookingRequestDTO
|
||||
import java.time.LocalDateTime
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/slots/booking")
|
||||
class BookingController {
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Autowired
|
||||
private lateinit var slotService: SlotService
|
||||
|
||||
@GetMapping
|
||||
fun all() = slotService.allBooked()
|
||||
@GetMapping("/{booked_slot_id}")
|
||||
fun byId(@PathVariable("booked_slot_id") id : Long) = slotService.bookedById(id)
|
||||
@GetMapping("/by-request/{request_id}")
|
||||
fun byId(@PathVariable("request_id") id : String) = slotService.bookedByReqId(id)
|
||||
|
||||
@PostMapping()
|
||||
fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req)
|
||||
@DeleteMapping
|
||||
fun cancelSlot(@RequestParam("id") id : Long) = slotService.cancelSlot(id)
|
||||
@PostMapping("request")
|
||||
fun bookReq(@RequestBody req : BookingRequestDTO) =
|
||||
slotService.bookReq(req)
|
||||
@DeleteMapping("request")
|
||||
fun deleteRequestBooking(@RequestParam requestId : String) =
|
||||
slotService.cancelReq(requestId)
|
||||
|
||||
/**
|
||||
* Legacy HTTP path intentionally retired.
|
||||
* Booked slots lifecycle is Kafka-first and mission-planning must publish Kafka events instead.
|
||||
*/
|
||||
@Deprecated("Booked slots status changes are Kafka-first; use booked slots Kafka events instead")
|
||||
@PostMapping("process")
|
||||
fun processBookedSlots(@RequestBody bookedSlotIds: List<Long>): Nothing {
|
||||
logger.warn(
|
||||
"Rejected legacy HTTP booked slots processing request for {} ids. Kafka lifecycle is the only supported primary path.",
|
||||
bookedSlotIds.distinct().size
|
||||
)
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.GONE,
|
||||
"Legacy booked slots HTTP processing is retired. Publish booked slots status changes via Kafka."
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SatelliteMissionBatchRequestDTO
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/slots/mission")
|
||||
class MissionController {
|
||||
|
||||
@Autowired
|
||||
private lateinit var slotService: SlotService
|
||||
|
||||
@GetMapping("/satellite")
|
||||
fun bySatelliteAndInterval(
|
||||
@RequestParam("satellite_id") satelliteId: Long,
|
||||
@RequestParam timeStart: LocalDateTime,
|
||||
@RequestParam timeStop: LocalDateTime
|
||||
) = slotService.bookedBySatelliteAndInterval(satelliteId, timeStart, timeStop)
|
||||
|
||||
@PostMapping("/satellites")
|
||||
fun bySatellitesAndInterval(@RequestBody body: SatelliteMissionBatchRequestDTO) =
|
||||
slotService.bookedBySatellitesAndInterval(body.satelliteIds, body.timeStart, body.timeStop)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
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.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/satellite")
|
||||
class SatelliteController {
|
||||
|
||||
@Autowired
|
||||
private lateinit var slotService: SlotService
|
||||
|
||||
@PostMapping("/rva/{id}/{duration}")
|
||||
fun rva(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : StationDTO)
|
||||
= slotService.rva(id, duration, station)
|
||||
|
||||
|
||||
@PostMapping("/rva-common/{id}/{duration}")
|
||||
fun rvaCommon(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : List<StationDTO>)
|
||||
= slotService.rvaCommon(id, duration, station)
|
||||
|
||||
@PostMapping("/rva-merge/{id}/{duration}")
|
||||
fun rvaMerge(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : List<StationDTO>)
|
||||
= slotService.rvaMerge(id, duration, station)
|
||||
|
||||
@PostMapping("/rva-merge-on-rev/{id}/{duration}")
|
||||
fun rvaMergeOnRev(@PathVariable id : Long, @PathVariable duration: Long, @RequestBody station : List<StationDTO>)
|
||||
= slotService.rvaMergeOnRev(id, duration, station)
|
||||
|
||||
|
||||
@PostMapping("/{id}/send-ic")
|
||||
fun publishInitialConditions(@PathVariable id: Long, @RequestParam time : LocalDateTime): ResponseEntity<Void> {
|
||||
slotService.publishInitialConditions(id, time, true)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
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_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.slots_service.service.SatelliteIcService
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/satellite-ic")
|
||||
class SatelliteIcController(
|
||||
private val satelliteIcService: SatelliteIcService
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun all() = satelliteIcService.all()
|
||||
|
||||
@GetMapping("/{satellite_id}")
|
||||
fun bySatelliteId(@PathVariable("satellite_id") satelliteId: Long) =
|
||||
satelliteIcService.bySatelliteId(satelliteId)
|
||||
|
||||
@PostMapping
|
||||
fun create(@RequestBody request: SatelliteICDTO) =
|
||||
satelliteIcService.create(request)
|
||||
|
||||
@PutMapping("/{satellite_id}")
|
||||
fun update(
|
||||
@PathVariable("satellite_id") satelliteId: Long,
|
||||
@RequestBody request: InitialConditionsDTO
|
||||
) = satelliteIcService.update(satelliteId, request)
|
||||
|
||||
@DeleteMapping("/{satellite_id}")
|
||||
fun delete(@PathVariable("satellite_id") satelliteId: Long): ResponseEntity<Void> {
|
||||
satelliteIcService.delete(satelliteId)
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema
|
||||
import io.swagger.v3.oas.annotations.media.Content
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
import org.apache.kafka.common.protocol.types.Field
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
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.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/slots")
|
||||
class SlotController {
|
||||
|
||||
@Autowired
|
||||
private lateinit var slotService: SlotService
|
||||
|
||||
|
||||
@Operation(summary = "Формирование слотов замкнутых орбит")
|
||||
@PostMapping("test-init")
|
||||
fun init(@RequestParam sats : List<Long>,
|
||||
@RequestParam slotDuration: Long,
|
||||
@RequestParam dailyDuration : Long,
|
||||
@RequestParam revolutionDuration : Long,
|
||||
@RequestParam recover : Boolean?
|
||||
) = slotService.init(sats, slotDuration, dailyDuration, revolutionDuration, recover)
|
||||
|
||||
|
||||
@Operation(summary = "Расчет покрытие площади")
|
||||
@GetMapping("poly-cover")
|
||||
fun cover(
|
||||
@Parameter(description = "Контур полигона", example = "POLYGON ((10 15, 12 17, 14 17, 16 15, 14 13, 12 13, 10 15))")
|
||||
@RequestParam wkt : String,
|
||||
@Parameter(description = "Время начала", example = "2026-02-05T00:00:00.000")
|
||||
@RequestParam timeStart : LocalDateTime,
|
||||
@Parameter(description = "Время конца", example = "2026-02-06T00:00:00.000")
|
||||
@RequestParam timeStop : LocalDateTime,
|
||||
@Parameter(description = "Признак ветви")
|
||||
@RequestParam("revSign") sign : RevolutionSign?,
|
||||
@Parameter(description = "Признак расчета покрытия")
|
||||
@RequestParam("cov") cov : Boolean?,
|
||||
@Parameter(description = "Список КА")
|
||||
@RequestParam("satellites") satellites : List<Long>?,
|
||||
@Parameter(description = "Угол Солнца, град")
|
||||
@RequestParam("sun") sun : Double?,
|
||||
@Parameter(description = "Вариант расчета покрытия")
|
||||
@RequestParam("coverageStrategy", required = false) coverageStrategy: SlotCoverageStrategy?,
|
||||
)
|
||||
= slotService.polySlots(wkt, timeStart, timeStop, sign, cov, null, satellites, sun, coverageStrategy)
|
||||
|
||||
@PostMapping("poly-cover/batch")
|
||||
fun coverBatch(@Valid @RequestBody body: SlotCoverageBatchRequestDTO) =
|
||||
slotService.polySlotsBatch(body)
|
||||
|
||||
|
||||
@GetMapping("request-cover")
|
||||
@Operation(summary = "Расчет покрытие заявки")
|
||||
@ApiResponses(
|
||||
value = [
|
||||
ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Слоты покрытия заявки",
|
||||
content = [
|
||||
Content(
|
||||
mediaType = "application/json",
|
||||
array = ArraySchema(schema = Schema(implementation = SlotDTO::class))
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
fun coverReq(
|
||||
@Parameter(description = "Id")
|
||||
@RequestParam requestId : String,
|
||||
@Parameter(description = "Время начала", example = "2026-02-05T00:00:00.000")
|
||||
@RequestParam timeStart : LocalDateTime,
|
||||
@Parameter(description = "Время конца", example = "2026-02-06T00:00:00.000")
|
||||
@RequestParam timeStop : LocalDateTime,
|
||||
@Parameter(description = "Признак ветви")
|
||||
@RequestParam("revSign") sign : RevolutionSign?,
|
||||
@Parameter(description = "Признак расчета покрытия")
|
||||
@RequestParam("cov") cov : Boolean?,
|
||||
@Parameter(description = "Список КА")
|
||||
@RequestParam("satellites") satellites : List<Long>?,
|
||||
@Parameter(description = "Угол Солнца, град")
|
||||
@RequestParam("sun") sun : Double?,
|
||||
@Parameter(description = "Вариант расчета покрытия")
|
||||
@RequestParam("coverageStrategy", required = false) coverageStrategy: SlotCoverageStrategy?,
|
||||
) : List<SlotDTO>
|
||||
= slotService.reqCover(requestId, timeStart, timeStop, sign, cov, satellites, sun, coverageStrategy).toList()
|
||||
|
||||
@GetMapping("interval")
|
||||
@Operation(summary = "Получение всех слотов на заданном интервале времени")
|
||||
fun allByInterval(
|
||||
@Parameter(description = "Идентификатор спутника")
|
||||
@RequestParam satelliteId: Long,
|
||||
@Parameter(description = "Время начала", example = "2026-02-05T00:00:00.000")
|
||||
@RequestParam timeStart: LocalDateTime,
|
||||
@Parameter(description = "Время конца", example = "2026-02-06T00:00:00.000")
|
||||
@RequestParam timeStop: LocalDateTime,
|
||||
): List<SlotDTO> = slotService.allByInterval(satelliteId, timeStart, timeStop)
|
||||
|
||||
@GetMapping("calculation-summary")
|
||||
@Operation(summary = "Получение сводки рассчитанных слотов по спутникам")
|
||||
fun calculationSummary(
|
||||
@Parameter(description = "Список идентификаторов спутников")
|
||||
@RequestParam satelliteIds: List<Long>
|
||||
) = slotService.slotCalculationSummaries(satelliteIds)
|
||||
|
||||
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package space.nstart.pcp.slots_service.entity
|
||||
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.Table
|
||||
|
||||
|
||||
@Entity
|
||||
@Table(name = "booked_slot_request")
|
||||
class BookedRequestEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val bookedSlotRequestId : Long? = null,
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "booked_slot_id", nullable = false)
|
||||
val bookedSlot : BookedSlotEntity = BookedSlotEntity(),
|
||||
@Column(name = "request_id", nullable = false, length = 50)
|
||||
var requestId : String = "",
|
||||
) {
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package space.nstart.pcp.slots_service.entity
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface BookedRequestsRepository : JpaRepository<BookedRequestEntity, Long> {
|
||||
|
||||
fun findByBookedSlot_BookedSlotIdAndRequestId(slot : Long, req : String) : BookedRequestEntity?
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package space.nstart.pcp.slots_service.entity
|
||||
|
||||
import jakarta.persistence.CascadeType
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.OneToMany
|
||||
import jakarta.persistence.Table
|
||||
import org.hibernate.annotations.Fetch
|
||||
import org.hibernate.annotations.FetchMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
|
||||
|
||||
@Entity
|
||||
@Table(name = "booked_slot")
|
||||
class BookedSlotEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val bookedSlotId : Long? = null,
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "slot_id", nullable = false)
|
||||
val slot : SlotEntity = SlotEntity(),
|
||||
@Column(nullable = false)
|
||||
var cycle : Long = 0,
|
||||
@OneToMany(
|
||||
fetch = FetchType.LAZY,
|
||||
mappedBy = "bookedSlot",
|
||||
cascade = [CascadeType.PERSIST, CascadeType.MERGE],
|
||||
orphanRemoval = true
|
||||
)
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
val requests: MutableList<BookedRequestEntity>? = null,
|
||||
@Column(name = "status", columnDefinition = "TEXT", nullable = false)
|
||||
var status : String = BookedSlotStatus.BOOKED.toString()
|
||||
) {
|
||||
constructor(slot : SlotEntity, cycle : Long) : this(
|
||||
null,
|
||||
slot,
|
||||
cycle
|
||||
)
|
||||
|
||||
fun toDTO() = BookedSlotDTO(
|
||||
bookedSlotId = bookedSlotId ?: 0,
|
||||
satelliteId = slot.satelliteId,
|
||||
slotNum = slot.slotNum,
|
||||
cycle = cycle
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package space.nstart.pcp.slots_service.entity
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Entity
|
||||
@Table(name = "satellite_ic")
|
||||
class SatelliteIcEntity(
|
||||
@Id
|
||||
@Column(name = "satellite_id", nullable = false)
|
||||
val satelliteId: Long = 0,
|
||||
@Column(name = "orb_time", nullable = false)
|
||||
val orbTime: LocalDateTime = LocalDateTime.now(),
|
||||
@Column(name = "orb_revolution", nullable = false)
|
||||
val orbRevolution: Long = 0,
|
||||
@Column(nullable = false)
|
||||
val x: Double = 0.0,
|
||||
@Column(nullable = false)
|
||||
val y: Double = 0.0,
|
||||
@Column(nullable = false)
|
||||
val z: Double = 0.0,
|
||||
@Column(nullable = false)
|
||||
val vx: Double = 0.0,
|
||||
@Column(nullable = false)
|
||||
val vy: Double = 0.0,
|
||||
@Column(nullable = false)
|
||||
val vz: Double = 0.0,
|
||||
@Column(name = "s_ball", nullable = false)
|
||||
val sBall: Double = 0.0,
|
||||
@Column(nullable = false)
|
||||
val f81: Double = 147.8
|
||||
)
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package space.nstart.pcp.slots_service.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import org.hibernate.annotations.Fetch
|
||||
import org.hibernate.annotations.FetchMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser
|
||||
import space.nstart.pcp.slots_service.model.Mar
|
||||
import java.time.LocalDateTime
|
||||
|
||||
|
||||
@Entity
|
||||
@Table(name = "slot")
|
||||
class SlotEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val slotId : Long? = null,
|
||||
@Column(nullable = false)
|
||||
val slotNum : Long = 0,
|
||||
@Column(nullable = false)
|
||||
var cycle : Long = 0,
|
||||
@Column(nullable = false)
|
||||
val satelliteId : Long = 0,
|
||||
@Column(nullable = false)
|
||||
val coveringType : Int = 0,
|
||||
@Column(nullable = false)
|
||||
var tn : LocalDateTime = LocalDateTime.now(),
|
||||
@Column(nullable = false)
|
||||
var tk : LocalDateTime = LocalDateTime.now(),
|
||||
val roll : Double = 0.0,
|
||||
@Column(name = "contour_wkt", columnDefinition = "TEXT", nullable = false)
|
||||
val contour : String = "",
|
||||
@Column(nullable = false)
|
||||
var revolution : Long = 0,
|
||||
@Column(name = "revolution_sign", columnDefinition = "VARCHAR", nullable = false)
|
||||
val revolutionSign: String = RevolutionSign.ASC.toString(),
|
||||
@OneToMany(
|
||||
fetch = FetchType.LAZY,
|
||||
mappedBy = "slot",
|
||||
cascade = [CascadeType.PERSIST, CascadeType.MERGE],
|
||||
orphanRemoval = true
|
||||
)
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
val modes: MutableList<BookedSlotEntity>? = null,
|
||||
|
||||
val latitude : Double = 0.0,
|
||||
val longitude : Double = 0.0,
|
||||
) {
|
||||
|
||||
constructor(mar : Mar, slotNum : Long, type : Int) : this(
|
||||
slotNum = slotNum,
|
||||
cycle = 1,
|
||||
satelliteId = mar.sat,
|
||||
tn = mar.tn,
|
||||
tk = mar.tk,
|
||||
roll = mar.roll,
|
||||
contour = mar.contour,
|
||||
revolution = mar.vit,
|
||||
revolutionSign = mar.revolutionSign.toString(),
|
||||
coveringType = type
|
||||
)
|
||||
|
||||
fun toMar(parser : WKTParser) = Mar(
|
||||
sat = satelliteId,
|
||||
tn = tn,
|
||||
tk = tk,
|
||||
roll = roll,
|
||||
contour = contour,
|
||||
poly = parser.parseWKT(contour),
|
||||
vit = revolution,
|
||||
revolutionSign = RevolutionSign.valueOf(revolutionSign),
|
||||
slotNum = slotNum
|
||||
)
|
||||
|
||||
fun toDTO() = SlotDTO(
|
||||
cycle,
|
||||
satelliteId,
|
||||
tn,
|
||||
tk,
|
||||
roll,
|
||||
contour,
|
||||
revolution,
|
||||
RevolutionSign.valueOf(revolutionSign),
|
||||
slotNum,
|
||||
SlotStatus.AVAILABLE,
|
||||
latitude,
|
||||
longitude
|
||||
)
|
||||
|
||||
fun toDTO(status : SlotStatus) = SlotDTO(
|
||||
cycle,
|
||||
satelliteId,
|
||||
tn,
|
||||
tk,
|
||||
roll,
|
||||
contour,
|
||||
revolution,
|
||||
RevolutionSign.valueOf(revolutionSign),
|
||||
slotNum,
|
||||
state = status,
|
||||
latitude,
|
||||
longitude
|
||||
)
|
||||
|
||||
fun toDTO(booked : Boolean, band : Boolean) = SlotDTO(
|
||||
cycle,
|
||||
satelliteId,
|
||||
tn,
|
||||
tk,
|
||||
roll,
|
||||
contour,
|
||||
revolution,
|
||||
RevolutionSign.valueOf(revolutionSign),
|
||||
slotNum,
|
||||
state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE,
|
||||
latitude,
|
||||
longitude
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class BookedSlot(
|
||||
val t : LocalDateTime,
|
||||
val tEnd : LocalDateTime,
|
||||
val roll : Double,
|
||||
var neighbours : Short,
|
||||
val cycle : Long
|
||||
)
|
||||
+927
@@ -0,0 +1,927 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import org.locationtech.jts.geom.Envelope
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.index.strtree.STRtree
|
||||
import org.slf4j.Logger
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
class ContinuousReqCoverSolver(
|
||||
private val logger: Logger,
|
||||
private val satellitesById: Map<Long, AbstractSatellite>
|
||||
) {
|
||||
|
||||
private data class ContinuousCandidate(
|
||||
val base: CoverageCandidate,
|
||||
val leftLongitude: Double,
|
||||
val rightLongitude: Double,
|
||||
val centerLongitude: Double,
|
||||
val trackLongitude: Double
|
||||
)
|
||||
|
||||
private data class TransitionEvaluation(
|
||||
val candidate: ContinuousCandidate,
|
||||
val gainArea: Double,
|
||||
val overlapArea: Double,
|
||||
val geometryGap: Double,
|
||||
val longitudeAdvance: Double,
|
||||
val branchPenalty: Int,
|
||||
val rollDelta: Double,
|
||||
val connected: Boolean,
|
||||
val meaningfulGain: Boolean,
|
||||
val score: Double
|
||||
)
|
||||
|
||||
private data class StartEvaluation(
|
||||
val candidate: ContinuousCandidate,
|
||||
val gainArea: Double,
|
||||
val previewGainArea: Double,
|
||||
val previewLength: Int,
|
||||
val rightReach: Double,
|
||||
val score: Double
|
||||
)
|
||||
|
||||
private data class StartPreScore(
|
||||
val candidate: ContinuousCandidate,
|
||||
val gainArea: Double,
|
||||
val score: Double
|
||||
)
|
||||
|
||||
private data class PreviewSummary(
|
||||
val gainArea: Double,
|
||||
val length: Int,
|
||||
val rightReach: Double
|
||||
)
|
||||
|
||||
private data class LongitudeOrdering(
|
||||
val useSlotLongitude: Boolean
|
||||
)
|
||||
|
||||
private data class PairwiseTransitionData(
|
||||
val relation: LongitudeRelation,
|
||||
val longitudeAdvance: Double,
|
||||
val branchPenalty: Int,
|
||||
val rollDelta: Double,
|
||||
val chainProgressBonus: Double,
|
||||
val connected: Boolean,
|
||||
val geometryGap: Double
|
||||
)
|
||||
|
||||
private data class CandidateSearchContext(
|
||||
val groupsByKey: Map<CoverageGroupKey, CoverageGroup>,
|
||||
val candidateIndexes: CoverageCandidateIndexes,
|
||||
val longitudeOrdering: LongitudeOrdering,
|
||||
val orderingLongitudeById: DoubleArray,
|
||||
val sortedByLongitudeIds: IntArray,
|
||||
val sortedLongitudePositionsById: IntArray,
|
||||
val groupForwardNeighborIds: Array<IntArray>,
|
||||
val spatialNeighborIds: Array<IntArray>,
|
||||
val pairwiseCache: MutableMap<Long, PairwiseTransitionData>
|
||||
)
|
||||
|
||||
private class LocalActiveOverlay(
|
||||
private val baseActive: BooleanArray
|
||||
) {
|
||||
private val locallyDisabled = hashSetOf<Int>()
|
||||
|
||||
fun isActive(candidateId: Int): Boolean = baseActive[candidateId] && candidateId !in locallyDisabled
|
||||
|
||||
fun deactivate(candidateId: Int) {
|
||||
locallyDisabled += candidateId
|
||||
}
|
||||
}
|
||||
|
||||
private class SegmentState {
|
||||
private val selected = mutableListOf<ContinuousCandidate>()
|
||||
private val selectedBySatellite = mutableMapOf<Long, MutableList<ContinuousCandidate>>()
|
||||
private val maxGroupIndexByKey = mutableMapOf<CoverageGroupKey, Int>()
|
||||
private val groupBounds = mutableMapOf<CoverageGroupKey, Pair<LocalDateTime, LocalDateTime>>()
|
||||
|
||||
fun add(candidate: ContinuousCandidate) {
|
||||
selected += candidate
|
||||
selectedBySatellite.getOrPut(candidate.base.slot.satelliteId) { mutableListOf() }.add(candidate)
|
||||
maxGroupIndexByKey.merge(candidate.base.groupKey, candidate.base.groupIndex, ::maxOf)
|
||||
val currentBounds = groupBounds[candidate.base.groupKey]
|
||||
groupBounds[candidate.base.groupKey] = if (currentBounds == null) {
|
||||
candidate.base.slot.tn to candidate.base.slot.tk
|
||||
} else {
|
||||
minOf(currentBounds.first, candidate.base.slot.tn) to maxOf(currentBounds.second, candidate.base.slot.tk)
|
||||
}
|
||||
}
|
||||
|
||||
fun candidates(): List<ContinuousCandidate> = selected
|
||||
|
||||
fun size(): Int = selected.size
|
||||
|
||||
fun selectedOnSatellite(satelliteId: Long): List<ContinuousCandidate> =
|
||||
selectedBySatellite[satelliteId] ?: emptyList()
|
||||
|
||||
fun maxGroupIndex(groupKey: CoverageGroupKey): Int? = maxGroupIndexByKey[groupKey]
|
||||
|
||||
fun projectedGroupDurationSeconds(groupKey: CoverageGroupKey, candidate: ContinuousCandidate): Long {
|
||||
val currentBounds = groupBounds[groupKey]
|
||||
if (currentBounds == null) {
|
||||
return Duration.between(candidate.base.slot.tn, candidate.base.slot.tk).seconds.coerceAtLeast(0L)
|
||||
}
|
||||
val start = minOf(currentBounds.first, candidate.base.slot.tn)
|
||||
val end = maxOf(currentBounds.second, candidate.base.slot.tk)
|
||||
return Duration.between(start, end).seconds.coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
|
||||
private val support = CoverageSolverSupport(logger, satellitesById)
|
||||
|
||||
fun select(targetWkt: String, slots: List<SlotDTO>): List<SlotDTO> {
|
||||
if (slots.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val slotChains = mergeSlotsToChains(slots)
|
||||
logger.info("Continuous solver chains after aggregation: {}", slotChains.size)
|
||||
if (slotChains.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val targetGeometry = support.parseTargetGeometry(targetWkt)
|
||||
if (targetGeometry.isEmpty || targetGeometry.area <= CoverageSolverSupport.AREA_EPS) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val preparedCandidates = support.buildCandidates(slotChains, targetGeometry)
|
||||
val baseCandidates = preparedCandidates.first
|
||||
if (baseCandidates.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val chainCandidates = baseCandidates.map { it.toContinuousCandidate(targetGeometry) }
|
||||
val searchContext = buildSearchContext(
|
||||
candidates = chainCandidates,
|
||||
groupsByKey = preparedCandidates.second
|
||||
)
|
||||
|
||||
val active = BooleanArray(chainCandidates.size) { true }
|
||||
val selected = mutableListOf<CoverageCandidate>()
|
||||
var uncovered = targetGeometry
|
||||
var uncoveredEnvelope = targetGeometry.envelopeInternal
|
||||
|
||||
val bookedSelected = mutableListOf<CoverageCandidate>()
|
||||
chainCandidates.forEach { candidate ->
|
||||
when (candidate.base.slot.state) {
|
||||
SlotStatus.BOOKED -> {
|
||||
bookedSelected += candidate.base
|
||||
selected += candidate.base
|
||||
uncovered = support.safeDifference(uncovered, candidate.base.coveredGeometry)
|
||||
uncoveredEnvelope = uncovered.envelopeInternal
|
||||
active[candidate.base.id] = false
|
||||
}
|
||||
SlotStatus.AVAILABLE -> Unit
|
||||
else -> active[candidate.base.id] = false
|
||||
}
|
||||
}
|
||||
if (bookedSelected.isNotEmpty()) {
|
||||
support.deactivateConflicts(
|
||||
bookedSelected,
|
||||
searchContext.candidateIndexes.satelliteCandidates,
|
||||
chainCandidates.map { it.base },
|
||||
active
|
||||
)
|
||||
}
|
||||
|
||||
var current = chooseStart(chainCandidates, active, targetGeometry, searchContext)
|
||||
while (current != null) {
|
||||
val segment = buildContinuousSegment(
|
||||
anchor = current,
|
||||
candidates = chainCandidates,
|
||||
active = active,
|
||||
uncovered = uncovered,
|
||||
targetGeometry = targetGeometry,
|
||||
searchContext = searchContext
|
||||
)
|
||||
if (segment.isEmpty()) {
|
||||
active[current.base.id] = false
|
||||
current = chooseStart(chainCandidates, active, targetGeometry, searchContext)
|
||||
continue
|
||||
}
|
||||
|
||||
val accepted = mutableListOf<CoverageCandidate>()
|
||||
for (candidate in segment) {
|
||||
if (!active[candidate.base.id]) {
|
||||
continue
|
||||
}
|
||||
if (!candidate.base.coveredEnvelope.intersects(uncoveredEnvelope)) {
|
||||
active[candidate.base.id] = false
|
||||
continue
|
||||
}
|
||||
|
||||
val gainArea = support.safeIntersection(candidate.base.coveredGeometry, uncovered).area
|
||||
if (gainArea <= CoverageSolverSupport.AREA_EPS) {
|
||||
active[candidate.base.id] = false
|
||||
continue
|
||||
}
|
||||
|
||||
selected += candidate.base
|
||||
accepted += candidate.base
|
||||
uncovered = support.safeDifference(uncovered, candidate.base.coveredGeometry)
|
||||
uncoveredEnvelope = uncovered.envelopeInternal
|
||||
active[candidate.base.id] = false
|
||||
|
||||
if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (accepted.isNotEmpty()) {
|
||||
support.deactivateConflicts(
|
||||
accepted,
|
||||
searchContext.candidateIndexes.satelliteCandidates,
|
||||
chainCandidates.map { it.base },
|
||||
active
|
||||
)
|
||||
}
|
||||
|
||||
if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) {
|
||||
break
|
||||
}
|
||||
|
||||
current = chooseStart(chainCandidates, active, targetGeometry, searchContext)
|
||||
}
|
||||
|
||||
val reducedSelected = support.removeRedundant(selected)
|
||||
val preparedSourceCoverageMap = support.buildPreparedSourceCoverageMap(reducedSelected)
|
||||
val resultSlots = support.collectUsefulSlotsFromSelectedChains(reducedSelected, targetGeometry)
|
||||
val filteredResult = support.removeNonCoveringAndFullyCoveredResultSlots(
|
||||
slots = resultSlots,
|
||||
targetGeometry = targetGeometry,
|
||||
preparedSourceCoverages = preparedSourceCoverageMap
|
||||
)
|
||||
logger.info(
|
||||
"Continuous coverage solver result: chains={}, selectedChains={}, resultSlots={}",
|
||||
chainCandidates.size,
|
||||
selected.size,
|
||||
filteredResult.size
|
||||
)
|
||||
return filteredResult.sortedWith(compareBy<SlotDTO>(
|
||||
{ it.tn },
|
||||
{ it.satelliteId },
|
||||
{ it.cycle },
|
||||
{ it.slotNumber }
|
||||
))
|
||||
}
|
||||
|
||||
private fun chooseStart(
|
||||
candidates: List<ContinuousCandidate>,
|
||||
active: BooleanArray,
|
||||
targetGeometry: Geometry,
|
||||
searchContext: CandidateSearchContext
|
||||
): ContinuousCandidate? {
|
||||
val available = candidates
|
||||
.asSequence()
|
||||
.filter { active[it.base.id] && it.base.slot.state == SlotStatus.AVAILABLE }
|
||||
.mapNotNull { candidate ->
|
||||
val gainArea = candidate.base.coveredGeometry.area
|
||||
if (gainArea <= CoverageSolverSupport.AREA_EPS) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
StartPreScore(
|
||||
candidate = candidate,
|
||||
gainArea = gainArea,
|
||||
score = cheapStartScore(candidate, gainArea, searchContext)
|
||||
)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<StartPreScore> { it.score }
|
||||
.thenByDescending { it.gainArea }
|
||||
.thenByDescending { orderingLongitude(it.candidate, searchContext.longitudeOrdering) }
|
||||
.thenByDescending { it.candidate.rightLongitude }
|
||||
)
|
||||
.toList()
|
||||
if (available.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val exactEvaluations = available
|
||||
.take(START_PREVIEW_TOP_K)
|
||||
.map { start ->
|
||||
val preview = previewSegment(
|
||||
anchor = start.candidate,
|
||||
candidates = candidates,
|
||||
active = active,
|
||||
targetGeometry = targetGeometry,
|
||||
searchContext = searchContext
|
||||
)
|
||||
val score = preview.gainArea * START_PREVIEW_GAIN_WEIGHT +
|
||||
start.gainArea * START_GAIN_WEIGHT +
|
||||
preview.length * START_PREVIEW_LENGTH_WEIGHT +
|
||||
max(0.0, preview.rightReach - orderingLongitude(start.candidate, searchContext.longitudeOrdering)) *
|
||||
START_RIGHT_REACH_WEIGHT
|
||||
StartEvaluation(
|
||||
candidate = start.candidate,
|
||||
gainArea = start.gainArea,
|
||||
previewGainArea = preview.gainArea,
|
||||
previewLength = preview.length,
|
||||
rightReach = preview.rightReach,
|
||||
score = score
|
||||
)
|
||||
}
|
||||
|
||||
return exactEvaluations.maxWithOrNull(
|
||||
compareBy<StartEvaluation> { it.score }
|
||||
.thenBy { it.previewGainArea }
|
||||
.thenBy { it.previewLength }
|
||||
.thenBy { it.gainArea }
|
||||
.thenBy { it.rightReach }
|
||||
.thenByDescending { orderingLongitude(it.candidate, searchContext.longitudeOrdering) }
|
||||
.thenByDescending { it.candidate.rightLongitude }
|
||||
.thenByDescending { it.candidate.base.slot.tn }
|
||||
.thenByDescending { it.candidate.base.slot.slotNumber }
|
||||
)?.candidate
|
||||
}
|
||||
|
||||
private fun chooseNext(
|
||||
previous: ContinuousCandidate,
|
||||
candidates: List<ContinuousCandidate>,
|
||||
active: LocalActiveOverlay,
|
||||
uncovered: Geometry,
|
||||
uncoveredEnvelope: Envelope,
|
||||
targetGeometry: Geometry,
|
||||
segmentState: SegmentState,
|
||||
searchContext: CandidateSearchContext
|
||||
): ContinuousCandidate? {
|
||||
val evaluations = evaluateCandidates(
|
||||
previous = previous,
|
||||
candidates = candidates,
|
||||
candidateIds = neighborCandidateIds(previous, searchContext, LONGITUDE_NEIGHBOR_LIMIT),
|
||||
active = active,
|
||||
uncovered = uncovered,
|
||||
uncoveredEnvelope = uncoveredEnvelope,
|
||||
targetGeometry = targetGeometry,
|
||||
segmentState = segmentState,
|
||||
searchContext = searchContext
|
||||
)
|
||||
val boundedEvaluations = if (evaluations.isNotEmpty()) {
|
||||
evaluations
|
||||
} else {
|
||||
evaluateCandidates(
|
||||
previous = previous,
|
||||
candidates = candidates,
|
||||
candidateIds = neighborCandidateIds(previous, searchContext, EXTENDED_LONGITUDE_NEIGHBOR_LIMIT),
|
||||
active = active,
|
||||
uncovered = uncovered,
|
||||
uncoveredEnvelope = uncoveredEnvelope,
|
||||
targetGeometry = targetGeometry,
|
||||
segmentState = segmentState,
|
||||
searchContext = searchContext
|
||||
)
|
||||
}
|
||||
if (boundedEvaluations.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val connectedMeaningful = boundedEvaluations.filter { it.connected && it.meaningfulGain }
|
||||
if (connectedMeaningful.isNotEmpty()) {
|
||||
return connectedMeaningful.maxWithOrNull(connectedComparator())?.candidate
|
||||
}
|
||||
|
||||
return boundedEvaluations.maxWithOrNull(fallbackComparator())?.candidate
|
||||
}
|
||||
|
||||
private fun evaluateCandidates(
|
||||
previous: ContinuousCandidate,
|
||||
candidates: List<ContinuousCandidate>,
|
||||
candidateIds: IntArray,
|
||||
active: LocalActiveOverlay,
|
||||
uncovered: Geometry,
|
||||
uncoveredEnvelope: Envelope,
|
||||
targetGeometry: Geometry,
|
||||
segmentState: SegmentState,
|
||||
searchContext: CandidateSearchContext
|
||||
): List<TransitionEvaluation> {
|
||||
if (candidateIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
return candidateIds.asSequence()
|
||||
.map { candidates[it] }
|
||||
.filter { active.isActive(it.base.id) && it.base.slot.state == SlotStatus.AVAILABLE && it.base.id != previous.base.id }
|
||||
.filter { candidate -> canFollowInSegment(previous, candidate, segmentState) }
|
||||
.mapNotNull { candidate ->
|
||||
evaluateTransition(
|
||||
previous = previous,
|
||||
candidate = candidate,
|
||||
uncovered = uncovered,
|
||||
uncoveredEnvelope = uncoveredEnvelope,
|
||||
targetGeometry = targetGeometry,
|
||||
searchContext = searchContext
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun buildContinuousSegment(
|
||||
anchor: ContinuousCandidate,
|
||||
candidates: List<ContinuousCandidate>,
|
||||
active: BooleanArray,
|
||||
uncovered: Geometry,
|
||||
targetGeometry: Geometry,
|
||||
searchContext: CandidateSearchContext
|
||||
): List<ContinuousCandidate> {
|
||||
val segmentState = SegmentState()
|
||||
val localActive = LocalActiveOverlay(active)
|
||||
var localUncovered = uncovered
|
||||
var localUncoveredEnvelope = uncovered.envelopeInternal
|
||||
var current: ContinuousCandidate? = anchor
|
||||
|
||||
while (current != null && localActive.isActive(current.base.id)) {
|
||||
if (!current.base.coveredEnvelope.intersects(localUncoveredEnvelope)) {
|
||||
localActive.deactivate(current.base.id)
|
||||
break
|
||||
}
|
||||
|
||||
val gainArea = support.safeIntersection(current.base.coveredGeometry, localUncovered).area
|
||||
if (gainArea <= CoverageSolverSupport.AREA_EPS) {
|
||||
localActive.deactivate(current.base.id)
|
||||
break
|
||||
}
|
||||
|
||||
segmentState.add(current)
|
||||
localActive.deactivate(current.base.id)
|
||||
localUncovered = support.safeDifference(localUncovered, current.base.coveredGeometry)
|
||||
localUncoveredEnvelope = localUncovered.envelopeInternal
|
||||
if (localUncovered.isEmpty || localUncovered.area <= CoverageSolverSupport.AREA_EPS) {
|
||||
break
|
||||
}
|
||||
|
||||
current = chooseNext(
|
||||
previous = current,
|
||||
candidates = candidates,
|
||||
active = localActive,
|
||||
uncovered = localUncovered,
|
||||
uncoveredEnvelope = localUncoveredEnvelope,
|
||||
targetGeometry = targetGeometry,
|
||||
segmentState = segmentState,
|
||||
searchContext = searchContext
|
||||
)
|
||||
}
|
||||
|
||||
return segmentState.candidates()
|
||||
}
|
||||
|
||||
private fun previewSegment(
|
||||
anchor: ContinuousCandidate,
|
||||
candidates: List<ContinuousCandidate>,
|
||||
active: BooleanArray,
|
||||
targetGeometry: Geometry,
|
||||
searchContext: CandidateSearchContext
|
||||
): PreviewSummary {
|
||||
val previewState = SegmentState()
|
||||
val localActive = LocalActiveOverlay(active)
|
||||
var localUncovered = targetGeometry
|
||||
var localUncoveredEnvelope = targetGeometry.envelopeInternal
|
||||
var current: ContinuousCandidate? = anchor
|
||||
var totalGain = 0.0
|
||||
var depth = 0
|
||||
var rightReach = orderingLongitude(anchor, searchContext.longitudeOrdering)
|
||||
|
||||
while (current != null && localActive.isActive(current.base.id) && depth < START_LOOKAHEAD_STEPS) {
|
||||
if (!current.base.coveredEnvelope.intersects(localUncoveredEnvelope)) {
|
||||
break
|
||||
}
|
||||
|
||||
val gainArea = support.safeIntersection(current.base.coveredGeometry, localUncovered).area
|
||||
if (gainArea <= CoverageSolverSupport.AREA_EPS) {
|
||||
break
|
||||
}
|
||||
|
||||
totalGain += gainArea
|
||||
rightReach = max(rightReach, orderingLongitude(current, searchContext.longitudeOrdering))
|
||||
previewState.add(current)
|
||||
localActive.deactivate(current.base.id)
|
||||
localUncovered = support.safeDifference(localUncovered, current.base.coveredGeometry)
|
||||
localUncoveredEnvelope = localUncovered.envelopeInternal
|
||||
if (localUncovered.isEmpty || localUncovered.area <= CoverageSolverSupport.AREA_EPS) {
|
||||
break
|
||||
}
|
||||
|
||||
current = chooseNext(
|
||||
previous = current,
|
||||
candidates = candidates,
|
||||
active = localActive,
|
||||
uncovered = localUncovered,
|
||||
uncoveredEnvelope = localUncoveredEnvelope,
|
||||
targetGeometry = targetGeometry,
|
||||
segmentState = previewState,
|
||||
searchContext = searchContext
|
||||
)
|
||||
depth++
|
||||
}
|
||||
|
||||
return PreviewSummary(
|
||||
gainArea = totalGain,
|
||||
length = previewState.size(),
|
||||
rightReach = rightReach
|
||||
)
|
||||
}
|
||||
|
||||
private fun evaluateTransition(
|
||||
previous: ContinuousCandidate,
|
||||
candidate: ContinuousCandidate,
|
||||
uncovered: Geometry,
|
||||
uncoveredEnvelope: Envelope,
|
||||
targetGeometry: Geometry,
|
||||
searchContext: CandidateSearchContext
|
||||
): TransitionEvaluation? {
|
||||
if (!candidate.base.coveredEnvelope.intersects(uncoveredEnvelope)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pairwise = pairwiseTransition(previous, candidate, searchContext)
|
||||
if (pairwise.relation == LongitudeRelation.BACKWARD) {
|
||||
return null
|
||||
}
|
||||
if (pairwise.relation == LongitudeRelation.SAME && !pairwise.connected) {
|
||||
return null
|
||||
}
|
||||
|
||||
val gainArea = support.safeIntersection(candidate.base.coveredGeometry, uncovered).area
|
||||
if (gainArea <= CoverageSolverSupport.AREA_EPS) {
|
||||
return null
|
||||
}
|
||||
|
||||
val meaningfulGain = gainArea >= meaningfulGainThreshold(targetGeometry.area)
|
||||
if (pairwise.relation == LongitudeRelation.SAME && !meaningfulGain) {
|
||||
return null
|
||||
}
|
||||
|
||||
val overlapArea = candidate.base.coveredGeometry.area - gainArea
|
||||
val score = gainArea * TRANSITION_GAIN_WEIGHT -
|
||||
overlapArea * TRANSITION_OVERLAP_WEIGHT -
|
||||
pairwise.branchPenalty * TRANSITION_BRANCH_WEIGHT -
|
||||
pairwise.rollDelta * TRANSITION_ROLL_WEIGHT -
|
||||
pairwise.geometryGap * TRANSITION_GAP_WEIGHT +
|
||||
max(0.0, pairwise.longitudeAdvance) * TRANSITION_RIGHT_PROGRESS_WEIGHT +
|
||||
pairwise.chainProgressBonus * TRANSITION_CHAIN_PROGRESS_WEIGHT +
|
||||
if (pairwise.connected) TRANSITION_CONTINUITY_BONUS else 0.0
|
||||
|
||||
return TransitionEvaluation(
|
||||
candidate = candidate,
|
||||
gainArea = gainArea,
|
||||
overlapArea = overlapArea,
|
||||
geometryGap = pairwise.geometryGap,
|
||||
longitudeAdvance = max(0.0, pairwise.longitudeAdvance),
|
||||
branchPenalty = pairwise.branchPenalty,
|
||||
rollDelta = pairwise.rollDelta,
|
||||
connected = pairwise.connected,
|
||||
meaningfulGain = meaningfulGain,
|
||||
score = score
|
||||
)
|
||||
}
|
||||
|
||||
private fun pairwiseTransition(
|
||||
previous: ContinuousCandidate,
|
||||
candidate: ContinuousCandidate,
|
||||
searchContext: CandidateSearchContext
|
||||
): PairwiseTransitionData {
|
||||
val key = pairwiseKey(previous.base.id, candidate.base.id)
|
||||
return searchContext.pairwiseCache.getOrPut(key) {
|
||||
val relation = longitudeRelation(previous, candidate, searchContext.longitudeOrdering)
|
||||
val longitudeAdvance = orderingLongitude(candidate, searchContext.longitudeOrdering) -
|
||||
orderingLongitude(previous, searchContext.longitudeOrdering)
|
||||
val branchPenalty = branchPenalty(previous, candidate)
|
||||
val rollDelta = abs(previous.base.slot.roll - candidate.base.slot.roll)
|
||||
val chainProgressBonus = chainProgressBonus(previous, candidate)
|
||||
|
||||
if (relation == LongitudeRelation.BACKWARD) {
|
||||
return@getOrPut PairwiseTransitionData(
|
||||
relation = relation,
|
||||
longitudeAdvance = longitudeAdvance,
|
||||
branchPenalty = branchPenalty,
|
||||
rollDelta = rollDelta,
|
||||
chainProgressBonus = chainProgressBonus,
|
||||
connected = false,
|
||||
geometryGap = Double.MAX_VALUE
|
||||
)
|
||||
}
|
||||
|
||||
val connected = runCatching {
|
||||
previous.base.coveredGeometry.intersects(candidate.base.coveredGeometry) ||
|
||||
previous.base.coveredGeometry.touches(candidate.base.coveredGeometry)
|
||||
}.getOrDefault(false)
|
||||
val geometryGap = if (connected) {
|
||||
0.0
|
||||
} else {
|
||||
runCatching { previous.base.coveredGeometry.distance(candidate.base.coveredGeometry) }
|
||||
.getOrDefault(envelopeGap(previous.base.coveredEnvelope, candidate.base.coveredEnvelope))
|
||||
}
|
||||
|
||||
PairwiseTransitionData(
|
||||
relation = relation,
|
||||
longitudeAdvance = longitudeAdvance,
|
||||
branchPenalty = branchPenalty,
|
||||
rollDelta = rollDelta,
|
||||
chainProgressBonus = chainProgressBonus,
|
||||
connected = connected,
|
||||
geometryGap = geometryGap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectedComparator(): Comparator<TransitionEvaluation> =
|
||||
compareBy<TransitionEvaluation> { it.score }
|
||||
.thenBy { it.gainArea }
|
||||
.thenBy { -it.overlapArea }
|
||||
.thenBy { -it.longitudeAdvance }
|
||||
.thenBy { -it.branchPenalty }
|
||||
.thenBy { -it.rollDelta }
|
||||
.thenBy { -it.candidate.base.slot.tn.toLocalTime().toSecondOfDay() }
|
||||
|
||||
private fun fallbackComparator(): Comparator<TransitionEvaluation> =
|
||||
compareBy<TransitionEvaluation> { it.gainArea }
|
||||
.thenBy { -it.score }
|
||||
.thenBy { -it.longitudeAdvance }
|
||||
.thenBy { -it.branchPenalty }
|
||||
.thenBy { -it.rollDelta }
|
||||
.thenBy { -it.geometryGap }
|
||||
.thenBy { -it.overlapArea }
|
||||
|
||||
private fun cheapStartScore(
|
||||
candidate: ContinuousCandidate,
|
||||
gainArea: Double,
|
||||
searchContext: CandidateSearchContext
|
||||
): Double {
|
||||
val continuationCount = searchContext.groupForwardNeighborIds[candidate.base.id].size
|
||||
val spatialCount = searchContext.spatialNeighborIds[candidate.base.id].count { neighborId ->
|
||||
orderingLongitudeById(neighborId, searchContext) >= orderingLongitude(candidate, searchContext.longitudeOrdering)
|
||||
}
|
||||
val rightReach = cheapRightReach(candidate, searchContext)
|
||||
return gainArea * START_GAIN_WEIGHT +
|
||||
continuationCount * START_CHEAP_GROUP_WEIGHT +
|
||||
spatialCount * START_CHEAP_SPATIAL_WEIGHT +
|
||||
max(0.0, rightReach - orderingLongitude(candidate, searchContext.longitudeOrdering)) * START_RIGHT_REACH_WEIGHT
|
||||
}
|
||||
|
||||
private fun cheapRightReach(
|
||||
candidate: ContinuousCandidate,
|
||||
searchContext: CandidateSearchContext
|
||||
): Double {
|
||||
var rightReach = orderingLongitude(candidate, searchContext.longitudeOrdering)
|
||||
val position = searchContext.sortedLongitudePositionsById[candidate.base.id]
|
||||
val until = minOf(searchContext.sortedByLongitudeIds.size, position + START_CHEAP_RIGHT_REACH_WINDOW + 1)
|
||||
for (index in position + 1 until until) {
|
||||
rightReach = max(rightReach, orderingLongitudeById(searchContext.sortedByLongitudeIds[index], searchContext))
|
||||
}
|
||||
val group = searchContext.groupsByKey[candidate.base.groupKey]
|
||||
if (group != null && candidate.base.groupIndex < group.candidateIds.lastIndex) {
|
||||
val lastId = group.candidateIds.last()
|
||||
rightReach = max(rightReach, orderingLongitudeById(lastId, searchContext))
|
||||
}
|
||||
return rightReach
|
||||
}
|
||||
|
||||
private fun neighborCandidateIds(
|
||||
previous: ContinuousCandidate,
|
||||
searchContext: CandidateSearchContext,
|
||||
longitudeWindow: Int
|
||||
): IntArray {
|
||||
val ids = linkedSetOf<Int>()
|
||||
ids += searchContext.groupForwardNeighborIds[previous.base.id].asIterable()
|
||||
ids += searchContext.spatialNeighborIds[previous.base.id].asIterable()
|
||||
|
||||
val position = searchContext.sortedLongitudePositionsById[previous.base.id]
|
||||
val until = minOf(searchContext.sortedByLongitudeIds.size, position + longitudeWindow + 1)
|
||||
for (index in position + 1 until until) {
|
||||
ids += searchContext.sortedByLongitudeIds[index]
|
||||
}
|
||||
|
||||
return ids.toIntArray()
|
||||
}
|
||||
|
||||
private fun branchPenalty(previous: ContinuousCandidate, candidate: ContinuousCandidate): Int =
|
||||
if (previous.base.slot.revolutionSign == candidate.base.slot.revolutionSign) 0 else 1
|
||||
|
||||
private fun chainProgressBonus(previous: ContinuousCandidate, candidate: ContinuousCandidate): Double =
|
||||
when {
|
||||
candidate.base.groupKey != previous.base.groupKey -> 0.0
|
||||
candidate.base.groupIndex <= previous.base.groupIndex -> 0.0
|
||||
else -> 1.0 / (candidate.base.groupIndex - previous.base.groupIndex)
|
||||
}
|
||||
|
||||
private fun meaningfulGainThreshold(targetArea: Double): Double =
|
||||
max(CoverageSolverSupport.AREA_EPS * 10.0, targetArea * MEANINGFUL_GAIN_RATIO)
|
||||
|
||||
private fun mergeSlotsToChains(slots: List<SlotDTO>): List<CoverageSlotChain> =
|
||||
support.mergeSlotsToChains(slots)
|
||||
|
||||
private fun canFollowInSegment(
|
||||
previous: ContinuousCandidate,
|
||||
candidate: ContinuousCandidate,
|
||||
segmentState: SegmentState
|
||||
): Boolean {
|
||||
val candidateBase = candidate.base
|
||||
val sameGroupContinuation = candidateBase.groupKey == previous.base.groupKey &&
|
||||
candidateBase.groupIndex > previous.base.groupIndex &&
|
||||
candidateBase.slot.revolutionSign == previous.base.slot.revolutionSign &&
|
||||
abs(candidateBase.slot.roll - previous.base.slot.roll) < CoverageSolverSupport.ROLL_EPS
|
||||
|
||||
if (!sameGroupContinuation && segmentState.selectedOnSatellite(candidateBase.slot.satelliteId).any { selected ->
|
||||
support.intervalsIntersect(
|
||||
candidateBase.slot.tn,
|
||||
candidateBase.slot.tk,
|
||||
selected.base.blockedStart,
|
||||
selected.base.blockedEnd
|
||||
)
|
||||
}
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val maxGroupIndex = segmentState.maxGroupIndex(candidateBase.groupKey)
|
||||
if (sameGroupContinuation && maxGroupIndex != null && candidateBase.groupIndex <= maxGroupIndex) {
|
||||
return false
|
||||
}
|
||||
|
||||
val segmentDurationSeconds = segmentState.projectedGroupDurationSeconds(candidateBase.groupKey, candidate)
|
||||
return segmentDurationSeconds <= candidateBase.satellite.maxSurveyDuration.toLong()
|
||||
}
|
||||
|
||||
private fun CoverageCandidate.toContinuousCandidate(targetGeometry: Geometry): ContinuousCandidate =
|
||||
ContinuousCandidate(
|
||||
base = this,
|
||||
leftLongitude = coveredEnvelope.minX,
|
||||
rightLongitude = coveredEnvelope.maxX,
|
||||
centerLongitude = (coveredEnvelope.minX + coveredEnvelope.maxX) / 2.0,
|
||||
trackLongitude = normalizeLongitudeToTarget(slot.longitude, targetGeometry)
|
||||
)
|
||||
|
||||
private fun buildSearchContext(
|
||||
candidates: List<ContinuousCandidate>,
|
||||
groupsByKey: Map<CoverageGroupKey, CoverageGroup>
|
||||
): CandidateSearchContext {
|
||||
val longitudeOrdering = determineLongitudeOrdering(candidates)
|
||||
val candidateIndexes = support.buildCandidateIndexes(candidates.map { it.base })
|
||||
val orderingLongitudeById = DoubleArray(candidates.size) { candidateId ->
|
||||
orderingLongitude(candidates[candidateId], longitudeOrdering)
|
||||
}
|
||||
val sortedByLongitudeIds = candidates.indices
|
||||
.sortedWith(
|
||||
compareBy<Int> { orderingLongitudeById[it] }
|
||||
.thenBy { candidates[it].rightLongitude }
|
||||
.thenBy { candidates[it].base.slot.tn }
|
||||
.thenBy { candidates[it].base.slot.slotNumber }
|
||||
)
|
||||
.toIntArray()
|
||||
val sortedLongitudePositionsById = IntArray(candidates.size)
|
||||
sortedByLongitudeIds.forEachIndexed { index, candidateId ->
|
||||
sortedLongitudePositionsById[candidateId] = index
|
||||
}
|
||||
|
||||
val groupForwardNeighborIds = Array(candidates.size) { IntArray(0) }
|
||||
for ((_, group) in groupsByKey) {
|
||||
val ids = group.candidateIds
|
||||
for (index in ids.indices) {
|
||||
val candidateId = ids[index]
|
||||
val from = index + 1
|
||||
val to = minOf(ids.size, from + GROUP_FORWARD_NEIGHBOR_LIMIT)
|
||||
groupForwardNeighborIds[candidateId] =
|
||||
if (from >= to) IntArray(0) else ids.sliceArray(from until to)
|
||||
}
|
||||
}
|
||||
|
||||
val spatialNeighborIds = buildSpatialNeighborIds(candidates, candidateIndexes.spatialIndex, longitudeOrdering)
|
||||
|
||||
return CandidateSearchContext(
|
||||
groupsByKey = groupsByKey,
|
||||
candidateIndexes = candidateIndexes,
|
||||
longitudeOrdering = longitudeOrdering,
|
||||
orderingLongitudeById = orderingLongitudeById,
|
||||
sortedByLongitudeIds = sortedByLongitudeIds,
|
||||
sortedLongitudePositionsById = sortedLongitudePositionsById,
|
||||
groupForwardNeighborIds = groupForwardNeighborIds,
|
||||
spatialNeighborIds = spatialNeighborIds,
|
||||
pairwiseCache = hashMapOf()
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSpatialNeighborIds(
|
||||
candidates: List<ContinuousCandidate>,
|
||||
spatialIndex: STRtree,
|
||||
longitudeOrdering: LongitudeOrdering
|
||||
): Array<IntArray> {
|
||||
return Array(candidates.size) { candidateId ->
|
||||
val candidate = candidates[candidateId]
|
||||
val searchEnvelope = Envelope(candidate.base.coveredEnvelope)
|
||||
searchEnvelope.expandBy(
|
||||
max(candidate.base.coveredEnvelope.width * SPATIAL_ENVELOPE_EXPAND_FACTOR, MIN_SPATIAL_LONGITUDE_PADDING),
|
||||
max(candidate.base.coveredEnvelope.height * SPATIAL_ENVELOPE_EXPAND_FACTOR, MIN_SPATIAL_LATITUDE_PADDING)
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val neighborIds = (spatialIndex.query(searchEnvelope) as List<Any>)
|
||||
.mapNotNull { (it as? Int) }
|
||||
.filter { it != candidateId }
|
||||
.sortedWith(
|
||||
compareBy<Int> { envelopeGap(candidate.base.coveredEnvelope, candidates[it].base.coveredEnvelope) }
|
||||
.thenBy { abs(orderingLongitude(candidates[it], longitudeOrdering) - orderingLongitude(candidate, longitudeOrdering)) }
|
||||
)
|
||||
.take(SPATIAL_NEIGHBOR_LIMIT)
|
||||
neighborIds.toIntArray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun determineLongitudeOrdering(candidates: List<ContinuousCandidate>): LongitudeOrdering {
|
||||
val distinctTrackLongitudes = candidates
|
||||
.map { it.trackLongitude }
|
||||
.sorted()
|
||||
.fold(mutableListOf<Double>()) { acc, value ->
|
||||
if (acc.none { abs(it - value) <= LONGITUDE_EPS }) {
|
||||
acc += value
|
||||
}
|
||||
acc
|
||||
}
|
||||
return LongitudeOrdering(useSlotLongitude = distinctTrackLongitudes.size > 1)
|
||||
}
|
||||
|
||||
private fun orderingLongitude(candidate: ContinuousCandidate, ordering: LongitudeOrdering): Double =
|
||||
if (ordering.useSlotLongitude) candidate.trackLongitude else candidate.rightLongitude
|
||||
|
||||
private fun orderingLongitudeById(candidateId: Int, searchContext: CandidateSearchContext): Double =
|
||||
searchContext.orderingLongitudeById[candidateId]
|
||||
|
||||
private fun normalizeLongitudeToTarget(longitude: Double, targetGeometry: Geometry): Double {
|
||||
val targetCenter = (targetGeometry.envelopeInternal.minX + targetGeometry.envelopeInternal.maxX) / 2.0
|
||||
return listOf(longitude - 360.0, longitude, longitude + 360.0)
|
||||
.minByOrNull { abs(it - targetCenter) }
|
||||
?: longitude
|
||||
}
|
||||
|
||||
private fun longitudeRelation(
|
||||
previous: ContinuousCandidate,
|
||||
candidate: ContinuousCandidate,
|
||||
ordering: LongitudeOrdering
|
||||
): LongitudeRelation {
|
||||
val delta = orderingLongitude(candidate, ordering) - orderingLongitude(previous, ordering)
|
||||
return when {
|
||||
delta > LONGITUDE_EPS -> LongitudeRelation.AHEAD
|
||||
abs(delta) <= LONGITUDE_EPS -> LongitudeRelation.SAME
|
||||
else -> LongitudeRelation.BACKWARD
|
||||
}
|
||||
}
|
||||
|
||||
private fun pairwiseKey(previousId: Int, candidateId: Int): Long =
|
||||
(previousId.toLong() shl 32) xor (candidateId.toLong() and 0xffffffffL)
|
||||
|
||||
private fun envelopeGap(left: Envelope, right: Envelope): Double {
|
||||
val dx = when {
|
||||
left.maxX < right.minX -> right.minX - left.maxX
|
||||
right.maxX < left.minX -> left.minX - right.maxX
|
||||
else -> 0.0
|
||||
}
|
||||
val dy = when {
|
||||
left.maxY < right.minY -> right.minY - left.maxY
|
||||
right.maxY < left.minY -> left.minY - right.maxY
|
||||
else -> 0.0
|
||||
}
|
||||
return kotlin.math.hypot(dx, dy)
|
||||
}
|
||||
|
||||
private enum class LongitudeRelation {
|
||||
AHEAD,
|
||||
SAME,
|
||||
BACKWARD
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LONGITUDE_EPS = 1e-9
|
||||
private const val MEANINGFUL_GAIN_RATIO = 0.001
|
||||
|
||||
private const val START_GAIN_WEIGHT = 100.0
|
||||
private const val START_PREVIEW_GAIN_WEIGHT = 130.0
|
||||
private const val START_PREVIEW_LENGTH_WEIGHT = 18.0
|
||||
private const val START_RIGHT_REACH_WEIGHT = 1.0
|
||||
private const val START_LOOKAHEAD_STEPS = 4
|
||||
private const val START_PREVIEW_TOP_K = 12
|
||||
private const val START_CHEAP_GROUP_WEIGHT = 18.0
|
||||
private const val START_CHEAP_SPATIAL_WEIGHT = 4.0
|
||||
private const val START_CHEAP_RIGHT_REACH_WINDOW = 12
|
||||
|
||||
private const val GROUP_FORWARD_NEIGHBOR_LIMIT = 4
|
||||
private const val SPATIAL_NEIGHBOR_LIMIT = 16
|
||||
private const val LONGITUDE_NEIGHBOR_LIMIT = 20
|
||||
private const val EXTENDED_LONGITUDE_NEIGHBOR_LIMIT = 64
|
||||
private const val SPATIAL_ENVELOPE_EXPAND_FACTOR = 1.5
|
||||
private const val MIN_SPATIAL_LONGITUDE_PADDING = 0.75
|
||||
private const val MIN_SPATIAL_LATITUDE_PADDING = 0.5
|
||||
|
||||
private const val TRANSITION_GAIN_WEIGHT = 100.0
|
||||
private const val TRANSITION_OVERLAP_WEIGHT = 0.35
|
||||
private const val TRANSITION_BRANCH_WEIGHT = 12.0
|
||||
private const val TRANSITION_ROLL_WEIGHT = 1.5
|
||||
private const val TRANSITION_GAP_WEIGHT = 18.0
|
||||
private const val TRANSITION_RIGHT_PROGRESS_WEIGHT = 8.0
|
||||
private const val TRANSITION_CHAIN_PROGRESS_WEIGHT = 10.0
|
||||
private const val TRANSITION_CONTINUITY_BONUS = 28.0
|
||||
}
|
||||
}
|
||||
+816
@@ -0,0 +1,816 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import org.locationtech.jts.geom.Envelope
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.geom.GeometryFactory
|
||||
import org.locationtech.jts.geom.Polygon
|
||||
import org.locationtech.jts.geom.PrecisionModel
|
||||
import org.locationtech.jts.index.strtree.STRtree
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.locationtech.jts.operation.overlayng.OverlayNG
|
||||
import org.locationtech.jts.operation.overlayng.OverlayNGRobust
|
||||
import org.locationtech.jts.precision.GeometryPrecisionReducer
|
||||
import org.slf4j.Logger
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.util.LongitudeWrapGeometry
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
internal data class CoverageSourceSlotCoverage(
|
||||
val slot: SlotDTO,
|
||||
val coveredGeometry: Geometry,
|
||||
val coveredEnvelope: Envelope
|
||||
)
|
||||
|
||||
internal data class CoverageGroupKey(
|
||||
val satelliteId: Long,
|
||||
val cycle: Long,
|
||||
val rollBucket: Int,
|
||||
val revolutionSign: RevolutionSign
|
||||
)
|
||||
|
||||
internal data class CoverageGroup(
|
||||
val candidateIds: IntArray
|
||||
)
|
||||
|
||||
internal data class CoverageSatelliteCandidates(
|
||||
val sortedCandidateIds: IntArray,
|
||||
val maxDurationSeconds: Long
|
||||
)
|
||||
|
||||
internal data class CoverageCandidateIndexes(
|
||||
val satelliteCandidates: Map<Long, CoverageSatelliteCandidates>,
|
||||
val spatialIndex: STRtree
|
||||
)
|
||||
|
||||
internal data class CoverageCandidate(
|
||||
val id: Int,
|
||||
val slot: SlotDTO,
|
||||
val sourceCoverages: List<CoverageSourceSlotCoverage>,
|
||||
val satellite: AbstractSatellite,
|
||||
val coveredGeometry: Geometry,
|
||||
val coveredEnvelope: Envelope,
|
||||
val blockedStart: LocalDateTime,
|
||||
val blockedEnd: LocalDateTime,
|
||||
val durationSeconds: Long,
|
||||
val groupKey: CoverageGroupKey,
|
||||
val groupIndex: Int
|
||||
)
|
||||
|
||||
internal data class CoverageSlotChain(
|
||||
val representative: SlotDTO,
|
||||
val slots: List<SlotDTO>
|
||||
)
|
||||
|
||||
private data class CoverageSlotChainKey(
|
||||
val satelliteId: Long,
|
||||
val cycle: Long,
|
||||
val rollBucket: Int,
|
||||
val revolutionSign: RevolutionSign
|
||||
)
|
||||
|
||||
internal data class GeometryMergeAssessment(
|
||||
val mergeable: Boolean,
|
||||
val intersectionArea: Double? = null,
|
||||
val geometryChecked: Boolean = true
|
||||
)
|
||||
|
||||
internal class CoverageSolverSupport(
|
||||
private val logger: Logger,
|
||||
private val satellitesById: Map<Long, AbstractSatellite>
|
||||
) {
|
||||
|
||||
private data class SlotCoverage(
|
||||
val id: Int,
|
||||
val slot: SlotDTO,
|
||||
val coveredGeometry: Geometry,
|
||||
val coveredEnvelope: Envelope,
|
||||
val area: Double
|
||||
)
|
||||
|
||||
private val geometryFactory = GeometryFactory()
|
||||
private val wktReader = WKTReader()
|
||||
|
||||
fun parseTargetGeometry(targetWkt: String): Geometry {
|
||||
val rawGeometry = try {
|
||||
LongitudeWrapGeometry.normalizeToContinuous360(wktReader.read(targetWkt))
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Некорректный контур заявки: ${e.message}")
|
||||
}
|
||||
return normalizeGeometry(rawGeometry)
|
||||
}
|
||||
|
||||
fun mergeSlotsToChains(slots: List<SlotDTO>): List<CoverageSlotChain> {
|
||||
if (slots.isEmpty()) return emptyList()
|
||||
|
||||
val buckets = mutableMapOf<CoverageSlotChainKey, MutableList<SlotDTO>>()
|
||||
for (slot in slots) {
|
||||
val key = CoverageSlotChainKey(
|
||||
satelliteId = slot.satelliteId,
|
||||
cycle = slot.cycle,
|
||||
rollBucket = rollBucket(slot.roll),
|
||||
revolutionSign = slot.revolutionSign
|
||||
)
|
||||
buckets.getOrPut(key) { mutableListOf() }.add(slot)
|
||||
}
|
||||
|
||||
val chains = mutableListOf<CoverageSlotChain>()
|
||||
for ((_, bucketSlots) in buckets) {
|
||||
if (bucketSlots.isEmpty()) continue
|
||||
bucketSlots.sortWith(compareBy<SlotDTO>({ it.tn }, { it.tk }, { it.slotNumber }))
|
||||
val maxChainDurationSeconds = satellitesById[bucketSlots.first().satelliteId]
|
||||
?.maxSurveyDuration
|
||||
?.toLong()
|
||||
?.coerceAtLeast(0L)
|
||||
?: Long.MAX_VALUE
|
||||
|
||||
var chainStart = bucketSlots.first().tn
|
||||
var chainEnd = bucketSlots.first().tk
|
||||
var chainContour = bucketSlots.first().contour
|
||||
var chainSlots = mutableListOf(bucketSlots.first())
|
||||
|
||||
for (i in 1 until bucketSlots.size) {
|
||||
val slot = bucketSlots[i]
|
||||
val candidateEnd = if (slot.tk > chainEnd) slot.tk else chainEnd
|
||||
val candidateDurationSeconds = Duration.between(chainStart, candidateEnd).seconds.coerceAtLeast(0L)
|
||||
val exceedsMaxDuration = candidateDurationSeconds > maxChainDurationSeconds
|
||||
|
||||
if (intervalsIntersect(chainStart, chainEnd, slot.tn, slot.tk) && !exceedsMaxDuration) {
|
||||
val geometryMergeAssessment = assessMergeableGeometryIntersection(
|
||||
leftContour = chainContour,
|
||||
rightContour = slot.contour,
|
||||
satelliteId = chainSlots.first().satelliteId
|
||||
)
|
||||
if (!geometryMergeAssessment.mergeable) {
|
||||
if (geometryMergeAssessment.geometryChecked) {
|
||||
logChainMergeSkippedWithoutSufficientGeometry(
|
||||
chainRepresentative = chainSlots.first(),
|
||||
candidate = slot,
|
||||
intersectionArea = geometryMergeAssessment.intersectionArea ?: 0.0
|
||||
)
|
||||
}
|
||||
chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour))
|
||||
chainStart = slot.tn
|
||||
chainEnd = slot.tk
|
||||
chainContour = slot.contour
|
||||
chainSlots = mutableListOf(slot)
|
||||
continue
|
||||
}
|
||||
val mergedContour = unionContoursIfPolygon(
|
||||
baseContour = chainContour,
|
||||
contourToAdd = slot.contour,
|
||||
chainRepresentative = chainSlots.first(),
|
||||
candidate = slot
|
||||
)
|
||||
if (mergedContour == null) {
|
||||
chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour))
|
||||
chainStart = slot.tn
|
||||
chainEnd = slot.tk
|
||||
chainContour = slot.contour
|
||||
chainSlots = mutableListOf(slot)
|
||||
continue
|
||||
}
|
||||
chainSlots.add(slot)
|
||||
chainEnd = candidateEnd
|
||||
chainContour = mergedContour
|
||||
} else {
|
||||
chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour))
|
||||
chainStart = slot.tn
|
||||
chainEnd = slot.tk
|
||||
chainContour = slot.contour
|
||||
chainSlots = mutableListOf(slot)
|
||||
}
|
||||
}
|
||||
chains.add(createSlotChain(chainSlots, chainStart, chainEnd, chainContour))
|
||||
}
|
||||
|
||||
return chains
|
||||
}
|
||||
|
||||
fun createSlotChain(
|
||||
slots: List<SlotDTO>,
|
||||
chainStart: LocalDateTime,
|
||||
chainEnd: LocalDateTime,
|
||||
chainContour: String
|
||||
): CoverageSlotChain {
|
||||
val first = slots.first()
|
||||
val chainState = when {
|
||||
slots.any { it.state == SlotStatus.BOOKED } -> SlotStatus.BOOKED
|
||||
slots.any { it.state == SlotStatus.AVAILABLE } -> SlotStatus.AVAILABLE
|
||||
else -> first.state
|
||||
}
|
||||
|
||||
val representative = SlotDTO(
|
||||
cycle = first.cycle,
|
||||
satelliteId = first.satelliteId,
|
||||
tn = chainStart,
|
||||
tk = chainEnd,
|
||||
roll = first.roll,
|
||||
contour = chainContour,
|
||||
revolution = first.revolution,
|
||||
revolutionSign = first.revolutionSign,
|
||||
slotNumber = slots.minOf { it.slotNumber },
|
||||
state = chainState,
|
||||
latitude = first.latitude,
|
||||
longitude = first.longitude
|
||||
)
|
||||
|
||||
return CoverageSlotChain(representative = representative, slots = slots.toList())
|
||||
}
|
||||
|
||||
fun assessMergeableGeometryIntersection(
|
||||
leftContour: String,
|
||||
rightContour: String,
|
||||
satelliteId: Long
|
||||
): GeometryMergeAssessment {
|
||||
return try {
|
||||
val leftGeometry = wktReader.read(leftContour)
|
||||
val rightGeometry = wktReader.read(rightContour)
|
||||
logInvalidGeometryIfNeeded(satelliteId, "chain", leftGeometry)
|
||||
logInvalidGeometryIfNeeded(satelliteId, "candidate", rightGeometry)
|
||||
val intersectionArea = leftGeometry.intersection(rightGeometry).area
|
||||
GeometryMergeAssessment(
|
||||
mergeable = intersectionArea > AREA_EPS,
|
||||
intersectionArea = intersectionArea
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Не удалось проверить пересечение геометрий цепочки solver: {}", e.message)
|
||||
GeometryMergeAssessment(mergeable = false, geometryChecked = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun unionContoursIfPolygon(
|
||||
baseContour: String,
|
||||
contourToAdd: String,
|
||||
chainRepresentative: SlotDTO,
|
||||
candidate: SlotDTO
|
||||
): String? {
|
||||
return try {
|
||||
val unionGeometry = wktReader.read(baseContour).union(wktReader.read(contourToAdd))
|
||||
if (unionGeometry !is Polygon) {
|
||||
logger.warn(
|
||||
"Solver chain merge skipped because union produced non-polygon geometry: satelliteId={}, geometryType={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotNumber={}, slotNumber={}",
|
||||
chainRepresentative.satelliteId,
|
||||
unionGeometry.geometryType,
|
||||
chainRepresentative.tn,
|
||||
chainRepresentative.tk,
|
||||
candidate.tn,
|
||||
candidate.tk,
|
||||
chainRepresentative.roll,
|
||||
candidate.roll,
|
||||
chainRepresentative.slotNumber,
|
||||
candidate.slotNumber
|
||||
)
|
||||
return null
|
||||
}
|
||||
unionGeometry.toText()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Не удалось объединить геометрию цепочки solver: {}", e.message)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun buildCandidates(
|
||||
slotChains: List<CoverageSlotChain>,
|
||||
targetGeometry: Geometry
|
||||
): Pair<List<CoverageCandidate>, Map<CoverageGroupKey, CoverageGroup>> {
|
||||
val tmpCandidates = ArrayList<CoverageCandidate>(slotChains.size)
|
||||
val groupBuckets = mutableMapOf<CoverageGroupKey, MutableList<Int>>()
|
||||
val targetEnvelope = targetGeometry.envelopeInternal
|
||||
|
||||
for (chain in slotChains) {
|
||||
val slot = chain.representative
|
||||
val satellite = satellitesById[slot.satelliteId] ?: continue
|
||||
var chainCoveredGeometry: Geometry? = null
|
||||
val sourceCoverages = mutableListOf<CoverageSourceSlotCoverage>()
|
||||
|
||||
for (sourceSlot in chain.slots) {
|
||||
val slotGeometry = try {
|
||||
normalizeGeometry(
|
||||
LongitudeWrapGeometry.alignToReference(
|
||||
wktReader.read(sourceSlot.contour),
|
||||
targetGeometry
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Пропуск слота {}:{}: {}", sourceSlot.satelliteId, sourceSlot.slotNumber, e.message)
|
||||
continue
|
||||
}
|
||||
if (!slotGeometry.envelopeInternal.intersects(targetEnvelope)) {
|
||||
continue
|
||||
}
|
||||
val coveredGeometry = safeIntersection(slotGeometry, targetGeometry)
|
||||
if (coveredGeometry.isEmpty || coveredGeometry.area <= AREA_EPS) {
|
||||
continue
|
||||
}
|
||||
sourceCoverages.add(
|
||||
CoverageSourceSlotCoverage(
|
||||
slot = sourceSlot,
|
||||
coveredGeometry = coveredGeometry,
|
||||
coveredEnvelope = coveredGeometry.envelopeInternal
|
||||
)
|
||||
)
|
||||
|
||||
chainCoveredGeometry = if (chainCoveredGeometry == null) {
|
||||
coveredGeometry
|
||||
} else {
|
||||
safeUnion(chainCoveredGeometry!!, coveredGeometry)
|
||||
}
|
||||
}
|
||||
|
||||
val coveredGeometry = chainCoveredGeometry ?: continue
|
||||
if (coveredGeometry.isEmpty || coveredGeometry.area <= AREA_EPS) {
|
||||
continue
|
||||
}
|
||||
val rollBucket = rollBucket(slot.roll)
|
||||
val groupKey = CoverageGroupKey(
|
||||
satelliteId = slot.satelliteId,
|
||||
cycle = slot.cycle,
|
||||
rollBucket = rollBucket,
|
||||
revolutionSign = slot.revolutionSign
|
||||
)
|
||||
val candidateId = tmpCandidates.size
|
||||
val mmi = satellite.mmi
|
||||
val candidate = CoverageCandidate(
|
||||
id = candidateId,
|
||||
slot = slot,
|
||||
sourceCoverages = sourceCoverages,
|
||||
satellite = satellite,
|
||||
coveredGeometry = coveredGeometry,
|
||||
coveredEnvelope = coveredGeometry.envelopeInternal,
|
||||
blockedStart = slot.tn.minusSeconds(mmi),
|
||||
blockedEnd = slot.tk.plusSeconds(mmi),
|
||||
durationSeconds = Duration.between(slot.tn, slot.tk).seconds.coerceAtLeast(0L),
|
||||
groupKey = groupKey,
|
||||
groupIndex = 0
|
||||
)
|
||||
tmpCandidates.add(candidate)
|
||||
groupBuckets.getOrPut(groupKey) { mutableListOf() }.add(candidateId)
|
||||
}
|
||||
|
||||
val finalizedCandidates = tmpCandidates.toMutableList()
|
||||
val groups = mutableMapOf<CoverageGroupKey, CoverageGroup>()
|
||||
|
||||
for ((key, ids) in groupBuckets) {
|
||||
ids.sortWith(compareBy<Int> { finalizedCandidates[it].slot.tn }
|
||||
.thenBy { finalizedCandidates[it].slot.tk }
|
||||
.thenBy { finalizedCandidates[it].slot.slotNumber })
|
||||
ids.forEachIndexed { index, id ->
|
||||
val old = finalizedCandidates[id]
|
||||
finalizedCandidates[id] = old.copy(groupIndex = index)
|
||||
}
|
||||
groups[key] = CoverageGroup(ids.toIntArray())
|
||||
}
|
||||
|
||||
return finalizedCandidates to groups
|
||||
}
|
||||
|
||||
fun buildSatelliteCandidatesIndex(candidates: List<CoverageCandidate>): Map<Long, CoverageSatelliteCandidates> {
|
||||
val candidatesBySatellite = mutableMapOf<Long, MutableList<Int>>()
|
||||
val maxDurationBySatellite = mutableMapOf<Long, Long>()
|
||||
|
||||
for (candidate in candidates) {
|
||||
val satelliteId = candidate.slot.satelliteId
|
||||
candidatesBySatellite.getOrPut(satelliteId) { mutableListOf() }.add(candidate.id)
|
||||
val maxDuration = maxDurationBySatellite[satelliteId] ?: 0L
|
||||
if (candidate.durationSeconds > maxDuration) {
|
||||
maxDurationBySatellite[satelliteId] = candidate.durationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
val result = mutableMapOf<Long, CoverageSatelliteCandidates>()
|
||||
for ((satelliteId, ids) in candidatesBySatellite) {
|
||||
ids.sortWith(compareBy<Int> { candidates[it].slot.tn }
|
||||
.thenBy { candidates[it].slot.tk }
|
||||
.thenBy { candidates[it].slot.slotNumber })
|
||||
result[satelliteId] = CoverageSatelliteCandidates(
|
||||
sortedCandidateIds = ids.toIntArray(),
|
||||
maxDurationSeconds = maxDurationBySatellite[satelliteId] ?: 0L
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun buildCandidateIndexes(candidates: List<CoverageCandidate>): CoverageCandidateIndexes {
|
||||
val spatialIndex = STRtree(candidates.size)
|
||||
for (candidate in candidates) {
|
||||
spatialIndex.insert(candidate.coveredEnvelope, candidate.id)
|
||||
}
|
||||
spatialIndex.build()
|
||||
return CoverageCandidateIndexes(
|
||||
satelliteCandidates = buildSatelliteCandidatesIndex(candidates),
|
||||
spatialIndex = spatialIndex
|
||||
)
|
||||
}
|
||||
|
||||
fun deactivateConflicts(
|
||||
selected: List<CoverageCandidate>,
|
||||
candidatesBySatellite: Map<Long, CoverageSatelliteCandidates>,
|
||||
candidates: List<CoverageCandidate>,
|
||||
active: BooleanArray
|
||||
) {
|
||||
if (selected.isEmpty()) return
|
||||
for (sel in selected) {
|
||||
val satelliteCandidates = candidatesBySatellite[sel.slot.satelliteId] ?: continue
|
||||
val ids = satelliteCandidates.sortedCandidateIds
|
||||
if (ids.isEmpty()) continue
|
||||
|
||||
val startBound = sel.blockedStart.minusSeconds(satelliteCandidates.maxDurationSeconds)
|
||||
val from = lowerBoundByStart(ids, candidates, startBound)
|
||||
val to = upperBoundByStart(ids, candidates, sel.blockedEnd)
|
||||
|
||||
for (idx in from until to) {
|
||||
val candidateId = ids[idx]
|
||||
if (!active[candidateId]) continue
|
||||
val candidate = candidates[candidateId]
|
||||
if (intervalsIntersect(candidate.slot.tn, candidate.slot.tk, sel.blockedStart, sel.blockedEnd)) {
|
||||
active[candidate.id] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRedundant(selected: List<CoverageCandidate>): List<CoverageCandidate> {
|
||||
if (selected.isEmpty()) return emptyList()
|
||||
val kept = selected.toMutableList()
|
||||
val targetArea = unionGeometries(kept.map { it.coveredGeometry }).area
|
||||
|
||||
var changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
val removable = kept
|
||||
.filter { it.slot.state != SlotStatus.BOOKED }
|
||||
.sortedBy { it.coveredGeometry.area }
|
||||
val indexById = kept
|
||||
.withIndex()
|
||||
.associate { (index, candidate) -> candidate.id to index }
|
||||
val prefixUnions = buildPrefixUnions(kept.map { it.coveredGeometry })
|
||||
val suffixUnions = buildSuffixUnions(kept.map { it.coveredGeometry })
|
||||
|
||||
for (candidate in removable) {
|
||||
val candidateIndex = indexById[candidate.id] ?: continue
|
||||
val areaWithout = safeUnion(
|
||||
prefixUnions[candidateIndex],
|
||||
suffixUnions[candidateIndex + 1]
|
||||
).area
|
||||
if (targetArea - areaWithout <= AREA_EPS) {
|
||||
kept.removeIf { it.id == candidate.id }
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
fun collectUsefulSlotsFromSelectedChains(
|
||||
selectedCandidates: List<CoverageCandidate>,
|
||||
targetGeometry: Geometry
|
||||
): List<SlotDTO> {
|
||||
if (selectedCandidates.isEmpty()) return emptyList()
|
||||
|
||||
val orderedCandidates = selectedCandidates.sortedWith(compareBy<CoverageCandidate>(
|
||||
{ it.slot.tn },
|
||||
{ it.slot.satelliteId },
|
||||
{ it.slot.cycle },
|
||||
{ it.slot.slotNumber }
|
||||
))
|
||||
|
||||
val resultSlots = mutableListOf<SlotDTO>()
|
||||
val seen = hashSetOf<String>()
|
||||
var uncovered = targetGeometry
|
||||
var uncoveredEnvelope = targetGeometry.envelopeInternal
|
||||
|
||||
for (candidate in orderedCandidates) {
|
||||
val orderedSources = candidate.sourceCoverages.sortedWith(compareBy<CoverageSourceSlotCoverage>(
|
||||
{ it.slot.tn },
|
||||
{ it.slot.satelliteId },
|
||||
{ it.slot.cycle },
|
||||
{ it.slot.slotNumber }
|
||||
))
|
||||
|
||||
for (source in orderedSources) {
|
||||
if (!source.coveredEnvelope.intersects(uncoveredEnvelope)) continue
|
||||
val gainArea = safeIntersection(source.coveredGeometry, uncovered).area
|
||||
if (gainArea <= AREA_EPS) continue
|
||||
|
||||
val key = slotKey(source.slot)
|
||||
if (seen.add(key)) {
|
||||
resultSlots.add(source.slot)
|
||||
}
|
||||
|
||||
uncovered = safeDifference(uncovered, source.coveredGeometry)
|
||||
uncoveredEnvelope = uncovered.envelopeInternal
|
||||
if (uncovered.isEmpty || uncovered.area <= AREA_EPS) {
|
||||
return resultSlots
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resultSlots
|
||||
}
|
||||
|
||||
fun buildPreparedSourceCoverageMap(selectedCandidates: List<CoverageCandidate>): Map<String, CoverageSourceSlotCoverage> {
|
||||
if (selectedCandidates.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
val result = linkedMapOf<String, CoverageSourceSlotCoverage>()
|
||||
for (candidate in selectedCandidates) {
|
||||
for (sourceCoverage in candidate.sourceCoverages) {
|
||||
result.putIfAbsent(slotKey(sourceCoverage.slot), sourceCoverage)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun removeNonCoveringAndFullyCoveredResultSlots(
|
||||
slots: List<SlotDTO>,
|
||||
targetGeometry: Geometry,
|
||||
preparedSourceCoverages: Map<String, CoverageSourceSlotCoverage> = emptyMap()
|
||||
): List<SlotDTO> {
|
||||
if (slots.isEmpty()) return emptyList()
|
||||
|
||||
val targetEnvelope = targetGeometry.envelopeInternal
|
||||
val slotCoverages = ArrayList<SlotCoverage>(slots.size)
|
||||
|
||||
for (slot in slots) {
|
||||
val preparedCoverage = preparedSourceCoverages[slotKey(slot)]
|
||||
val coveredGeometry = if (preparedCoverage != null) {
|
||||
preparedCoverage.coveredGeometry
|
||||
} else {
|
||||
try {
|
||||
val slotGeometry = normalizeGeometry(
|
||||
LongitudeWrapGeometry.alignToReference(
|
||||
wktReader.read(slot.contour),
|
||||
targetGeometry
|
||||
)
|
||||
)
|
||||
if (!slotGeometry.envelopeInternal.intersects(targetEnvelope)) {
|
||||
geometryFactory.createPolygon()
|
||||
} else {
|
||||
safeIntersection(slotGeometry, targetGeometry)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Исключен слот {}:{}: некорректный контур ({})", slot.satelliteId, slot.slotNumber, e.message)
|
||||
geometryFactory.createPolygon()
|
||||
}
|
||||
}
|
||||
|
||||
if (coveredGeometry.isEmpty || coveredGeometry.area <= AREA_EPS) continue
|
||||
slotCoverages.add(
|
||||
SlotCoverage(
|
||||
id = slotCoverages.size,
|
||||
slot = slot,
|
||||
coveredGeometry = coveredGeometry,
|
||||
coveredEnvelope = coveredGeometry.envelopeInternal,
|
||||
area = coveredGeometry.area
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (slotCoverages.size <= 1) return slotCoverages.map { it.slot }
|
||||
|
||||
val index = STRtree(slotCoverages.size)
|
||||
for (coverage in slotCoverages) {
|
||||
index.insert(coverage.coveredEnvelope, coverage)
|
||||
}
|
||||
index.build()
|
||||
|
||||
val active = BooleanArray(slotCoverages.size) { true }
|
||||
val orderedByArea = slotCoverages
|
||||
.indices
|
||||
.sortedWith(compareBy<Int> { slotCoverages[it].area }
|
||||
.thenBy { slotCoverages[it].slot.tn }
|
||||
.thenBy { slotCoverages[it].slot.satelliteId }
|
||||
.thenBy { slotCoverages[it].slot.cycle }
|
||||
.thenBy { slotCoverages[it].slot.slotNumber })
|
||||
|
||||
for (idx in orderedByArea) {
|
||||
if (!active[idx]) continue
|
||||
val candidate = slotCoverages[idx]
|
||||
|
||||
val queried = index.query(candidate.coveredEnvelope)
|
||||
val coverers = ArrayList<SlotCoverage>(queried.size)
|
||||
for (item in queried) {
|
||||
val coverage = item as? SlotCoverage ?: continue
|
||||
if (coverage.id == candidate.id) continue
|
||||
if (!active[coverage.id]) continue
|
||||
if (!coverage.coveredEnvelope.intersects(candidate.coveredEnvelope)) continue
|
||||
coverers.add(coverage)
|
||||
}
|
||||
if (coverers.isEmpty()) continue
|
||||
|
||||
var fullyCovered = false
|
||||
for (coverer in coverers) {
|
||||
if (coverer.coveredGeometry.covers(candidate.coveredGeometry)) {
|
||||
fullyCovered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (fullyCovered) {
|
||||
active[idx] = false
|
||||
continue
|
||||
}
|
||||
|
||||
val orderedCoverers = coverers.sortedByDescending {
|
||||
envelopeIntersectionArea(it.coveredEnvelope, candidate.coveredEnvelope)
|
||||
}
|
||||
|
||||
var unionGeometry: Geometry? = null
|
||||
val unionEnvelope = Envelope()
|
||||
for (coverer in orderedCoverers) {
|
||||
unionGeometry = if (unionGeometry == null) {
|
||||
coverer.coveredGeometry
|
||||
} else {
|
||||
safeUnion(unionGeometry!!, coverer.coveredGeometry)
|
||||
}
|
||||
|
||||
unionEnvelope.expandToInclude(coverer.coveredEnvelope)
|
||||
if (!unionEnvelope.contains(candidate.coveredEnvelope)) continue
|
||||
|
||||
val uncoveredPart = safeDifference(candidate.coveredGeometry, unionGeometry!!)
|
||||
if (uncoveredPart.isEmpty || uncoveredPart.area <= AREA_EPS) {
|
||||
active[idx] = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slotCoverages
|
||||
.filter { active[it.id] }
|
||||
.map { it.slot }
|
||||
}
|
||||
|
||||
fun unionGeometries(geometries: List<Geometry>): Geometry {
|
||||
if (geometries.isEmpty()) return geometryFactory.createPolygon()
|
||||
var union = geometries.first()
|
||||
for (i in 1 until geometries.size) {
|
||||
union = safeUnion(union, geometries[i])
|
||||
}
|
||||
return union
|
||||
}
|
||||
|
||||
fun safeIntersection(a: Geometry, b: Geometry): Geometry {
|
||||
if (a.isEmpty || b.isEmpty) return geometryFactory.createPolygon()
|
||||
if (!a.envelopeInternal.intersects(b.envelopeInternal)) return geometryFactory.createPolygon()
|
||||
return safeOverlay(a, b, OverlayNG.INTERSECTION)
|
||||
}
|
||||
|
||||
fun safeDifference(a: Geometry, b: Geometry): Geometry {
|
||||
if (a.isEmpty) return geometryFactory.createPolygon()
|
||||
if (b.isEmpty) return a
|
||||
if (!a.envelopeInternal.intersects(b.envelopeInternal)) return a
|
||||
return safeOverlay(a, b, OverlayNG.DIFFERENCE)
|
||||
}
|
||||
|
||||
fun safeUnion(a: Geometry, b: Geometry): Geometry {
|
||||
if (a.isEmpty) return b
|
||||
if (b.isEmpty) return a
|
||||
return safeOverlay(a, b, OverlayNG.UNION)
|
||||
}
|
||||
|
||||
fun normalizeGeometry(geometry: Geometry): Geometry {
|
||||
if (geometry.isEmpty) return geometryFactory.createPolygon()
|
||||
return if (geometry.isValid) geometry else geometry.buffer(0.0)
|
||||
}
|
||||
|
||||
fun intervalsIntersect(
|
||||
start1: LocalDateTime,
|
||||
end1: LocalDateTime,
|
||||
start2: LocalDateTime,
|
||||
end2: LocalDateTime
|
||||
) = start1 <= end2 && start2 <= end1
|
||||
|
||||
fun rollBucket(roll: Double): Int = (roll / ROLL_EPS).toInt()
|
||||
|
||||
private fun lowerBoundByStart(ids: IntArray, candidates: List<CoverageCandidate>, threshold: LocalDateTime): Int {
|
||||
var low = 0
|
||||
var high = ids.size
|
||||
while (low < high) {
|
||||
val mid = (low + high) ushr 1
|
||||
if (candidates[ids[mid]].slot.tn < threshold) {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
private fun upperBoundByStart(ids: IntArray, candidates: List<CoverageCandidate>, threshold: LocalDateTime): Int {
|
||||
var low = 0
|
||||
var high = ids.size
|
||||
while (low < high) {
|
||||
val mid = (low + high) ushr 1
|
||||
if (candidates[ids[mid]].slot.tn <= threshold) {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
private fun slotKey(slot: SlotDTO): String =
|
||||
"${slot.satelliteId}_${slot.cycle}_${slot.slotNumber}_${slot.tn}_${slot.tk}_${slot.roll}"
|
||||
|
||||
private fun buildPrefixUnions(geometries: List<Geometry>): List<Geometry> {
|
||||
val prefix = ArrayList<Geometry>(geometries.size + 1)
|
||||
prefix.add(geometryFactory.createPolygon())
|
||||
for (geometry in geometries) {
|
||||
prefix.add(safeUnion(prefix.last(), geometry))
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
private fun buildSuffixUnions(geometries: List<Geometry>): List<Geometry> {
|
||||
val suffix = MutableList(geometries.size + 1) { geometryFactory.createPolygon() as Geometry }
|
||||
for (index in geometries.indices.reversed()) {
|
||||
suffix[index] = safeUnion(geometries[index], suffix[index + 1])
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
private fun envelopeIntersectionArea(a: Envelope, b: Envelope): Double {
|
||||
val minX = maxOf(a.minX, b.minX)
|
||||
val maxX = minOf(a.maxX, b.maxX)
|
||||
if (maxX <= minX) return 0.0
|
||||
|
||||
val minY = maxOf(a.minY, b.minY)
|
||||
val maxY = minOf(a.maxY, b.maxY)
|
||||
if (maxY <= minY) return 0.0
|
||||
|
||||
return (maxX - minX) * (maxY - minY)
|
||||
}
|
||||
|
||||
private fun safeOverlay(a: Geometry, b: Geometry, opCode: Int): Geometry {
|
||||
val g1 = normalizeGeometry(a)
|
||||
val g2 = normalizeGeometry(b)
|
||||
|
||||
return try {
|
||||
normalizeGeometry(OverlayNGRobust.overlay(g1, g2, opCode))
|
||||
} catch (_: Exception) {
|
||||
val snapped1 = snapGeometry(g1, 1e-6)
|
||||
val snapped2 = snapGeometry(g2, 1e-6)
|
||||
try {
|
||||
normalizeGeometry(OverlayNGRobust.overlay(snapped1, snapped2, opCode))
|
||||
} catch (_: Exception) {
|
||||
val buffered1 = normalizeGeometry(snapped1.buffer(0.0))
|
||||
val buffered2 = normalizeGeometry(snapped2.buffer(0.0))
|
||||
normalizeGeometry(OverlayNGRobust.overlay(buffered1, buffered2, opCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapGeometry(geometry: Geometry, tolerance: Double): Geometry {
|
||||
val scale = 1.0 / tolerance
|
||||
return normalizeGeometry(GeometryPrecisionReducer.reduce(geometry, PrecisionModel(scale)))
|
||||
}
|
||||
|
||||
private fun logInvalidGeometryIfNeeded(
|
||||
satelliteId: Long,
|
||||
geometryRole: String,
|
||||
geometry: Geometry
|
||||
) {
|
||||
if (geometry.isValid) {
|
||||
return
|
||||
}
|
||||
logger.warn(
|
||||
"Solver chain merge geometry is invalid: satelliteId={}, geometryRole={}, geometryType={}",
|
||||
satelliteId,
|
||||
geometryRole,
|
||||
geometry.geometryType
|
||||
)
|
||||
}
|
||||
|
||||
private fun logChainMergeSkippedWithoutSufficientGeometry(
|
||||
chainRepresentative: SlotDTO,
|
||||
candidate: SlotDTO,
|
||||
intersectionArea: Double
|
||||
) {
|
||||
logger.warn(
|
||||
"Solver chain merge skipped because contours lack sufficient areal intersection despite temporal overlap: satelliteId={}, intersectionArea={}, epsilon={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotNumber={}, slotNumber={}",
|
||||
chainRepresentative.satelliteId,
|
||||
intersectionArea,
|
||||
AREA_EPS,
|
||||
chainRepresentative.tn,
|
||||
chainRepresentative.tk,
|
||||
candidate.tn,
|
||||
candidate.tk,
|
||||
chainRepresentative.roll,
|
||||
candidate.roll,
|
||||
chainRepresentative.slotNumber,
|
||||
candidate.slotNumber
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val AREA_EPS = 1e-8
|
||||
const val ROLL_EPS = 0.01
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class Mar (
|
||||
val sat : Long = 0,
|
||||
val tn : LocalDateTime = LocalDateTime.now(),
|
||||
val tk : LocalDateTime = LocalDateTime.now(),
|
||||
val roll : Double = 0.0,
|
||||
val contour : String = "",
|
||||
var poly : Geometry? = null,
|
||||
val vit : Long = 0,
|
||||
val revolutionSign: RevolutionSign = RevolutionSign.ASC,
|
||||
var cycle : Int = 1,
|
||||
var slotNum : Long = 0,
|
||||
val latitude : Double = 0.0,
|
||||
val longitude : Double = 0.0,
|
||||
){
|
||||
}
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import org.locationtech.jts.geom.Envelope
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.slf4j.Logger
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import java.time.LocalDateTime
|
||||
import java.util.PriorityQueue
|
||||
import kotlin.jvm.Throws
|
||||
import kotlin.math.abs
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class OptimalReqCoverSolver(
|
||||
private val logger: Logger,
|
||||
private val satellitesById: Map<Long, AbstractSatellite>
|
||||
) {
|
||||
|
||||
private data class QueueItem(
|
||||
val candidateId: Int,
|
||||
val gainArea: Double,
|
||||
val tn: LocalDateTime
|
||||
)
|
||||
|
||||
private val support = CoverageSolverSupport(logger, satellitesById)
|
||||
|
||||
@Throws(CustomErrorException::class)
|
||||
fun select(targetWkt: String, slots: List<SlotDTO>): List<SlotDTO> {
|
||||
lateinit var slotChains: List<CoverageSlotChain>
|
||||
val mergeChainsMs = measureTimeMillis {
|
||||
slotChains = mergeSlotsToChains(slots)
|
||||
}
|
||||
logger.info("Слотов после объединения в цепочки ${slotChains.size}")
|
||||
|
||||
// if (slotChains.size > 25000)
|
||||
// throw CustomErrorException("Для расчета покрытия получено больше 5000 слотов! Расчет отменен")
|
||||
|
||||
if (slotChains.isEmpty()) {
|
||||
logger.info(
|
||||
"coverageSolver metrics: inputSlots={}, chains=0, candidates=0, resultSlots=0, mergeChainsMs={}, totalMs={}",
|
||||
slots.size,
|
||||
mergeChainsMs,
|
||||
mergeChainsMs
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
lateinit var targetGeometry: Geometry
|
||||
val parseTargetMs = measureTimeMillis {
|
||||
targetGeometry = support.parseTargetGeometry(targetWkt)
|
||||
}
|
||||
if (targetGeometry.isEmpty || targetGeometry.area <= CoverageSolverSupport.AREA_EPS) {
|
||||
logger.info(
|
||||
"coverageSolver metrics: inputSlots={}, chains={}, candidates=0, resultSlots=0, mergeChainsMs={}, parseTargetMs={}, totalMs={}",
|
||||
slots.size,
|
||||
slotChains.size,
|
||||
mergeChainsMs,
|
||||
parseTargetMs,
|
||||
mergeChainsMs + parseTargetMs
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
lateinit var preparedCandidates: Pair<List<CoverageCandidate>, Map<CoverageGroupKey, CoverageGroup>>
|
||||
val buildCandidatesMs = measureTimeMillis {
|
||||
preparedCandidates = support.buildCandidates(slotChains, targetGeometry)
|
||||
}
|
||||
val candidates = preparedCandidates.first
|
||||
if (candidates.isEmpty()) {
|
||||
logger.info(
|
||||
"coverageSolver metrics: inputSlots={}, chains={}, candidates=0, resultSlots=0, mergeChainsMs={}, parseTargetMs={}, buildCandidatesMs={}, totalMs={}",
|
||||
slots.size,
|
||||
slotChains.size,
|
||||
mergeChainsMs,
|
||||
parseTargetMs,
|
||||
buildCandidatesMs,
|
||||
mergeChainsMs + parseTargetMs + buildCandidatesMs
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
val groupsByKey = preparedCandidates.second
|
||||
|
||||
lateinit var satelliteCandidates: Map<Long, CoverageSatelliteCandidates>
|
||||
val buildIndexMs = measureTimeMillis {
|
||||
satelliteCandidates = support.buildSatelliteCandidatesIndex(candidates)
|
||||
}
|
||||
|
||||
val active = BooleanArray(candidates.size) { true }
|
||||
val selected = mutableListOf<CoverageCandidate>()
|
||||
var uncovered = targetGeometry
|
||||
var uncoveredEnvelope = targetGeometry.envelopeInternal
|
||||
var pqPolls = 0
|
||||
var lazyRequeues = 0
|
||||
var zeroGainDeactivated = 0
|
||||
var envelopeMissDeactivated = 0
|
||||
var acceptedCandidates = 0
|
||||
var conflictDeactivateCalls = 0
|
||||
|
||||
val pq = PriorityQueue<QueueItem>(compareByDescending<QueueItem> { it.gainArea }
|
||||
.thenBy { it.tn })
|
||||
|
||||
val mainLoopMs = measureTimeMillis {
|
||||
for (candidate in candidates) {
|
||||
if (candidate.slot.state == SlotStatus.BOOKED) {
|
||||
selected.add(candidate)
|
||||
acceptedCandidates++
|
||||
uncovered = support.safeDifference(uncovered, candidate.coveredGeometry)
|
||||
uncoveredEnvelope = uncovered.envelopeInternal
|
||||
active[candidate.id] = false
|
||||
} else if (candidate.slot.state == SlotStatus.AVAILABLE) {
|
||||
pq.add(QueueItem(candidate.id, candidate.coveredGeometry.area, candidate.slot.tn))
|
||||
} else {
|
||||
active[candidate.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isNotEmpty()) {
|
||||
conflictDeactivateCalls++
|
||||
support.deactivateConflicts(selected, satelliteCandidates, candidates, active)
|
||||
}
|
||||
|
||||
while (pq.isNotEmpty()) {
|
||||
pqPolls++
|
||||
val item = pq.poll()
|
||||
if (!active[item.candidateId]) {
|
||||
continue
|
||||
}
|
||||
val candidate = candidates[item.candidateId]
|
||||
if (!candidate.coveredEnvelope.intersects(uncoveredEnvelope)) {
|
||||
active[item.candidateId] = false
|
||||
envelopeMissDeactivated++
|
||||
continue
|
||||
}
|
||||
|
||||
val gainArea = support.safeIntersection(candidate.coveredGeometry, uncovered).area
|
||||
if (gainArea <= CoverageSolverSupport.AREA_EPS) {
|
||||
active[item.candidateId] = false
|
||||
zeroGainDeactivated++
|
||||
continue
|
||||
}
|
||||
|
||||
// lazy update if gain dropped significantly
|
||||
if (gainArea + CoverageSolverSupport.AREA_EPS < item.gainArea) {
|
||||
lazyRequeues++
|
||||
pq.add(QueueItem(candidate.id, gainArea, candidate.slot.tn))
|
||||
continue
|
||||
}
|
||||
|
||||
val chain = buildGreedyChain(candidate, groupsByKey, candidates, active, uncovered, uncoveredEnvelope)
|
||||
if (chain.isEmpty()) {
|
||||
active[item.candidateId] = false
|
||||
continue
|
||||
}
|
||||
|
||||
val acceptedChain = mutableListOf<CoverageCandidate>()
|
||||
chain.forEach { chosen ->
|
||||
if (active[chosen.id]) {
|
||||
if (!chosen.coveredEnvelope.intersects(uncoveredEnvelope)) {
|
||||
active[chosen.id] = false
|
||||
envelopeMissDeactivated++
|
||||
return@forEach
|
||||
}
|
||||
val gainArea = support.safeIntersection(chosen.coveredGeometry, uncovered).area
|
||||
if (gainArea <= CoverageSolverSupport.AREA_EPS) {
|
||||
active[chosen.id] = false
|
||||
zeroGainDeactivated++
|
||||
return@forEach
|
||||
}
|
||||
selected.add(chosen)
|
||||
acceptedCandidates++
|
||||
acceptedChain.add(chosen)
|
||||
uncovered = support.safeDifference(uncovered, chosen.coveredGeometry)
|
||||
uncoveredEnvelope = uncovered.envelopeInternal
|
||||
active[chosen.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
if (acceptedChain.isNotEmpty()) {
|
||||
conflictDeactivateCalls++
|
||||
support.deactivateConflicts(acceptedChain, satelliteCandidates, candidates, active)
|
||||
}
|
||||
|
||||
if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lateinit var nonRedundantCandidates: List<CoverageCandidate>
|
||||
val removeRedundantMs = measureTimeMillis {
|
||||
nonRedundantCandidates = support.removeRedundant(selected)
|
||||
}
|
||||
lateinit var preparedSourceCoverages: Map<String, CoverageSourceSlotCoverage>
|
||||
val sourceCoverageMapMs = measureTimeMillis {
|
||||
preparedSourceCoverages = support.buildPreparedSourceCoverageMap(nonRedundantCandidates)
|
||||
}
|
||||
lateinit var resultSlots: List<SlotDTO>
|
||||
val collectUsefulSlotsMs = measureTimeMillis {
|
||||
resultSlots = support.collectUsefulSlotsFromSelectedChains(nonRedundantCandidates, targetGeometry)
|
||||
}
|
||||
lateinit var filteredResult: List<SlotDTO>
|
||||
val finalFilterMs = measureTimeMillis {
|
||||
filteredResult = support.removeNonCoveringAndFullyCoveredResultSlots(
|
||||
slots = resultSlots,
|
||||
targetGeometry = targetGeometry,
|
||||
preparedSourceCoverages = preparedSourceCoverages
|
||||
)
|
||||
}
|
||||
val totalMs = mergeChainsMs + parseTargetMs + buildCandidatesMs + buildIndexMs + mainLoopMs +
|
||||
removeRedundantMs + sourceCoverageMapMs + collectUsefulSlotsMs + finalFilterMs
|
||||
val coveragePercent = 100 * (targetGeometry.area - uncovered.area) / targetGeometry.area
|
||||
logger.info(
|
||||
"coverageSolver metrics: inputSlots={}, chains={}, candidates={}, groups={}, selectedCandidates={}, nonRedundantCandidates={}, sourceCoverageCache={}, sourceSlots={} -> {}, coveragePercent={}, pqPolls={}, lazyRequeues={}, zeroGainDeactivated={}, envelopeMissDeactivated={}, conflictDeactivateCalls={}, acceptedCandidates={}, mergeChainsMs={}, parseTargetMs={}, buildCandidatesMs={}, buildIndexMs={}, mainLoopMs={}, removeRedundantMs={}, sourceCoverageMapMs={}, collectUsefulSlotsMs={}, finalFilterMs={}, totalMs={}",
|
||||
slots.size,
|
||||
slotChains.size,
|
||||
candidates.size,
|
||||
groupsByKey.size,
|
||||
selected.size,
|
||||
nonRedundantCandidates.size,
|
||||
preparedSourceCoverages.size,
|
||||
resultSlots.size,
|
||||
filteredResult.size,
|
||||
coveragePercent,
|
||||
pqPolls,
|
||||
lazyRequeues,
|
||||
zeroGainDeactivated,
|
||||
envelopeMissDeactivated,
|
||||
conflictDeactivateCalls,
|
||||
acceptedCandidates,
|
||||
mergeChainsMs,
|
||||
parseTargetMs,
|
||||
buildCandidatesMs,
|
||||
buildIndexMs,
|
||||
mainLoopMs,
|
||||
removeRedundantMs,
|
||||
sourceCoverageMapMs,
|
||||
collectUsefulSlotsMs,
|
||||
finalFilterMs,
|
||||
totalMs
|
||||
)
|
||||
return filteredResult
|
||||
.sortedWith(compareBy<SlotDTO>(
|
||||
{ it.tn },
|
||||
{ it.satelliteId },
|
||||
{ it.cycle },
|
||||
{ it.slotNumber }
|
||||
))
|
||||
}
|
||||
|
||||
private fun mergeSlotsToChains(slots: List<SlotDTO>): List<CoverageSlotChain> =
|
||||
support.mergeSlotsToChains(slots)
|
||||
|
||||
private fun createSlotChain(
|
||||
slots: List<SlotDTO>,
|
||||
chainStart: LocalDateTime,
|
||||
chainEnd: LocalDateTime,
|
||||
chainContour: String
|
||||
): CoverageSlotChain = support.createSlotChain(slots, chainStart, chainEnd, chainContour)
|
||||
|
||||
private fun assessMergeableGeometryIntersection(
|
||||
leftContour: String,
|
||||
rightContour: String,
|
||||
satelliteId: Long
|
||||
): GeometryMergeAssessment = support.assessMergeableGeometryIntersection(leftContour, rightContour, satelliteId)
|
||||
|
||||
private fun unionContoursIfPolygon(
|
||||
baseContour: String,
|
||||
contourToAdd: String,
|
||||
chainRepresentative: SlotDTO,
|
||||
candidate: SlotDTO
|
||||
): String? = support.unionContoursIfPolygon(baseContour, contourToAdd, chainRepresentative, candidate)
|
||||
|
||||
private fun buildGreedyChain(
|
||||
anchor: CoverageCandidate,
|
||||
groupsByKey: Map<CoverageGroupKey, CoverageGroup>,
|
||||
candidates: List<CoverageCandidate>,
|
||||
active: BooleanArray,
|
||||
uncovered: Geometry,
|
||||
uncoveredEnvelope: Envelope
|
||||
): List<CoverageCandidate> {
|
||||
val group = groupsByKey[anchor.groupKey] ?: return listOf(anchor)
|
||||
val ids = group.candidateIds
|
||||
var left = anchor.groupIndex
|
||||
var right = anchor.groupIndex
|
||||
var duration = anchor.durationSeconds
|
||||
val maxDuration = anchor.satellite.maxSurveyDuration.toLong().coerceAtLeast(anchor.durationSeconds)
|
||||
|
||||
val chain = mutableListOf(anchor)
|
||||
|
||||
var expanded = true
|
||||
while (expanded) {
|
||||
expanded = false
|
||||
|
||||
val leftCandidate = if (left > 0) candidates[ids[left - 1]] else null
|
||||
val rightCandidate = if (right < ids.lastIndex) candidates[ids[right + 1]] else null
|
||||
|
||||
val chooseLeft = shouldExtend(leftCandidate, anchor, active, duration, maxDuration, uncovered, uncoveredEnvelope)
|
||||
val chooseRight = shouldExtend(rightCandidate, anchor, active, duration, maxDuration, uncovered, uncoveredEnvelope)
|
||||
|
||||
val chosen = when {
|
||||
chooseLeft == null && chooseRight == null -> null
|
||||
chooseLeft != null && chooseRight == null -> chooseLeft.second
|
||||
chooseLeft == null && chooseRight != null -> chooseRight.second
|
||||
else -> if (chooseLeft!!.first >= chooseRight!!.first) chooseLeft.second else chooseRight!!.second
|
||||
}
|
||||
|
||||
if (chosen != null) {
|
||||
val candidate = chosen
|
||||
chain.add(candidate)
|
||||
duration += candidate.durationSeconds
|
||||
if (candidate.id == leftCandidate?.id) {
|
||||
left--
|
||||
} else if (candidate.id == rightCandidate?.id) {
|
||||
right++
|
||||
}
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
|
||||
return chain.sortedBy { it.slot.tn }
|
||||
}
|
||||
|
||||
private fun shouldExtend(
|
||||
candidate: CoverageCandidate?,
|
||||
anchor: CoverageCandidate,
|
||||
active: BooleanArray,
|
||||
currentDuration: Long,
|
||||
maxDuration: Long,
|
||||
uncovered: Geometry,
|
||||
uncoveredEnvelope: Envelope
|
||||
): Pair<Double, CoverageCandidate>? {
|
||||
if (candidate == null) return null
|
||||
if (!active[candidate.id]) return null
|
||||
if (!canChainNeighbours(anchor, candidate)) return null
|
||||
if (currentDuration + candidate.durationSeconds > maxDuration) return null
|
||||
if (!candidate.coveredEnvelope.intersects(uncoveredEnvelope)) return null
|
||||
|
||||
val gain = support.safeIntersection(candidate.coveredGeometry, uncovered).area
|
||||
if (gain <= CoverageSolverSupport.AREA_EPS) return null
|
||||
|
||||
val score = gain
|
||||
return score to candidate
|
||||
}
|
||||
|
||||
private fun canChainNeighbours(left: CoverageCandidate, right: CoverageCandidate): Boolean {
|
||||
if (left.slot.satelliteId != right.slot.satelliteId) return false
|
||||
if (left.slot.cycle != right.slot.cycle) return false
|
||||
if (abs(left.slot.roll - right.slot.roll) >= CoverageSolverSupport.ROLL_EPS) return false
|
||||
return support.intervalsIntersect(
|
||||
right.slot.tn,
|
||||
right.slot.tk,
|
||||
left.blockedStart,
|
||||
left.blockedEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
|
||||
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.locationtech.jts.geom.PrecisionModel
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.locationtech.jts.operation.overlayng.OverlayNG
|
||||
import org.locationtech.jts.operation.union.CascadedPolygonUnion
|
||||
import org.locationtech.jts.precision.GeometryPrecisionReducer
|
||||
import org.locationtech.jts.simplify.TopologyPreservingSimplifier
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.abs
|
||||
|
||||
data class TimedPolygon(
|
||||
val geometry: Geometry,
|
||||
val time: LocalDateTime,
|
||||
val id: String
|
||||
)
|
||||
|
||||
data class PolygonCoverage(
|
||||
val selectedPolygons: List<TimedPolygon>,
|
||||
val coverageArea: Double,
|
||||
val coveragePercentage: Double,
|
||||
val uncoveredArea: Geometry?
|
||||
)
|
||||
|
||||
class PolygonCoverageSolver {
|
||||
|
||||
private val geometryFactory = GeometryFactory()
|
||||
private val wktReader = WKTReader()
|
||||
|
||||
|
||||
// Вспомогательный объект для безопасных геометрических операций
|
||||
private object GeometryUtils {
|
||||
private const val SNAP_TOLERANCE = 1e-6
|
||||
private const val MIN_AREA = 1e-8
|
||||
private val geometryFactory = GeometryFactory()
|
||||
|
||||
// Безопасное пересечение через OverlayNG
|
||||
fun safeIntersection(g1: Geometry, g2: Geometry): Geometry {
|
||||
return try {
|
||||
val result = OverlayNG.overlay(g1, g2, OverlayNG.INTERSECTION)
|
||||
validateAndNormalize(result)
|
||||
} catch (e: Exception) {
|
||||
recoverTopology(g1, g2) { a, b -> OverlayNG.overlay(a, b, OverlayNG.INTERSECTION) }
|
||||
}
|
||||
}
|
||||
|
||||
// Безопасная разность
|
||||
fun safeDifference(g1: Geometry, g2: Geometry): Geometry {
|
||||
return try {
|
||||
val result = OverlayNG.overlay(g1, g2, OverlayNG.DIFFERENCE)
|
||||
validateAndNormalize(result)
|
||||
} catch (e: Exception) {
|
||||
recoverTopology(g1, g2) { a, b -> OverlayNG.overlay(a, b, OverlayNG.DIFFERENCE) }
|
||||
}
|
||||
}
|
||||
|
||||
// Безопасное объединение списка геометрий (рекомендуемый подход для JTS 1.19+)
|
||||
fun safeUnion(geometries: List<Geometry>): Geometry {
|
||||
if (geometries.isEmpty()) return geometryFactory.createPolygon()
|
||||
if (geometries.size == 1) return validateAndNormalize(geometries[0])
|
||||
|
||||
// Используем CascadedPolygonUnion — оптимальный для больших наборов
|
||||
return try {
|
||||
val result = CascadedPolygonUnion.union(geometries)
|
||||
validateAndNormalize(result)
|
||||
} catch (e: Exception) {
|
||||
// Резерв: поэлементное объединение
|
||||
var union = validateAndNormalize(geometries[0])
|
||||
for (i in 1 until geometries.size) {
|
||||
union = safeUnion(union, geometries[i])
|
||||
}
|
||||
union
|
||||
}
|
||||
}
|
||||
|
||||
// Безопасное объединение двух геометрий
|
||||
fun safeUnion(g1: Geometry, g2: Geometry): Geometry {
|
||||
return try {
|
||||
val result = OverlayNG.overlay(g1, g2, OverlayNG.UNION)
|
||||
validateAndNormalize(result)
|
||||
} catch (e: Exception) {
|
||||
recoverTopology(g1, g2) { a, b -> OverlayNG.overlay(a, b, OverlayNG.UNION) }
|
||||
}
|
||||
}
|
||||
|
||||
// Механизм восстановления топологии при ошибках
|
||||
private fun recoverTopology(
|
||||
g1: Geometry,
|
||||
g2: Geometry,
|
||||
operation: (Geometry, Geometry) -> Geometry
|
||||
): Geometry {
|
||||
// Уровень 1: снэппинг к сетке
|
||||
try {
|
||||
val snapped1 = snapToGrid(g1, SNAP_TOLERANCE)
|
||||
val snapped2 = snapToGrid(g2, SNAP_TOLERANCE)
|
||||
return validateAndNormalize(operation(snapped1, snapped2))
|
||||
} catch (e1: Exception) {
|
||||
// Уровень 2: упрощение с сохранением топологии
|
||||
try {
|
||||
val simplified1 = TopologyPreservingSimplifier.simplify(snapToGrid(g1, SNAP_TOLERANCE), SNAP_TOLERANCE * 0.1)
|
||||
val simplified2 = TopologyPreservingSimplifier.simplify(snapToGrid(g2, SNAP_TOLERANCE), SNAP_TOLERANCE * 0.1)
|
||||
return validateAndNormalize(operation(simplified1, simplified2))
|
||||
} catch (e2: Exception) {
|
||||
// Уровень 3: буферизация как крайняя мера
|
||||
return validateAndNormalize(g1.buffer(SNAP_TOLERANCE * 0.01).union(g2.buffer(SNAP_TOLERANCE * 0.01)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Снэппинг к сетке
|
||||
private fun snapToGrid(geom: Geometry, tolerance: Double): Geometry {
|
||||
val gridScale = 1.0 / tolerance
|
||||
return GeometryPrecisionReducer.reduce(geom, PrecisionModel(gridScale))
|
||||
}
|
||||
|
||||
// Валидация и нормализация геометрии
|
||||
fun validateAndNormalize(geom: Geometry): Geometry {
|
||||
if (geom.isEmpty || geom.area < MIN_AREA) {
|
||||
return geometryFactory.createPolygon()
|
||||
}
|
||||
|
||||
// Исправление невалидных геометрий
|
||||
val validGeom = if (!geom.isValid) {
|
||||
try {
|
||||
// Для JTS 1.18+ доступен makeValid()
|
||||
geom.buffer(0.0)
|
||||
} catch (e: Exception) {
|
||||
// Резервный вариант для всех версий
|
||||
geom.buffer(0.0)
|
||||
}
|
||||
} else {
|
||||
geom
|
||||
}
|
||||
|
||||
// Очистка вырожденных частей для MultiPolygon
|
||||
return when (validGeom) {
|
||||
is Polygon -> if (validGeom.area > MIN_AREA) validGeom else geometryFactory.createPolygon()
|
||||
is MultiPolygon -> {
|
||||
val validParts = (0 until validGeom.numGeometries)
|
||||
.map { validGeom.getGeometryN(it) }
|
||||
.filter { it is Polygon && it.area > MIN_AREA }
|
||||
.map { it as Polygon }
|
||||
|
||||
when {
|
||||
validParts.isEmpty() -> geometryFactory.createPolygon()
|
||||
validParts.size == 1 -> validParts[0]
|
||||
else -> geometryFactory.createMultiPolygon(validParts.toTypedArray())
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Для других типов (LineString, Point) — преобразуем в буфер
|
||||
if (validGeom is GeometryCollection) {
|
||||
// Рекурсивная обработка коллекции
|
||||
val validGeoms = (0 until validGeom.numGeometries)
|
||||
.map { validateAndNormalize(validGeom.getGeometryN(it)) }
|
||||
.filter { !it.isEmpty && it.area > MIN_AREA }
|
||||
if (validGeoms.isEmpty()) geometryFactory.createPolygon()
|
||||
else safeUnion(validGeoms)
|
||||
} else {
|
||||
// Преобразуем в полигон через буфер
|
||||
validGeom.buffer(SNAP_TOLERANCE * 0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательный класс для обработки слотов
|
||||
private data class SlotGeometry(
|
||||
val slot: SlotDTO,
|
||||
val geometry: Geometry,
|
||||
val intersectionArea: Double
|
||||
)
|
||||
|
||||
/**
|
||||
* Оптимальное покрытие полигона с балансом площади и количества слотов
|
||||
*
|
||||
* @param slots список доступных слотов с полигонами в формате WKT
|
||||
* @param targetWkt целевой полигон в формате WKT
|
||||
* @param minCoverageRatio минимальная доля покрытия (0.0 - 1.0), по умолчанию 0.95 (95%)
|
||||
* @param minRelativeGain минимальный относительный прирост площади для продолжения отбора
|
||||
* @return список оптимально отобранных слотов
|
||||
*/
|
||||
fun optimalPolygonCoverage(
|
||||
slots: List<SlotDTO>,
|
||||
targetWkt: String,
|
||||
minCoverageRatio: Double = 0.95,
|
||||
minRelativeGain: Double = 0.005
|
||||
): List<SlotDTO> {
|
||||
|
||||
val wktReader = WKTReader()
|
||||
val geometryFactory = GeometryFactory()
|
||||
|
||||
// Валидация целевого полигона
|
||||
val targetGeom: Polygon = try {
|
||||
val raw = wktReader.read(targetWkt)
|
||||
require(raw is Polygon) { "Target must be Polygon, got ${raw.geometryType}" }
|
||||
GeometryUtils.validateAndNormalize(raw) as Polygon
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Invalid target WKT: ${e.message}", e)
|
||||
}
|
||||
val targetArea = targetGeom.area
|
||||
require(targetArea > 1e-6) { "Target polygon has near-zero area: $targetArea" }
|
||||
|
||||
|
||||
|
||||
|
||||
// Предварительная обработка слотов с защитой от ошибок
|
||||
val validSlots = slots.mapNotNull { slot ->
|
||||
try {
|
||||
val rawGeom = wktReader.read(slot.contour)
|
||||
val validGeom = GeometryUtils.validateAndNormalize(rawGeom)
|
||||
|
||||
// Быстрая проверка через envelope (ограничивающий прямоугольник)
|
||||
if (!validGeom.envelopeInternal.intersects(targetGeom.envelopeInternal)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val intersection = GeometryUtils.safeIntersection(validGeom, targetGeom)
|
||||
val area = intersection.area
|
||||
|
||||
// Фильтрация аномалий: слишком маленькие или слишком большие пересечения
|
||||
if (area > 1e-6 && area < targetArea * 10) {
|
||||
SlotGeometry(slot, validGeom, area)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Для production замените на логирование
|
||||
println("Skipping invalid slot ${slot.slotNumber}: ${e.message?.take(100)}")
|
||||
null
|
||||
}
|
||||
}.sortedByDescending { it.intersectionArea }
|
||||
.toMutableList()
|
||||
|
||||
if (validSlots.isEmpty()) return emptyList()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Жадный отбор с приоритетом уникальной площади
|
||||
val selected = mutableListOf<SlotGeometry>()
|
||||
var uncovered: Geometry = targetGeom // Тип Geometry, а не Polygon!
|
||||
|
||||
// забронированные слоты
|
||||
val bookedSlots = validSlots.filter { it.slot.state == SlotStatus.BOOKED }
|
||||
bookedSlots.forEach { bestCandidate ->
|
||||
|
||||
selected.add(bestCandidate)
|
||||
validSlots.remove(bestCandidate)
|
||||
validSlots.removeIf { slot ->
|
||||
bestCandidate.slot.satelliteId == slot.slot.satelliteId &&
|
||||
abs(Duration.between(slot.slot.tn, bestCandidate.slot.tn).seconds) < 30
|
||||
}
|
||||
if (uncovered.area > targetArea * (1 - minCoverageRatio)){
|
||||
uncovered = GeometryUtils.safeDifference(uncovered, bestCandidate.geometry)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
while (uncovered.area > targetArea * (1 - minCoverageRatio)) {
|
||||
var bestGain = 0.0
|
||||
var bestCandidate: SlotGeometry? = null
|
||||
for (candidate in validSlots) {
|
||||
if (candidate in selected) continue
|
||||
val newPart = GeometryUtils.safeIntersection(candidate.geometry, uncovered)
|
||||
val gain = newPart.area
|
||||
if (gain > bestGain) {
|
||||
bestGain = gain
|
||||
bestCandidate = candidate
|
||||
}
|
||||
}
|
||||
// Условия остановки
|
||||
// if (bestCandidate == null || bestGain < targetArea * minRelativeGain || bestGain < 1e-6) break
|
||||
if (bestCandidate == null || bestGain < 1e-8) break
|
||||
selected.add(bestCandidate)
|
||||
validSlots.remove(bestCandidate)
|
||||
validSlots.removeIf { slot ->
|
||||
slot.slot.satelliteId == bestCandidate.slot.satelliteId &&
|
||||
abs(Duration.between(slot.slot.tn, bestCandidate.slot.tn).seconds) < 30
|
||||
}
|
||||
uncovered = GeometryUtils.safeDifference(uncovered, bestCandidate.geometry)
|
||||
// Защита от вырожденных геометрий
|
||||
if (uncovered.isEmpty) break
|
||||
}
|
||||
|
||||
// Постоптимизация: удаление избыточных слотов
|
||||
return removeRedundantSlots(selected, targetGeom, targetArea).map { it.slot }
|
||||
}
|
||||
|
||||
private fun removeRedundantSlots(
|
||||
slots: List<SlotGeometry>,
|
||||
targetGeom: Polygon,
|
||||
targetArea: Double
|
||||
): List<SlotGeometry> {
|
||||
val essential = slots.filter { it.slot.state == SlotStatus.BOOKED }.toMutableList()
|
||||
val candidates = slots.filter { it.slot.state == SlotStatus.AVAILABLE }.sortedByDescending { it.intersectionArea }.toMutableList()
|
||||
|
||||
while (candidates.isNotEmpty()) {
|
||||
val candidate = candidates.removeFirst()
|
||||
val others = essential + candidates
|
||||
|
||||
val othersUnion = if (others.isNotEmpty()) {
|
||||
GeometryUtils.safeUnion(others.map { it.geometry })
|
||||
} else {
|
||||
GeometryFactory().createPolygon()
|
||||
}
|
||||
|
||||
// Вычисляем покрытие с и без кандидата
|
||||
val coverageWith = GeometryUtils.safeIntersection(
|
||||
GeometryUtils.safeUnion(listOf(othersUnion, candidate.geometry)),
|
||||
targetGeom
|
||||
)
|
||||
val coverageWithout = GeometryUtils.safeIntersection(othersUnion, targetGeom)
|
||||
|
||||
val areaLoss = coverageWith.area - coverageWithout.area
|
||||
|
||||
// Сохраняем только если потеря площади значима (>0.1% от целевой)
|
||||
if (areaLoss > targetArea * 0.001) {
|
||||
essential.add(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return essential
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
fun intersection(tn : LocalDateTime, tk : LocalDateTime, tn2 : LocalDateTime, tk2 : LocalDateTime) =
|
||||
tn <= tk2 && tn2 <= tk
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package space.nstart.pcp.slots_service.model.satellite
|
||||
|
||||
import ballistics.types.InitialConditions
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import kotlin.math.sin
|
||||
|
||||
data class PreparedCoverageSlot(
|
||||
val sourceSlotId: Long,
|
||||
val slot: SlotDTO
|
||||
)
|
||||
|
||||
abstract class AbstractSatellite(
|
||||
val id : Long = 0,
|
||||
var cycleRevs : Long = 243,
|
||||
var tnCalc : LocalDateTime = LocalDateTime.of(
|
||||
LocalDate.of(2025,10,27),
|
||||
LocalTime.of(0,29,23,162630000)),
|
||||
var durationCalc : Long = 16,
|
||||
var slotDuration : Long = 10,
|
||||
val maxChainLength : Int = 30,
|
||||
val maxSurveyDuration : Int = 300,
|
||||
val mmi : Long = 10,
|
||||
val angles : List<Double> = listOf(),
|
||||
val ic : InitialConditions = InitialConditions()
|
||||
) {
|
||||
|
||||
abstract fun prepareCoverageSlots(
|
||||
slots : List<SlotEntity>,
|
||||
bookedSlots : List<BookedSlotEntity>,
|
||||
timeStart : LocalDateTime,
|
||||
timeStop : LocalDateTime,
|
||||
sign : RevolutionSign?,
|
||||
states : List<SlotStatus>? = null
|
||||
) : List<PreparedCoverageSlot>
|
||||
|
||||
fun prepareSlots(
|
||||
slots : List<SlotEntity>,
|
||||
bookedSlots : List<BookedSlotEntity>,
|
||||
timeStart : LocalDateTime,
|
||||
timeStop : LocalDateTime,
|
||||
sign : RevolutionSign?,
|
||||
states : List<SlotStatus>? = null
|
||||
) : List<SlotDTO> = prepareCoverageSlots(slots, bookedSlots, timeStart, timeStop, sign, states).map { it.slot }
|
||||
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package space.nstart.pcp.slots_service.model.satellite
|
||||
|
||||
import ballistics.types.InitialConditions
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.BookedSlot
|
||||
import space.nstart.pcp.slots_service.model.intersection
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import kotlin.math.abs
|
||||
|
||||
class TestSatelliteImpl(
|
||||
id : Long = 0,
|
||||
cycleRevs : Long = 243,
|
||||
tnCalc : LocalDateTime = LocalDateTime.of(
|
||||
LocalDate.of(2025,10,27),
|
||||
LocalTime.of(0,29,23,162630000)),
|
||||
durationCalc : Long = 16,
|
||||
slotDuration : Long = 10,
|
||||
maxChainLength : Int = 3,
|
||||
maxSurveyDuration: Int = 300,
|
||||
mmi: Long = 10,
|
||||
angles : List<Double> = listOf(),
|
||||
ic : InitialConditions = InitialConditions()
|
||||
) : AbstractSatellite(id, cycleRevs, tnCalc, durationCalc, slotDuration, maxChainLength, maxSurveyDuration, mmi, angles, ic) {
|
||||
override fun prepareCoverageSlots(
|
||||
slots: List<SlotEntity>,
|
||||
bookedSlots: List<BookedSlotEntity>,
|
||||
timeStart : LocalDateTime,
|
||||
timeStop : LocalDateTime,
|
||||
sign : RevolutionSign?,
|
||||
states : List<SlotStatus>?
|
||||
): List<PreparedCoverageSlot> {
|
||||
class BookedInfo(val id : Long, val cycle : Long)
|
||||
val selectedSlots = mutableListOf<PreparedCoverageSlot>()
|
||||
val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc
|
||||
val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc
|
||||
val booked = bookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) }
|
||||
val bookedData = (
|
||||
bookedSlots.map { BookedSlot(it.slot.tn, it.slot.tk,it.slot.roll, 0, it.cycle) }.toMutableList()
|
||||
)
|
||||
calculateChainLengths(bookedData)
|
||||
for (cycleN in cycleBegin..cycleEnd) {
|
||||
val currentSlots = slots.map { ent ->
|
||||
SlotEntity(
|
||||
ent.slotId,
|
||||
ent.slotNum,
|
||||
cycleN,
|
||||
ent.satelliteId,
|
||||
ent.coveringType,
|
||||
ent.tn.plusDays(cycleN * durationCalc),
|
||||
ent.tk.plusDays(cycleN * durationCalc),
|
||||
ent.roll,
|
||||
ent.contour,
|
||||
ent.revolution + cycleN * cycleRevs,
|
||||
ent.revolutionSign,
|
||||
latitude = ent.latitude,
|
||||
longitude = ent.longitude
|
||||
)
|
||||
}
|
||||
selectedSlots.addAll(
|
||||
currentSlots.filter { slot ->
|
||||
slot.tn >= timeStart && slot.tk <= timeStop &&
|
||||
sign?.let { slot.revolutionSign == it.toString() } ?: true
|
||||
}.map {
|
||||
val band = bookedData.filter { booked ->
|
||||
val dif = intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), booked.t, booked.tEnd)
|
||||
|
||||
val gam = abs(it.roll - booked.roll)
|
||||
|
||||
if (dif) {
|
||||
if (gam < 0.01 && booked.neighbours < maxChainLength) {
|
||||
// if (gam < 0.01) {
|
||||
// booked.neighbours++
|
||||
false
|
||||
} else true
|
||||
} else false
|
||||
}
|
||||
PreparedCoverageSlot(
|
||||
sourceSlotId = it.slotId ?: 0L,
|
||||
slot = it.toDTO(
|
||||
booked.count { booked -> booked.cycle == it.cycle && booked.id == it.slotId } > 0,
|
||||
band.isNotEmpty()
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
if (!states.isNullOrEmpty())
|
||||
selectedSlots.removeIf { !states.contains(it.slot.state) }
|
||||
return selectedSlots
|
||||
}
|
||||
|
||||
private fun calculateChainLengths(slots: MutableList<BookedSlot>) {
|
||||
if (slots.isEmpty())
|
||||
return
|
||||
// 1. Сортируем слоты по времени
|
||||
slots.sortBy { it.t }
|
||||
var chainStartIndex = 0
|
||||
// 2. Проходим по отсортированному списку и определяем границы цепочек
|
||||
for (i in 1 until slots.size) {
|
||||
val prev = slots[i - 1]
|
||||
val current = slots[i]
|
||||
// Проверяем критерии соседства:
|
||||
// - разница во времени < 1 секунды
|
||||
// - разница в roll < 0.01
|
||||
val timeDiffSeconds = Duration.between(prev.t, current.t).toMillis() / 1000.0
|
||||
val rollDiff = abs(current.roll - prev.roll)
|
||||
// Если элементы НЕ являются соседями — завершаем текущую цепочку
|
||||
if (timeDiffSeconds >= 30.0 || rollDiff >= 0.01) {
|
||||
// Заполняем длину цепочки всем её элементам
|
||||
val chainLength = (i - chainStartIndex).toShort()
|
||||
for (j in chainStartIndex until i) {
|
||||
slots[j].neighbours = chainLength
|
||||
}
|
||||
// Начинаем новую цепочку
|
||||
chainStartIndex = i
|
||||
}
|
||||
}
|
||||
// 3. Обрабатываем последнюю цепочку
|
||||
val lastChainLength = (slots.size - chainStartIndex).toShort()
|
||||
for (j in chainStartIndex until slots.size) {
|
||||
slots[j].neighbours = lastChainLength
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package space.nstart.pcp.slots_service.repository
|
||||
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp.slots_service.entity.BookedRequestEntity
|
||||
|
||||
interface BookedRequestRepository : JpaRepository<BookedRequestEntity, Long>{
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("DELETE FROM booked_slot_request WHERE booked_slot_request_id =:id", nativeQuery = true)
|
||||
fun deleteByBookedSlotRequestId(@Param("id") id : Long) : Int
|
||||
|
||||
fun findByBookedSlot_BookedSlotIdAndRequestId(slotId:Long, requestId:String): BookedRequestEntity?
|
||||
|
||||
fun findByRequestId(requestId:String): List<BookedRequestEntity>
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package space.nstart.pcp.slots_service.repository
|
||||
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.data.jpa.repository.EntityGraph
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import java.util.Optional
|
||||
|
||||
interface BookedSlotsRepository : JpaRepository<BookedSlotEntity, Long>{
|
||||
|
||||
fun findByBookedSlotId(id : Long) : Optional<BookedSlotEntity>
|
||||
|
||||
fun findBySlot_SlotIdAndCycle(id : Long, cycle : Long) : BookedSlotEntity?
|
||||
|
||||
fun findBySlot_SatelliteIdAndCycleIn(id : Long, n : Iterable<Long>) : List<BookedSlotEntity>
|
||||
|
||||
fun findBySlot_SatelliteIdAndCycleBetween(id: Long, cycleBegin: Long, cycleEnd: Long): List<BookedSlotEntity>
|
||||
|
||||
fun findBySlot_SatelliteIdInAndCycleBetween(ids: Collection<Long>, cycleBegin: Long, cycleEnd: Long): List<BookedSlotEntity>
|
||||
|
||||
fun countBySlot_SatelliteId(id: Long): Long
|
||||
|
||||
@EntityGraph(attributePaths = ["slot"])
|
||||
fun findAllByBookedSlotIdIn(ids: Collection<Long>): List<BookedSlotEntity>
|
||||
|
||||
@EntityGraph(attributePaths = ["slot"])
|
||||
fun findAllByStatus(status: String): List<BookedSlotEntity>
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("DELETE FROM booked_slot WHERE booked_slot_id =:id", nativeQuery = true)
|
||||
fun deleteByBookedSlotId(@Param("id") id : Long) : Int
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package space.nstart.pcp.slots_service.repository
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp.slots_service.entity.SatelliteIcEntity
|
||||
|
||||
interface SatelliteIcRepository : JpaRepository<SatelliteIcEntity, Long> {
|
||||
fun findAllByOrderBySatelliteIdAsc(): List<SatelliteIcEntity>
|
||||
|
||||
@Modifying
|
||||
@Query("delete from SatelliteIcEntity entity where entity.satelliteId = :satelliteId")
|
||||
fun deleteBySatelliteId(@Param("satelliteId") satelliteId: Long): Int
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package space.nstart.pcp.slots_service.repository
|
||||
|
||||
import jakarta.persistence.EntityManager
|
||||
import jakarta.persistence.PersistenceContext
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
interface SlotRepository: JpaRepository<SlotEntity, Long>, SlotRepositoryCustom {
|
||||
interface SlotCalculationSummaryProjection {
|
||||
val satelliteId: Long
|
||||
val slotCount: Long
|
||||
val minTime: LocalDateTime?
|
||||
val maxTime: LocalDateTime?
|
||||
val minRevolution: Long?
|
||||
val maxRevolution: Long?
|
||||
}
|
||||
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("DELETE FROM slot WHERE satellite_id =:sat", nativeQuery = true)
|
||||
fun deleteBySatId(@Param("sat") sat : Long) : Int
|
||||
|
||||
fun findBySatelliteId(id : Long) : List<SlotEntity>
|
||||
|
||||
fun countBySatelliteId(id: Long): Long
|
||||
|
||||
fun findBySatelliteIdAndRevolutionIn(id : Long, revs : Iterable<Long>) : List<SlotEntity>
|
||||
|
||||
fun findBySatelliteIdAndSlotNum(id : Long, num : Long) : Optional<SlotEntity>
|
||||
|
||||
fun findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
|
||||
satelliteId: Long,
|
||||
timeStart: java.time.LocalDateTime,
|
||||
timeStop: java.time.LocalDateTime
|
||||
): List<SlotEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
CAST(:satelliteId AS BIGINT) AS "satelliteId",
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM slot WHERE satellite_id = :satelliteId)
|
||||
THEN CAST(1 AS BIGINT)
|
||||
ELSE CAST(0 AS BIGINT)
|
||||
END AS "slotCount",
|
||||
(SELECT tn FROM slot WHERE satellite_id = :satelliteId ORDER BY tn ASC LIMIT 1) AS "minTime",
|
||||
(SELECT tk FROM slot WHERE satellite_id = :satelliteId ORDER BY tk DESC LIMIT 1) AS "maxTime",
|
||||
(SELECT revolution FROM slot WHERE satellite_id = :satelliteId ORDER BY revolution ASC LIMIT 1) AS "minRevolution",
|
||||
(SELECT revolution FROM slot WHERE satellite_id = :satelliteId ORDER BY revolution DESC LIMIT 1) AS "maxRevolution"
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun slotCalculationSummary(@Param("satelliteId") satelliteId: Long): SlotCalculationSummaryProjection
|
||||
|
||||
@Query(value = "SELECT * FROM slot WHERE ST_Intersects( ST_GeomFromText(:polygon, 4326), contour_geom)", nativeQuery = true)
|
||||
fun findAllByWKT(@Param("polygon") polygonWkt: String): List<SlotEntity>
|
||||
|
||||
@Query(value = "SELECT * FROM slot WHERE satellite_id=:id AND ST_Intersects( ST_GeomFromText(:polygon, 4326), contour_geom)", nativeQuery = true)
|
||||
fun findByWKTAndSatId(@Param("polygon") polygonWkt: String, @Param("id") id : Long): List<SlotEntity>
|
||||
|
||||
@Query(
|
||||
value = "SELECT * FROM slot WHERE satellite_id IN (:ids) AND ST_Intersects(ST_GeomFromText(:polygon, 4326), contour_geom)",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findByWKTAndSatelliteIdIn(@Param("polygon") polygonWkt: String, @Param("ids") ids: Collection<Long>): List<SlotEntity>
|
||||
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package space.nstart.pcp.slots_service.repository
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class BatchCoverageSearchRequest(
|
||||
val targetId: Long,
|
||||
val satelliteId: Long,
|
||||
val windowStart: LocalDateTime,
|
||||
val windowStop: LocalDateTime,
|
||||
val polygonWkt: String
|
||||
)
|
||||
|
||||
data class BatchCoverageSlotRow(
|
||||
val targetId: Long,
|
||||
val slotId: Long,
|
||||
val slotNum: Long,
|
||||
val cycle: Long,
|
||||
val satelliteId: Long,
|
||||
val coveringType: Int,
|
||||
val tn: java.time.LocalDateTime,
|
||||
val tk: java.time.LocalDateTime,
|
||||
val roll: Double,
|
||||
val contour: String,
|
||||
val revolution: Long,
|
||||
val revolutionSign: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double
|
||||
)
|
||||
|
||||
interface SlotRepositoryCustom {
|
||||
fun findTargetSlotRows(
|
||||
searchRequests: List<BatchCoverageSearchRequest>,
|
||||
chunkSize: Int
|
||||
): List<BatchCoverageSlotRow>
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package space.nstart.pcp.slots_service.repository
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.sql.Timestamp
|
||||
|
||||
@Repository
|
||||
class SlotRepositoryImpl(
|
||||
private val jdbcTemplate: JdbcTemplate
|
||||
) : SlotRepositoryCustom {
|
||||
|
||||
override fun findTargetSlotRows(
|
||||
searchRequests: List<BatchCoverageSearchRequest>,
|
||||
chunkSize: Int
|
||||
): List<BatchCoverageSlotRow> {
|
||||
if (searchRequests.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val effectiveChunkSize = chunkSize.coerceAtLeast(1)
|
||||
|
||||
return searchRequests
|
||||
.distinct()
|
||||
.chunked(effectiveChunkSize)
|
||||
.flatMap { chunk ->
|
||||
jdbcTemplate.query(
|
||||
buildBatchSpatialFetchSql(chunk.size),
|
||||
{ rs, _ ->
|
||||
BatchCoverageSlotRow(
|
||||
targetId = rs.getLong("target_id"),
|
||||
slotId = rs.getLong("slot_id"),
|
||||
slotNum = rs.getLong("slot_num"),
|
||||
cycle = rs.getLong("cycle"),
|
||||
satelliteId = rs.getLong("satellite_id"),
|
||||
coveringType = rs.getInt("covering_type"),
|
||||
tn = rs.getTimestamp("tn").toLocalDateTime(),
|
||||
tk = rs.getTimestamp("tk").toLocalDateTime(),
|
||||
roll = rs.getDouble("roll"),
|
||||
contour = rs.getString("contour_wkt"),
|
||||
revolution = rs.getLong("revolution"),
|
||||
revolutionSign = rs.getString("revolution_sign"),
|
||||
latitude = rs.getDouble("latitude"),
|
||||
longitude = rs.getDouble("longitude")
|
||||
)
|
||||
},
|
||||
*buildBatchSpatialFetchParams(chunk)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildBatchSpatialFetchSql(searchRequestCount: Int): String {
|
||||
val valuesSql = List(searchRequestCount) {
|
||||
"(?::bigint, ?::bigint, ?::timestamp, ?::timestamp, ?::text)"
|
||||
}.joinToString(", ")
|
||||
|
||||
return """
|
||||
with search_input(target_id, satellite_id, window_start, window_stop, polygon_wkt) as (
|
||||
values $valuesSql
|
||||
),
|
||||
search as (
|
||||
select
|
||||
target_id,
|
||||
satellite_id,
|
||||
window_start,
|
||||
window_stop,
|
||||
st_geomfromtext(polygon_wkt, 4326) as search_geom
|
||||
from search_input
|
||||
)
|
||||
select distinct
|
||||
search.target_id,
|
||||
slot.slot_id,
|
||||
slot.slot_num,
|
||||
slot.cycle,
|
||||
slot.satellite_id,
|
||||
slot.covering_type,
|
||||
slot.tn,
|
||||
slot.tk,
|
||||
slot.roll,
|
||||
slot.contour_wkt,
|
||||
slot.revolution,
|
||||
slot.revolution_sign,
|
||||
slot.latitude,
|
||||
slot.longitude
|
||||
from search
|
||||
join slot on slot.satellite_id = search.satellite_id
|
||||
and slot.tn >= search.window_start
|
||||
and slot.tk <= search.window_stop
|
||||
and slot.contour_geom && search.search_geom
|
||||
and st_intersects(search.search_geom, slot.contour_geom)
|
||||
order by search.target_id, slot.satellite_id, slot.slot_id
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun buildBatchSpatialFetchParams(
|
||||
chunk: List<BatchCoverageSearchRequest>
|
||||
): Array<Any> {
|
||||
val params = ArrayList<Any>(chunk.size * 5)
|
||||
chunk.forEach { request ->
|
||||
params += request.targetId
|
||||
params += request.satelliteId
|
||||
params += Timestamp.valueOf(request.windowStart)
|
||||
params += Timestamp.valueOf(request.windowStop)
|
||||
params += request.polygonWkt
|
||||
}
|
||||
return params.toTypedArray()
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
|
||||
@Service
|
||||
class BookedSlotStatusService(
|
||||
private val bookedSlotsRepository: BookedSlotsRepository
|
||||
) : BookedSlotStatusUpdater {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Transactional
|
||||
override fun updateBookedSlotsStatus(
|
||||
bookedSlotIds: List<Long>,
|
||||
targetStatus: BookedSlotStatus,
|
||||
context: String
|
||||
): Int {
|
||||
val ids = bookedSlotIds.distinct().filter { it > 0 }
|
||||
if (ids.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val bookedSlots = bookedSlotsRepository.findAllByBookedSlotIdIn(ids)
|
||||
val foundIds = bookedSlots.mapNotNull { it.bookedSlotId }.toSet()
|
||||
val missingIds = ids.filterNot(foundIds::contains)
|
||||
if (missingIds.isNotEmpty()) {
|
||||
logger.warn(
|
||||
"Booked slots status update skipped missing ids: context={}, status={}, missingIds={}",
|
||||
context,
|
||||
targetStatus,
|
||||
missingIds
|
||||
)
|
||||
}
|
||||
|
||||
val changed = bookedSlots.filter { bookedSlot ->
|
||||
val currentStatus = bookedSlot.currentBookedSlotStatus() ?: return@filter false
|
||||
when {
|
||||
currentStatus == targetStatus -> false
|
||||
isAllowedTransition(currentStatus, targetStatus) -> {
|
||||
bookedSlot.status = targetStatus.name
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logger.warn(
|
||||
"Ignoring invalid booked slot transition: id={}, current={}, target={}, context={}",
|
||||
bookedSlot.bookedSlotId,
|
||||
currentStatus,
|
||||
targetStatus,
|
||||
context
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed.isNotEmpty()) {
|
||||
bookedSlotsRepository.saveAll(changed)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Booked slots status update applied: context={}, targetStatus={}, requested={}, updated={}",
|
||||
context,
|
||||
targetStatus,
|
||||
ids.size,
|
||||
changed.size
|
||||
)
|
||||
return changed.size
|
||||
}
|
||||
|
||||
private fun BookedSlotEntity.currentBookedSlotStatus(): BookedSlotStatus? =
|
||||
when (status) {
|
||||
"CANCELLED" -> BookedSlotStatus.CANCELED
|
||||
"CRASHED" -> BookedSlotStatus.FAILED
|
||||
"COMPLETED" -> BookedSlotStatus.FINISHED
|
||||
else -> runCatching { BookedSlotStatus.valueOf(status) }
|
||||
.onFailure {
|
||||
logger.warn("Ignoring booked slot {} with unsupported current status {}", bookedSlotId, status)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private fun isAllowedTransition(current: BookedSlotStatus, target: BookedSlotStatus): Boolean = when (current) {
|
||||
BookedSlotStatus.BOOKED -> target == BookedSlotStatus.PROCESSED || target == BookedSlotStatus.FAILED_TIMEOUT
|
||||
BookedSlotStatus.PROCESSED -> target == BookedSlotStatus.FINISHED
|
||||
|| target == BookedSlotStatus.CANCELED
|
||||
|| target == BookedSlotStatus.FAILED
|
||||
|| target == BookedSlotStatus.FAILED_TIMEOUT
|
||||
BookedSlotStatus.FINISHED,
|
||||
BookedSlotStatus.CANCELED,
|
||||
BookedSlotStatus.FAILED,
|
||||
BookedSlotStatus.FAILED_TIMEOUT -> false
|
||||
}
|
||||
}
|
||||
|
||||
interface BookedSlotStatusUpdater {
|
||||
fun updateBookedSlotsStatus(
|
||||
bookedSlotIds: List<Long>,
|
||||
targetStatus: BookedSlotStatus,
|
||||
context: String
|
||||
): Int
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface BookedSlotTimeResolver {
|
||||
fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime?
|
||||
}
|
||||
|
||||
@Service
|
||||
class SatelliteBookedSlotTimeResolver(
|
||||
private val slotService: SlotService
|
||||
) : BookedSlotTimeResolver {
|
||||
override fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? =
|
||||
slotService.resolveBookedSlotStartTime(bookedSlot)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
import space.nstart.pcp.slots_service.configuration.BookedSlotsTimeoutProperties
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import java.time.Clock
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Component
|
||||
class BookedSlotTimeoutScheduler(
|
||||
private val bookedSlotsRepository: BookedSlotsRepository,
|
||||
private val bookedSlotStatusService: BookedSlotStatusUpdater,
|
||||
private val bookedSlotTimeResolver: BookedSlotTimeResolver,
|
||||
private val properties: BookedSlotsTimeoutProperties,
|
||||
private val clock: Clock = Clock.systemDefaultZone()
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Scheduled(fixedDelayString = "#{@bookedSlotsTimeoutProperties.timeoutCheckInterval.toMillis()}")
|
||||
fun markTimedOutBookedSlots() {
|
||||
val thresholdTime = LocalDateTime.now(clock).minus(properties.timeoutThreshold)
|
||||
val staleBookedSlotIds = bookedSlotsRepository.findAllByStatus(BookedSlotStatus.BOOKED.name)
|
||||
.filter { bookedSlot ->
|
||||
val actualStart = bookedSlotTimeResolver.resolveBookedSlotStartTime(bookedSlot) ?: return@filter false
|
||||
actualStart.isBefore(thresholdTime)
|
||||
}
|
||||
.mapNotNull { it.bookedSlotId }
|
||||
|
||||
if (staleBookedSlotIds.isEmpty()) {
|
||||
logger.debug("Booked slot timeout scheduler found no stale slots")
|
||||
return
|
||||
}
|
||||
|
||||
val updated = bookedSlotStatusService.updateBookedSlotsStatus(
|
||||
bookedSlotIds = staleBookedSlotIds,
|
||||
targetStatus = BookedSlotStatus.FAILED_TIMEOUT,
|
||||
context = "timeout-scheduler"
|
||||
)
|
||||
logger.info(
|
||||
"Booked slot timeout scheduler marked stale slots: found={}, updated={}, thresholdTime={}",
|
||||
staleBookedSlotIds.size,
|
||||
updated,
|
||||
thresholdTime
|
||||
)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotsStatusChangedEvent
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
|
||||
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||
@Component
|
||||
class BookedSlotsKafkaListener(
|
||||
objectMapperProvider: ObjectProvider<ObjectMapper>,
|
||||
private val bookedSlotStatusService: BookedSlotStatusUpdater
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
|
||||
|
||||
@KafkaListener(
|
||||
topics = ["\${app.kafka.topics.booked-slots}"],
|
||||
groupId = "\${spring.kafka.consumer.group-id}-booked-slots"
|
||||
)
|
||||
fun consume(message: String) {
|
||||
try {
|
||||
val payload = objectMapper.readTree(message)
|
||||
val eventType = payload.path("type").asText()
|
||||
if (eventType != PcpKafkaEvent.BookedSlotsStatusChangedEvent.name) {
|
||||
logger.debug("Ignoring Kafka message with unsupported type {}", eventType)
|
||||
return
|
||||
}
|
||||
|
||||
val event = readEvent(payload)
|
||||
bookedSlotStatusService.updateBookedSlotsStatus(
|
||||
bookedSlotIds = event.bookedSlotIds,
|
||||
targetStatus = event.status,
|
||||
context = "kafka:${event.sourceService}:${event.missionId ?: "-"}:${event.modeId ?: "-"}"
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
logger.error("Failed to process booked slots Kafka event", exception)
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
private fun readEvent(payload: JsonNode): BookedSlotsStatusChangedEvent =
|
||||
objectMapper.treeToValue(payload.path("data"), BookedSlotsStatusChangedEvent::class.java)
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.core.publisher.Flux
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
@Service
|
||||
/**
|
||||
* Request/grid reader backed by pcp-request-service.
|
||||
*/
|
||||
class EarthService(
|
||||
webClientBuilderProvider: ObjectProvider<WebClient.Builder>,
|
||||
@param:Value("\${settings.request-service:\${settings.earth-grid-service:pcp-request-service}}")
|
||||
private val url: String = "pcp-request-service",
|
||||
) {
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
|
||||
fun cells(importance: Double): Iterable<EarthCellDTO> {
|
||||
val firstPage = cellsPage(importance, page = 0)
|
||||
val items = firstPage.items.toMutableList()
|
||||
|
||||
for (page in 1 until firstPage.totalPages) {
|
||||
items += cellsPage(importance, page).items
|
||||
}
|
||||
|
||||
return items.map { cell -> cell.toEarthCellDto() }
|
||||
}
|
||||
|
||||
private fun cellsPage(importance: Double, page: Int): CellsListResponseDto =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri(cellsUri(importance, page))
|
||||
.retrieve()
|
||||
.bodyToMono(CellsListResponseDto::class.java)
|
||||
.block() ?: CellsListResponseDto()
|
||||
|
||||
private fun cellsUri(importance: Double, page: Int): String =
|
||||
// TODO: restore countLat/countLong forwarding if pcp-request-service adds aggregation to GET /v1/cells.
|
||||
"$url/v1/cells?minImportance=$importance&page=$page&size=$CELLS_PAGE_SIZE"
|
||||
|
||||
fun reqcells(id : String): RequestWithCellsDTO? =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/v1/requests/$id/with-cells")
|
||||
.retrieve()
|
||||
.bodyToMono(RequestWithCellsResponseDto::class.java)
|
||||
.block()
|
||||
?.toRequestWithCellsDto()
|
||||
|
||||
fun reqs(): Flux<RequestDTO> {
|
||||
val firstPage = requestsPage(page = 0)
|
||||
val summaries = firstPage.items.toMutableList()
|
||||
|
||||
for (page in 1 until firstPage.totalPages) {
|
||||
summaries += requestsPage(page).items
|
||||
}
|
||||
|
||||
val requests = summaries.map { summary -> requestDetails(summary.id).toRequestDto() }
|
||||
return Flux.fromIterable(requests)
|
||||
}
|
||||
|
||||
private fun requestsPage(page: Int): RequestsListResponseDto =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/v1/requests?page=$page&size=$REQUESTS_PAGE_SIZE")
|
||||
.retrieve()
|
||||
.bodyToMono(RequestsListResponseDto::class.java)
|
||||
.block() ?: RequestsListResponseDto()
|
||||
|
||||
private fun requestDetails(id: UUID): RequestDetailsResponseDto =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/v1/requests/$id")
|
||||
.retrieve()
|
||||
.bodyToMono(RequestDetailsResponseDto::class.java)
|
||||
.block() ?: RequestDetailsResponseDto(id = id)
|
||||
|
||||
private fun CellSummaryResponseDto.toEarthCellDto(): EarthCellDTO =
|
||||
EarthCellDTO(
|
||||
id = cellNum,
|
||||
num = cellNum,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
importance = importance,
|
||||
contour = contour,
|
||||
)
|
||||
|
||||
private fun RequestDetailsResponseDto.toRequestDto(): RequestDTO =
|
||||
RequestDTO(
|
||||
requestId = id,
|
||||
name = name,
|
||||
importance = importance,
|
||||
contour = geometry,
|
||||
)
|
||||
|
||||
private fun RequestWithCellsResponseDto.toRequestWithCellsDto(): RequestWithCellsDTO =
|
||||
RequestWithCellsDTO(
|
||||
request = request.toRequestDto(),
|
||||
cells = cells.map { cell -> cell.toEarthCellDto() },
|
||||
)
|
||||
|
||||
private fun RequestResponseDto.toRequestDto(): RequestDTO =
|
||||
RequestDTO(
|
||||
requestId = id,
|
||||
name = name,
|
||||
importance = importance,
|
||||
contour = geometry,
|
||||
)
|
||||
|
||||
private fun RequestCellResponseDto.toEarthCellDto(): EarthCellDTO =
|
||||
EarthCellDTO(
|
||||
id = cellNum,
|
||||
num = cellNum,
|
||||
importance = importance,
|
||||
contour = contour,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val CELLS_PAGE_SIZE = 500
|
||||
const val REQUESTS_PAGE_SIZE = 500
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class RequestsListResponseDto(
|
||||
val items: List<RequestSummaryResponseDto> = emptyList(),
|
||||
val page: Int = 0,
|
||||
val size: Int = 0,
|
||||
val totalItems: Long = 0,
|
||||
val totalPages: Int = 0,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class RequestSummaryResponseDto(
|
||||
val id: UUID,
|
||||
val name: String = "",
|
||||
val status: String = "",
|
||||
val surveyType: String = "",
|
||||
val importance: Double = 0.0,
|
||||
val beginDateTime: String = "",
|
||||
val endDateTime: String = "",
|
||||
val kpp: List<Int> = emptyList(),
|
||||
val highPriorityTransmit: Boolean = false,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class RequestDetailsResponseDto(
|
||||
val id: UUID,
|
||||
val name: String = "",
|
||||
val geometry: String = "",
|
||||
val importance: Double = 0.0,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class RequestWithCellsResponseDto(
|
||||
val request: RequestResponseDto = RequestResponseDto(),
|
||||
val cells: List<RequestCellResponseDto> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class RequestResponseDto(
|
||||
val id: UUID = UUID(0L, 0L),
|
||||
val name: String = "",
|
||||
val status: String = "",
|
||||
val surveyType: String = "",
|
||||
val geometry: String = "",
|
||||
val importance: Double = 0.0,
|
||||
val beginDateTime: String = "",
|
||||
val endDateTime: String = "",
|
||||
val kpp: List<Int> = emptyList(),
|
||||
val highPriorityTransmit: Boolean = false,
|
||||
val optics: Any? = null,
|
||||
val rsa: Any? = null,
|
||||
val coverage: CoverageStateDto = CoverageStateDto(),
|
||||
val createdAt: String = "",
|
||||
val updatedAt: String = "",
|
||||
val deletedAt: String? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class RequestCellResponseDto(
|
||||
val cellNum: Long = 0,
|
||||
val coveragePercent: Double = 0.0,
|
||||
val importance: Double = 0.0,
|
||||
val contour: String = "",
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CoverageStateDto(
|
||||
val requiredPercent: Double? = null,
|
||||
val currentPercent: Double = 0.0,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CellsListResponseDto(
|
||||
val items: List<CellSummaryResponseDto> = emptyList(),
|
||||
val page: Int = 0,
|
||||
val size: Int = 0,
|
||||
val totalItems: Long = 0,
|
||||
val totalPages: Int = 0,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CellSummaryResponseDto(
|
||||
val cellNum: Long = 0,
|
||||
val latitude: Double = 0.0,
|
||||
val longitude: Double = 0.0,
|
||||
val importance: Double = 0.0,
|
||||
val contour: String = "",
|
||||
)
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import java.time.Duration
|
||||
import kotlin.math.abs
|
||||
|
||||
internal object OccupiedIntervalPrefilter {
|
||||
|
||||
private const val ROLL_EPSILON = 0.01
|
||||
|
||||
fun normalizeBySatellite(
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>,
|
||||
satellitesById: Map<Long, AbstractSatellite>
|
||||
): Map<Long, List<OccupiedIntervalDTO>> =
|
||||
occupiedIntervals
|
||||
.groupBy { it.satelliteId }
|
||||
.mapValues { (satelliteId, intervals) ->
|
||||
val satellite = satellitesById[satelliteId] ?: return@mapValues emptyList()
|
||||
normalize(intervals, satellite)
|
||||
}
|
||||
.filterValues { it.isNotEmpty() }
|
||||
|
||||
fun normalize(
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>,
|
||||
satellite: AbstractSatellite
|
||||
): List<OccupiedIntervalDTO> {
|
||||
if (occupiedIntervals.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val normalized = mutableListOf<OccupiedIntervalDTO>()
|
||||
occupiedIntervals
|
||||
.sortedWith(compareBy<OccupiedIntervalDTO> { it.roll }.thenBy { it.startTime }.thenBy { it.endTime })
|
||||
.forEach { interval ->
|
||||
val current = normalized.lastOrNull()
|
||||
if (current != null &&
|
||||
sameRoll(current.roll, interval.roll) &&
|
||||
interval.startTime <= current.endTime.plusSeconds(satellite.mmi)
|
||||
) {
|
||||
normalized[normalized.lastIndex] = current.copy(
|
||||
startTime = minOf(current.startTime, interval.startTime),
|
||||
endTime = maxOf(current.endTime, interval.endTime),
|
||||
source = current.source ?: interval.source,
|
||||
modeId = current.modeId ?: interval.modeId
|
||||
)
|
||||
} else {
|
||||
normalized += interval
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun filterCandidates(
|
||||
candidateSlots: List<SlotDTO>,
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>,
|
||||
satellite: AbstractSatellite
|
||||
): List<SlotDTO> {
|
||||
if (candidateSlots.isEmpty() || occupiedIntervals.isEmpty()) {
|
||||
return candidateSlots
|
||||
}
|
||||
|
||||
return candidateSlots.filter { candidate ->
|
||||
isAllowed(candidate, occupiedIntervals, satellite)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAllowed(
|
||||
candidate: SlotDTO,
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>,
|
||||
satellite: AbstractSatellite
|
||||
): Boolean {
|
||||
val conflictingDifferentRoll = occupiedIntervals.any { occupied ->
|
||||
!sameRoll(candidate.roll, occupied.roll) &&
|
||||
intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi)
|
||||
}
|
||||
if (conflictingDifferentRoll) {
|
||||
return false
|
||||
}
|
||||
|
||||
val sameRollChains = occupiedIntervals.filter { occupied ->
|
||||
sameRoll(candidate.roll, occupied.roll) &&
|
||||
intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi)
|
||||
}
|
||||
if (sameRollChains.isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
val mergedStart = sameRollChains.minOf { it.startTime }.coerceAtMost(candidate.tn)
|
||||
val mergedEnd = sameRollChains.maxOf { it.endTime }.coerceAtLeast(candidate.tk)
|
||||
val mergedDurationSeconds = Duration.between(mergedStart, mergedEnd).seconds
|
||||
return mergedDurationSeconds <= satellite.maxSurveyDuration.toLong()
|
||||
}
|
||||
|
||||
private fun intersectsWithMmi(
|
||||
start1: java.time.LocalDateTime,
|
||||
end1: java.time.LocalDateTime,
|
||||
start2: java.time.LocalDateTime,
|
||||
end2: java.time.LocalDateTime,
|
||||
mmi: Long
|
||||
): Boolean = start1 <= end2.plusSeconds(mmi) && end1 >= start2.minusSeconds(mmi)
|
||||
|
||||
private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatusCode
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.ClientResponse
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.core.publisher.Mono
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteBatchRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.slots_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
|
||||
interface SatelliteCatalogClient {
|
||||
fun allSatellites(): List<AbstractSatellite>
|
||||
fun satellites(ids: List<Long>): List<AbstractSatellite>
|
||||
fun satellite(id: Long): AbstractSatellite
|
||||
fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO
|
||||
}
|
||||
|
||||
@Service
|
||||
class SatelliteCatalogWebClient(
|
||||
webClientBuilderProvider: ObjectProvider<WebClient.Builder>
|
||||
) : SatelliteCatalogClient {
|
||||
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
private val objectMapper = ObjectMapper().findAndRegisterModules()
|
||||
|
||||
@Value("\${settings.satellite-catalog-service:pcp-satellite-catalog-service}")
|
||||
private val satelliteCatalogServiceUrl = ""
|
||||
|
||||
override fun allSatellites(): List<AbstractSatellite> {
|
||||
val summaries = webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$satelliteCatalogServiceUrl/api/satellites")
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, ::mapError)
|
||||
.bodyToFlux(SatelliteSummaryDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
if (summaries.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
return fetchSatellites(summaries.map { it.id })
|
||||
}
|
||||
|
||||
override fun satellites(ids: List<Long>): List<AbstractSatellite> {
|
||||
val uniqueIds = ids.distinct()
|
||||
if (uniqueIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val byId = fetchSatellites(uniqueIds).associateBy { it.id }
|
||||
return uniqueIds.map { satelliteId ->
|
||||
byId[satelliteId] ?: throw CustomValidationException("КА $satelliteId не зарегистрирован")
|
||||
}
|
||||
}
|
||||
|
||||
override fun satellite(id: Long): AbstractSatellite =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$satelliteCatalogServiceUrl/api/satellites/$id")
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, ::mapError)
|
||||
.bodyToMono(SatelliteDTO::class.java)
|
||||
.map { it.toAbstractSatellite() }
|
||||
.block()
|
||||
?: throw CustomErrorException("Не удалось получить параметры КА $id: пустой ответ")
|
||||
|
||||
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO =
|
||||
webClientBuilder.build()
|
||||
.put()
|
||||
.uri("$satelliteCatalogServiceUrl/api/satellites/$id/slot-profile")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, ::mapError)
|
||||
.bodyToMono(SatelliteSlotProfileDTO::class.java)
|
||||
.block()
|
||||
?: throw CustomErrorException("Не удалось обновить slot-profile КА $id: пустой ответ")
|
||||
|
||||
private fun fetchSatellites(ids: List<Long>): List<AbstractSatellite> =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$satelliteCatalogServiceUrl/api/satellites/batch")
|
||||
.bodyValue(SatelliteBatchRequestDTO(ids = ids))
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, ::mapError)
|
||||
.bodyToFlux(SatelliteDTO::class.java)
|
||||
.map { it.toAbstractSatellite() }
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
private fun SatelliteDTO.toAbstractSatellite(): AbstractSatellite {
|
||||
val observationProfileValue = observationProfile
|
||||
?: throw CustomErrorException("Для КА $id в каталоге не заполнен observation-profile")
|
||||
val slotProfileValue = slotProfile
|
||||
?: throw CustomErrorException("Для КА $id в каталоге не заполнен slot-profile")
|
||||
val tnCalcValue = slotProfileValue.tnCalc
|
||||
?: throw CustomErrorException("Для КА $id в каталоге не заполнен slot-profile.tnCalc")
|
||||
|
||||
return TestSatelliteImpl(
|
||||
id = id,
|
||||
cycleRevs = slotProfileValue.cycleRevs,
|
||||
tnCalc = tnCalcValue,
|
||||
durationCalc = slotProfileValue.durationCalcDays,
|
||||
slotDuration = slotProfileValue.slotDuration,
|
||||
maxChainLength = slotProfileValue.maxChainLength,
|
||||
maxSurveyDuration = observationProfileValue.durationMaxSeconds.toInt(),
|
||||
mmi = observationProfileValue.mmiSeconds,
|
||||
angles = slotProfileValue.defaultAngles.flattenAngles()
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SatelliteSlotAngleDTO>.flattenAngles(): List<Double> =
|
||||
flatMap { listOf(it.angleBegin, it.angleEnd) }
|
||||
|
||||
private fun mapError(response: ClientResponse): Mono<Throwable> =
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty(response.statusCode().toString())
|
||||
.flatMap { body ->
|
||||
val message = extractErrorMessage(body)
|
||||
val exception = if (response.statusCode().is4xxClientError) {
|
||||
CustomValidationException(message)
|
||||
} else {
|
||||
CustomErrorException(message)
|
||||
}
|
||||
Mono.error(exception)
|
||||
}
|
||||
|
||||
private fun extractErrorMessage(body: String): String {
|
||||
val text = body.trim()
|
||||
if (text.isEmpty()) {
|
||||
return "Ошибка обращения к каталогу спутников"
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val node = objectMapper.readTree(text)
|
||||
when {
|
||||
node.hasNonNull("error") -> node["error"].asText()
|
||||
node.hasNonNull("message") -> node["message"].asText()
|
||||
else -> text
|
||||
}
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO
|
||||
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
|
||||
private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-slots-service-satellite-deleted"
|
||||
|
||||
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||
@Component
|
||||
class SatelliteDeletedKafkaListener(
|
||||
objectMapperProvider: ObjectProvider<ObjectMapper>,
|
||||
private val satelliteDeletedService: SatelliteDeletedService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
|
||||
|
||||
@KafkaListener(
|
||||
id = "satelliteDeletedFilter",
|
||||
topics = ["\${app.kafka.topics.satellites:pcp.satellites}"],
|
||||
groupId = SATELLITE_DELETED_CONSUMER_GROUP,
|
||||
filter = "satelliteDeletedFilter"
|
||||
)
|
||||
fun consume(message: String) {
|
||||
try {
|
||||
val kafkaMessage = objectMapper.readValue(
|
||||
message,
|
||||
object : TypeReference<KafkaMessage<SatelliteDeletedEventDTO>>() {}
|
||||
)
|
||||
if (kafkaMessage.type != PcpKafkaEvent.SatelliteDeletedEvent) {
|
||||
logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type)
|
||||
return
|
||||
}
|
||||
|
||||
val event = kafkaMessage.data
|
||||
logger.info(
|
||||
"Received SatelliteDeletedEvent in slots-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}",
|
||||
event.satelliteId,
|
||||
event.noradId,
|
||||
kafkaMessage.id,
|
||||
kafkaMessage.traceId,
|
||||
kafkaMessage.source,
|
||||
SATELLITE_DELETED_CONSUMER_GROUP
|
||||
)
|
||||
event.deleteIdentifiers().forEach { satelliteId ->
|
||||
satelliteDeletedService.deleteSatelliteData(satelliteId)
|
||||
}
|
||||
logger.info(
|
||||
"SatelliteDeletedEvent processed in slots-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}",
|
||||
event.satelliteId,
|
||||
event.noradId,
|
||||
kafkaMessage.id,
|
||||
kafkaMessage.traceId,
|
||||
kafkaMessage.source,
|
||||
SATELLITE_DELETED_CONSUMER_GROUP
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
logger.error("Failed to process SatelliteDeletedEvent in slots-service", exception)
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List<Long> =
|
||||
listOfNotNull(satelliteId, noradId).distinct()
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import space.nstart.pcp.slots_service.repository.SatelliteIcRepository
|
||||
import space.nstart.pcp.slots_service.repository.SlotRepository
|
||||
|
||||
@Service
|
||||
class SatelliteDeletedService(
|
||||
private val slotRepository: SlotRepository,
|
||||
private val bookedSlotsRepository: BookedSlotsRepository,
|
||||
private val satelliteIcRepository: SatelliteIcRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Transactional
|
||||
fun deleteSatelliteData(satelliteId: Long): SatelliteSlotsDeletionSummary {
|
||||
val bookedSlotsDeletedByCascade = bookedSlotsRepository.countBySlot_SatelliteId(satelliteId)
|
||||
val slotsBeforeDelete = slotRepository.countBySatelliteId(satelliteId)
|
||||
val slotsDeleted = slotRepository.deleteBySatId(satelliteId)
|
||||
val initialConditionsDeleted = satelliteIcRepository.deleteBySatelliteId(satelliteId)
|
||||
val summary = SatelliteSlotsDeletionSummary(
|
||||
satelliteId = satelliteId,
|
||||
slotsDeleted = slotsDeleted,
|
||||
bookedSlotsDeletedByCascade = bookedSlotsDeletedByCascade,
|
||||
initialConditionsDeleted = initialConditionsDeleted
|
||||
)
|
||||
logger.info(
|
||||
"Deleted slots data for satellite: satelliteId={}, slotsDeleted={}, slotsBeforeDelete={}, bookedSlotsDeletedByCascade={}, initialConditionsDeleted={}",
|
||||
summary.satelliteId,
|
||||
summary.slotsDeleted,
|
||||
slotsBeforeDelete,
|
||||
summary.bookedSlotsDeletedByCascade,
|
||||
summary.initialConditionsDeleted
|
||||
)
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
data class SatelliteSlotsDeletionSummary(
|
||||
val satelliteId: Long,
|
||||
val slotsDeleted: Int,
|
||||
val bookedSlotsDeletedByCascade: Long,
|
||||
val initialConditionsDeleted: Int
|
||||
)
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.apache.kafka.clients.producer.ProducerRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Component
|
||||
class SatelliteIcKafkaPublisher(
|
||||
kafkaTemplateProvider: ObjectProvider<KafkaTemplate<String, Any>>,
|
||||
objectMapperProvider: ObjectProvider<ObjectMapper>,
|
||||
@param:Value("\${spring.kafka.template.default-topic:}") private val topic: String,
|
||||
@param:Value("\${spring.application.name:slots-service}") private val applicationName: String
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val kafkaTemplate = kafkaTemplateProvider.ifAvailable
|
||||
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
|
||||
|
||||
fun publish(messagePayload: SatelliteICDTO) {
|
||||
val template = kafkaTemplate ?: throw CustomErrorException("Kafka publisher for satellite IC is unavailable")
|
||||
if (topic.isBlank()) {
|
||||
throw CustomErrorException("Kafka topic for satellite IC publishing is not configured")
|
||||
}
|
||||
|
||||
val message = KafkaMessage(
|
||||
type = PcpKafkaEvent.ICRVPlacedEvent,
|
||||
data = messagePayload
|
||||
).apply {
|
||||
source = applicationName
|
||||
}
|
||||
|
||||
val payload = try {
|
||||
objectMapper.writeValueAsString(message)
|
||||
} catch (exception: JsonProcessingException) {
|
||||
throw CustomErrorException("Ошибка сериализации начальных условий КА ${messagePayload.satelliteId}: ${exception.message}")
|
||||
}
|
||||
|
||||
val record = ProducerRecord<String, Any>(topic, messagePayload.satelliteId.toString(), payload)
|
||||
record.headers().add(TYPE_HEADER, PcpKafkaEvent.ICRVPlacedEvent.name.toByteArray(StandardCharsets.UTF_8))
|
||||
template.send(record)
|
||||
logger.info("Published satellite initial conditions event: satelliteId={}, topic={}, eventType={}", messagePayload.satelliteId, topic, PcpKafkaEvent.ICRVPlacedEvent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_HEADER = "type"
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import ballistics.types.InitialConditions
|
||||
import ballistics.types.OrbitalPoint
|
||||
import ballistics.utils.fromDateTime
|
||||
import ballistics.utils.math.Vector3D
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.entity.SatelliteIcEntity
|
||||
import space.nstart.pcp.slots_service.repository.SatelliteIcRepository
|
||||
|
||||
@Service
|
||||
class SatelliteIcService(
|
||||
private val satelliteIcRepository: SatelliteIcRepository,
|
||||
private val satelliteCatalogClient: SatelliteCatalogClient
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun all(): List<SatelliteICDTO> =
|
||||
satelliteIcRepository.findAllByOrderBySatelliteIdAsc().map { it.toDto() }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun bySatelliteId(satelliteId: Long): SatelliteICDTO =
|
||||
requireSatelliteIc(satelliteId).toDto()
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun initialConditions(satelliteId: Long): InitialConditions =
|
||||
requireSatelliteIc(satelliteId).toModel()
|
||||
|
||||
@Transactional
|
||||
fun create(request: SatelliteICDTO): SatelliteICDTO {
|
||||
requireSatelliteExists(request.satelliteId)
|
||||
if (satelliteIcRepository.existsById(request.satelliteId)) {
|
||||
throw CustomValidationException("Начальные условия КА ${request.satelliteId} уже зарегистрированы")
|
||||
}
|
||||
return satelliteIcRepository.save(request.toEntity()).toDto()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(satelliteId: Long, request: InitialConditionsDTO): SatelliteICDTO {
|
||||
requireSatelliteExists(satelliteId)
|
||||
if (!satelliteIcRepository.existsById(satelliteId)) {
|
||||
throw CustomValidationException("Начальные условия КА $satelliteId не зарегистрированы")
|
||||
}
|
||||
return satelliteIcRepository.save(request.toEntity(satelliteId)).toDto()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(satelliteId: Long) {
|
||||
if (!satelliteIcRepository.existsById(satelliteId)) {
|
||||
throw CustomValidationException("Начальные условия КА $satelliteId не зарегистрированы")
|
||||
}
|
||||
satelliteIcRepository.deleteById(satelliteId)
|
||||
}
|
||||
|
||||
private fun requireSatelliteIc(satelliteId: Long): SatelliteIcEntity =
|
||||
satelliteIcRepository.findById(satelliteId).orElseThrow {
|
||||
CustomValidationException("Начальные условия КА $satelliteId не зарегистрированы")
|
||||
}
|
||||
|
||||
private fun requireSatelliteExists(satelliteId: Long) {
|
||||
satelliteCatalogClient.satellite(satelliteId)
|
||||
}
|
||||
|
||||
private fun SatelliteIcEntity.toDto(): SatelliteICDTO =
|
||||
SatelliteICDTO(
|
||||
satelliteId = satelliteId,
|
||||
ic = InitialConditionsDTO(
|
||||
orbPoint = OrbPointDTO(
|
||||
time = orbTime,
|
||||
revolution = orbRevolution,
|
||||
vx = vx,
|
||||
vy = vy,
|
||||
vz = vz,
|
||||
x = x,
|
||||
y = y,
|
||||
z = z
|
||||
),
|
||||
sBall = sBall,
|
||||
f81 = f81
|
||||
)
|
||||
)
|
||||
|
||||
private fun SatelliteIcEntity.toModel(): InitialConditions =
|
||||
InitialConditions(
|
||||
point = OrbitalPoint(
|
||||
fromDateTime(orbTime),
|
||||
orbRevolution.toInt(),
|
||||
Vector3D(x, y, z),
|
||||
Vector3D(vx, vy, vz)
|
||||
),
|
||||
sBall,
|
||||
f81
|
||||
)
|
||||
|
||||
private fun SatelliteICDTO.toEntity(): SatelliteIcEntity = ic.toEntity(satelliteId)
|
||||
|
||||
private fun InitialConditionsDTO.toEntity(satelliteId: Long): SatelliteIcEntity =
|
||||
SatelliteIcEntity(
|
||||
satelliteId = satelliteId,
|
||||
orbTime = orbPoint.time,
|
||||
orbRevolution = orbPoint.revolution,
|
||||
x = orbPoint.x,
|
||||
y = orbPoint.y,
|
||||
z = orbPoint.z,
|
||||
vx = orbPoint.vx,
|
||||
vy = orbPoint.vy,
|
||||
vz = orbPoint.vz,
|
||||
sBall = sBall,
|
||||
f81 = f81
|
||||
)
|
||||
}
|
||||
+2497
File diff suppressed because it is too large
Load Diff
+172
@@ -0,0 +1,172 @@
|
||||
package space.nstart.pcp.slots_service.util
|
||||
|
||||
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.LinearRing
|
||||
import org.locationtech.jts.geom.MultiPolygon
|
||||
import org.locationtech.jts.geom.Polygon
|
||||
import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
|
||||
object LongitudeWrapGeometry {
|
||||
|
||||
private val parserThreadLocal = ThreadLocal.withInitial { WKTParser() }
|
||||
private val geometryFactory = GeometryFactory()
|
||||
|
||||
private fun parser(): WKTParser = parserThreadLocal.get()
|
||||
|
||||
fun normalizeToContinuous360Wkt(wkt: String): String =
|
||||
parser().toWKT(normalizeToContinuous360(parser().parseWKT(wkt)))
|
||||
|
||||
fun buildSearchVariantsWkt(wkt: String): List<String> =
|
||||
buildSearchVariants(parser().parseWKT(wkt)).map(parser()::toWKT).distinct()
|
||||
|
||||
fun normalizeToContinuous360(geometry: Geometry): Geometry {
|
||||
val normalized = shiftNegativeLongitudesTo360(geometry.copy())
|
||||
return when (normalized) {
|
||||
is Polygon -> normalizePolygon(normalized)
|
||||
is MultiPolygon -> normalizeMultiPolygon(normalized)
|
||||
else -> normalized
|
||||
}.let { if (it.isValid) it else it.buffer(0.0) }
|
||||
}
|
||||
|
||||
fun buildSearchVariants(geometry: Geometry): List<Geometry> {
|
||||
val normalized = normalizeToContinuous360(geometry)
|
||||
return listOf(
|
||||
shiftLongitude(normalized, -360.0),
|
||||
normalized,
|
||||
shiftLongitude(normalized, 360.0)
|
||||
).distinctBy { parser().toWKT(it) }
|
||||
}
|
||||
|
||||
fun alignToReference(geometry: Geometry, reference: Geometry): Geometry {
|
||||
val normalizedReference = normalizeToContinuous360(reference)
|
||||
val referenceCenter = envelopeCenterX(normalizedReference.envelopeInternal)
|
||||
|
||||
return buildSearchVariants(geometry)
|
||||
.minByOrNull { abs(envelopeCenterX(it.envelopeInternal) - referenceCenter) }
|
||||
?: normalizeToContinuous360(geometry)
|
||||
}
|
||||
|
||||
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 normalizePolygon(polygon: Polygon): Geometry {
|
||||
val shell = unwrapRing(polygon.exteriorRing)
|
||||
val holes = Array(polygon.numInteriorRing) { index ->
|
||||
unwrapRing(polygon.getInteriorRingN(index))
|
||||
}
|
||||
|
||||
return shiftIntoCanonicalBand(repairGeometry(
|
||||
polygon.factory.createPolygon(shell, holes)
|
||||
))
|
||||
}
|
||||
|
||||
private fun normalizeMultiPolygon(multiPolygon: MultiPolygon): Geometry {
|
||||
val parts = mutableListOf<Polygon>()
|
||||
for (index in 0 until multiPolygon.numGeometries) {
|
||||
collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon), parts)
|
||||
}
|
||||
|
||||
return when (parts.size) {
|
||||
0 -> geometryFactory.createPolygon()
|
||||
1 -> parts.first()
|
||||
else -> geometryFactory.createMultiPolygon(parts.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectPolygons(geometry: Geometry, parts: MutableList<Polygon>) {
|
||||
when (geometry) {
|
||||
is Polygon -> parts += geometry
|
||||
is MultiPolygon -> {
|
||||
for (index in 0 until geometry.numGeometries) {
|
||||
parts += geometry.getGeometryN(index) as Polygon
|
||||
}
|
||||
}
|
||||
is GeometryCollection -> {
|
||||
for (index in 0 until geometry.numGeometries) {
|
||||
collectPolygons(geometry.getGeometryN(index), parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun repairGeometry(geometry: Geometry): Geometry =
|
||||
if (geometry.isValid) geometry else geometry.buffer(0.0)
|
||||
|
||||
private fun shiftIntoCanonicalBand(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 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 envelopeCenterX(envelope: Envelope): Double = (envelope.minX + envelope.maxX) / 2.0
|
||||
|
||||
private fun unwrapRing(ring: org.locationtech.jts.geom.LineString): LinearRing {
|
||||
val source = ring.coordinates
|
||||
if (source.isEmpty()) {
|
||||
return ring.factory.createLinearRing()
|
||||
}
|
||||
|
||||
val first = source.first()
|
||||
val unwrapped = ArrayList<Coordinate>(source.size)
|
||||
var previousX = first.x
|
||||
unwrapped.add(Coordinate(previousX, first.y, 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.add(Coordinate(adjustedX, current.y, current.z))
|
||||
previousX = adjustedX
|
||||
}
|
||||
|
||||
val start = unwrapped.first()
|
||||
unwrapped.add(Coordinate(start.x, start.y, start.z))
|
||||
return ring.factory.createLinearRing(unwrapped.toTypedArray())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
spring:
|
||||
application:
|
||||
name: slots-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}
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE IF NOT EXISTS satellite_ic(
|
||||
satellite_id BIGINT PRIMARY KEY,
|
||||
orb_time TIMESTAMP NOT NULL,
|
||||
orb_revolution BIGINT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
z DOUBLE PRECISION NOT NULL,
|
||||
vx DOUBLE PRECISION NOT NULL,
|
||||
vy DOUBLE PRECISION NOT NULL,
|
||||
vz DOUBLE PRECISION NOT NULL,
|
||||
s_ball DOUBLE PRECISION NOT NULL,
|
||||
f81 DOUBLE PRECISION NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO satellite_ic (satellite_id, orb_time, orb_revolution, x, y, z, vx, vy, vz, s_ball,f81) VALUES
|
||||
(1, '2026-03-20 07:20:00.000000000', 1, 6834436.888999999, 597938.4140000014, -0.3770000001939167, 137.3883790000003, 1464.748528, 7562.254627000002, 0.0, 0.0),
|
||||
(2, '2026-03-20 07:20:00.000000000', 1, -3266701.5884873797, 1278725.1552186068, 5884819.247744364, 6697.319996679769, -307.8285855300839, 3775.916576094365, 0.0, 0.0),
|
||||
(3, '2026-03-20 07:20:00.000000000', 1, 3508050.1042085, -23618.976590790862, 5884949.766167523, 6334.124848683056, -2197.290023159083, -3775.6117154309222, 0.0, 0.0),
|
||||
(4, '2026-03-20 07:20:00.000000000', 1, 6569209.44012876, -1978849.514208376, 8.549715463940899, -415.5998592415322, -1411.299509223835, -7561.962819171092, 0.0, 0.0),
|
||||
(5, '2026-03-20 07:20:00.000000000', 1, 2943239.937782717, -1922373.245130921, -5897170.487022758, -6476.784913434777, 1661.054349866645, -3775.66202329109, 0.0, 0.0),
|
||||
(6, '2026-03-20 07:20:00.000000000', 1, -3436251.306747606, 741544.7543712078, -5897104.773002284, -5734.695868980442, 3438.16424470693, 3775.7766970312277, 0.0, 0.0),
|
||||
(7, '2026-03-20 03:00:00.000000000', 1, -6343478.396000003, 2627554.784, -1.77635683940025e-12, 574.4640830000003, 1362.170732, 7558.135535, 0.0588, 125.0),
|
||||
(8, '2026-03-20 03:18:50.782779625', 1, -1500868.337345541, 1713488.271989074, 6465579.621686231, 6712.464186248605, -2938.606060145184, 2331.536180172411, 0.0588, 125.0),
|
||||
(9, '2026-03-20 03:37:40.764922495', 1, 4984345.125556404, -2494614.053184607, 3998726.389771715, 3211.515143045879, -3408.641923514943, -6111.022893681113, 0.0588, 125.0),
|
||||
(10, '2026-03-20 03:56:32.881376251', 1, 4150155.5063736434, -3732737.1762040304, -4004553.1452223673, -4331.971891773795, 1731.421783096582, -6111.015697381638, 0.0588, 125.0),
|
||||
(11, '2026-03-20 04:15:27.865948215', 1, -2148327.9320683316, 773228.4988466053, -6480904.004374029, -5172.91956636886, 5163.260323770023, 2331.412682288123, 0.0588, 125.0),
|
||||
(12, '2026-03-20 04:36:17.199469748', 1, -5577440.566626521, 2884295.721554679, 2768009.21001528, 3415.1909962371587, -47.07995218278599, 6902.524078471197, 0.0588, 125.0),
|
||||
(13, '2026-03-20 04:36:17.199469748', 1, 1049585.1536136891, 422595.4885303282, 6760381.113752117, 6627.151703742502, -3816.4267934255963, -788.7301563455045, 0.0588, 125.0),
|
||||
(14, '2026-03-20 04:36:17.199469748', 1, 5700410.634731486, -3551849.8950458337, 1415295.6418679939, 543.7800930870285, -2093.4677990699024, -7392.666893723815, 0.0588, 125.0),
|
||||
(15, '2026-03-20 04:36:17.199469748', 1, 2226452.129165154, -2725645.970252924, -5901132.02228053, -5676.821938940451, 3529.089468671107, -3774.088216667501, 0.0588, 125.0),
|
||||
(16, '2026-03-20 04:36:17.199469748', 1, -3812294.81281716, 2651900.7823911654, -5063730.148299142, -3500.162878099333, 4608.015564707656, 5051.928406182758, 0.0588, 125.0),
|
||||
(17, '2026-03-20 06:12:34.090912147', 1, -3853599.6919466653, 2576048.3106887443, 5054325.968491229, 5584.613907982802, -1584.611512736432, 5052.09382171401, 0.0588, 125.0),
|
||||
(18, '2026-03-20 06:12:34.090912147', 1, 3343662.265252321, -1073879.596285395, 5888481.632523625, 5376.226538726409, -4003.3181715893215, -3774.0209436005152, 0.0588, 125.0),
|
||||
(19, '2026-03-20 06:12:34.090912147', 1, 5392946.66813025, -4009091.35838356, -1416020.9990885688, -2132.052876866671, -270.3084256740931, -7392.664064050133, 0.0588, 125.0),
|
||||
(20, '2026-03-20 06:12:34.090912147', 1, -9878.150691137964, -1134172.345175515, -6777094.963096752, -5959.561661919599, 4762.465791485728, -788.8304355957544, 0.0588, 125.0),
|
||||
(21, '2026-03-20 06:12:34.090912147', 1, -4726156.476778872, 4143869.212740232, -2770870.028395509, -1276.866626715071, 3149.351408771444, 6902.361873377728, 0.0588, 125.0),
|
||||
(22, '2026-03-20 07:20:00.000000000', 1, -6834436.888999999, 597938.4140000014, -0.3770000001939167, 137.3883790000003, 1464.748528, 7562.254627000002, 0.0, 0.0),
|
||||
(666, '2028-06-01 00:00:00.000000000', 1, 1206473.149, 415444.745, 6735835.213, 6875.6050000000005, -3278.8179999999998, -1037.9270000000001, 0.0, 125.0),
|
||||
(667, '2028-06-01 00:00:00.000000000', 1, -1213286.869, -411426.514, -6728843.538, -6882.499, 3281.629, 1031.689, 0.0, 125.0),
|
||||
(668, '2028-06-01 00:00:00.000000000', 1, 6145810.177, -2865214.215, -934946.8369999999, -1566.251, -904.4879999999999, -7499.759, 0.0, 125.0),
|
||||
(669, '2028-06-01 00:00:00.000000000', 1, -6161752.687999999, 2870843.512, 924613.278, 1545.135, 913.259, 7485.861, 0.0, 125.0),
|
||||
(62138, '2025-10-27 00:29:33.162630000', 5028, -5574448.0182, 4045996.9959, 0, 881.19655406, 1197.8366823, 7544.9922967, 0.0, 125.0),
|
||||
(56756, '2025-10-27 1:27:51.310780000', 13427, -3477478.8547, 5945714.2979, 0, 1288.2677793, 742.41429008, 7545.0218967, 0.0, 125.0);
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
INSERT INTO satellite_ic (satellite_id, orb_time, orb_revolution, x, y, z, vx, vy, vz, s_ball, f81) VALUES
|
||||
(62138, '2025-10-27 00:29:33.162630000', 5028, -5574448.0182, 4045996.9959, 0, 881.19655406, 1197.8366823, 7544.9922967, 0.0, 125.0),
|
||||
(56756, '2025-10-27 1:27:51.310780000', 13427, -3477478.8547, 5945714.2979, 0, 1288.2677793, 742.41429008, 7545.0218967, 0.0, 125.0)
|
||||
ON CONFLICT (satellite_id) DO UPDATE SET
|
||||
orb_time = EXCLUDED.orb_time,
|
||||
orb_revolution = EXCLUDED.orb_revolution,
|
||||
x = EXCLUDED.x,
|
||||
y = EXCLUDED.y,
|
||||
z = EXCLUDED.z,
|
||||
vx = EXCLUDED.vx,
|
||||
vy = EXCLUDED.vy,
|
||||
vz = EXCLUDED.vz,
|
||||
s_ball = EXCLUDED.s_ball,
|
||||
f81 = EXCLUDED.f81;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_slot_satellite_tk ON slot(satellite_id, tk);
|
||||
CREATE INDEX IF NOT EXISTS idx_slot_satellite_revolution ON slot(satellite_id, revolution);
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS slot(
|
||||
slot_id BIGSERIAL PRIMARY KEY,
|
||||
satellite_id BIGINT NOT NULL,
|
||||
covering_type INT NOT NULL,
|
||||
slot_num BIGINT NOT NULL,
|
||||
cycle BIGINT NOT NULL,
|
||||
tn TIMESTAMP NOT NULL,
|
||||
tk TIMESTAMP NOT NULL,
|
||||
roll DOUBLE PRECISION,
|
||||
contour_wkt TEXT NOT NULL,
|
||||
revolution BIGINT NOT NULL,
|
||||
revolution_sign VARCHAR(4) NOT NULL CHECK (revolution_sign IN ('ASC', 'DESC'))
|
||||
);
|
||||
|
||||
ALTER TABLE slot ADD COLUMN IF NOT EXISTS contour_geom geometry(Polygon, 4326)
|
||||
GENERATED ALWAYS AS (ST_GeomFromText(contour_wkt)::geometry(Polygon, 4326)) STORED;
|
||||
|
||||
|
||||
CREATE INDEX idx_covering_type ON slot(covering_type);
|
||||
CREATE INDEX idx_satellite_id ON slot(satellite_id);
|
||||
CREATE INDEX idx_revolution_sign ON slot(revolution_sign);
|
||||
CREATE INDEX idx_slot_contour_geom ON slot USING GIST (contour_geom);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS booked_slot(
|
||||
booked_slot_id BIGSERIAL PRIMARY KEY,
|
||||
slot_id BIGINT NOT NULL,
|
||||
cycle BIGINT NOT NULL,
|
||||
FOREIGN KEY (slot_id) REFERENCES slot(slot_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
CREATE INDEX idx_slot_slot_id ON booked_slot(slot_id);
|
||||
CREATE INDEX idx_slot_cycle ON booked_slot(cycle);
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX idx_slot_num ON slot(slot_num);
|
||||
CREATE INDEX idx_slot_dict ON slot(satellite_id, slot_num);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE booked_slot ADD CONSTRAINT unique_booked_slot UNIQUE (slot_id, cycle);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS booked_slot_request(
|
||||
booked_slot_request_id BIGSERIAL PRIMARY KEY,
|
||||
booked_slot_id BIGINT NOT NULL,
|
||||
request_id VARCHAR(50) NOT NULL,
|
||||
FOREIGN KEY (booked_slot_id) REFERENCES booked_slot(booked_slot_id) ON DELETE CASCADE,
|
||||
UNIQUE (booked_slot_id, request_id)
|
||||
);
|
||||
|
||||
|
||||
CREATE INDEX idx_booked_slot_request_slot_id ON booked_slot_request(booked_slot_id);
|
||||
CREATE INDEX idx_booked_slot_request_request_id ON booked_slot_request(request_id);
|
||||
CREATE INDEX idx_booked_slot_request_request_id_slot_id ON booked_slot_request(booked_slot_id, request_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE booked_slot
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'BOOKED';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE slot ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
ALTER TABLE slot ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
UPDATE booked_slot
|
||||
SET status = 'CANCELED'
|
||||
WHERE status = 'CANCELLED';
|
||||
|
||||
UPDATE booked_slot
|
||||
SET status = 'FAILED'
|
||||
WHERE status = 'CRASHED';
|
||||
|
||||
UPDATE booked_slot
|
||||
SET status = 'FINISHED'
|
||||
WHERE status = 'COMPLETED';
|
||||
|
||||
ALTER TABLE booked_slot
|
||||
DROP CONSTRAINT IF EXISTS chk_booked_slot_status;
|
||||
|
||||
ALTER TABLE booked_slot
|
||||
ADD CONSTRAINT chk_booked_slot_status
|
||||
CHECK (status IN ('BOOKED', 'PROCESSED', 'FINISHED', 'CANCELED', 'FAILED', 'FAILED_TIMEOUT'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_booked_slot_status
|
||||
ON booked_slot(status);
|
||||
+1
@@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_slot_satellite_tn_tk ON slot(satellite_id, tn, tk);
|
||||
Reference in New Issue
Block a user