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);
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package space.nstart.pcp.slots_service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
|
||||
@SpringBootTest(
|
||||
properties = [
|
||||
"spring.main.lazy-initialization=true",
|
||||
"spring.autoconfigure.exclude="
|
||||
]
|
||||
)
|
||||
class SlotsServiceApplicationTests {
|
||||
|
||||
@Test
|
||||
fun contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verifyNoInteractions
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BookingControllerTest {
|
||||
|
||||
private val slotService = mock(SlotService::class.java)
|
||||
private val controller = BookingController().also {
|
||||
ReflectionTestUtils.setField(it, "slotService", slotService)
|
||||
}
|
||||
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller).build()
|
||||
|
||||
@Test
|
||||
fun `legacy process endpoint returns gone and does not update booked slots`() {
|
||||
val result = mockMvc.perform(
|
||||
post("/api/slots/booking/process")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("[10,20]")
|
||||
)
|
||||
.andExpect(status().isGone)
|
||||
.andReturn()
|
||||
|
||||
assertTrue(result.resolvedException?.message?.contains("Legacy booked slots HTTP processing is retired") == true)
|
||||
|
||||
verifyNoInteractions(slotService)
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.doThrow
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
import space.nstart.pcp.slots_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class SatelliteControllerTest {
|
||||
|
||||
private val slotService = mock(SlotService::class.java)
|
||||
private val controller = SatelliteController().also {
|
||||
ReflectionTestUtils.setField(it, "slotService", slotService)
|
||||
}
|
||||
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(GlobalExceptionHandler())
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `publish initial conditions endpoint delegates to slot service`() {
|
||||
val time = LocalDateTime.of(2026, 3, 30, 10, 0)
|
||||
mockMvc.perform(
|
||||
post("/api/satellite/56756/send-ic")
|
||||
.param("time", "2026-03-30T10:00:00")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
)
|
||||
.andExpect(status().isAccepted)
|
||||
|
||||
verify(slotService).publishInitialConditions(56756L, time, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `publish initial conditions endpoint returns bad request when satellite is missing`() {
|
||||
val time = LocalDateTime.of(2026, 3, 30, 10, 0)
|
||||
doThrow(CustomValidationException("КА 999 не зарегистрирован"))
|
||||
.`when`(slotService).publishInitialConditions(999L, time, true)
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/satellite/999/send-ic")
|
||||
.param("time", "2026-03-30T10:00:00")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.error").value("КА 999 не зарегистрирован"))
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
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.GlobalExceptionHandler
|
||||
import space.nstart.pcp.slots_service.service.SatelliteIcService
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class SatelliteIcControllerTest {
|
||||
|
||||
private val satelliteIcService = mock(SatelliteIcService::class.java)
|
||||
private val controller = SatelliteIcController(satelliteIcService)
|
||||
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(GlobalExceptionHandler())
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `get satellite ic delegates to service`() {
|
||||
`when`(satelliteIcService.bySatelliteId(22L)).thenReturn(sampleIc(22L))
|
||||
|
||||
mockMvc.perform(get("/api/satellite-ic/22"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.satelliteId").value(22L))
|
||||
.andExpect(jsonPath("$.ic.orbPoint.revolution").value(42))
|
||||
|
||||
verify(satelliteIcService).bySatelliteId(22L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create satellite ic delegates to service`() {
|
||||
`when`(satelliteIcService.create(anySatelliteIc()))
|
||||
.thenReturn(sampleIc(22L))
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/satellite-ic")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{
|
||||
"satelliteId": 22,
|
||||
"ic": {
|
||||
"orbPoint": {
|
||||
"time": "2026-03-20T07:20:00",
|
||||
"revolution": 42,
|
||||
"vx": 1.0,
|
||||
"vy": 2.0,
|
||||
"vz": 3.0,
|
||||
"x": 4.0,
|
||||
"y": 5.0,
|
||||
"z": 6.0
|
||||
},
|
||||
"sBall": 0.1,
|
||||
"f81": 125.0
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.satelliteId").value(22L))
|
||||
|
||||
verify(satelliteIcService).create(anySatelliteIc())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update satellite ic delegates to service`() {
|
||||
`when`(satelliteIcService.update(eqValue(22L), anyInitialConditions()))
|
||||
.thenReturn(sampleIc(22L))
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/satellite-ic/22")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{
|
||||
"orbPoint": {
|
||||
"time": "2026-03-20T07:20:00",
|
||||
"revolution": 42,
|
||||
"vx": 1.0,
|
||||
"vy": 2.0,
|
||||
"vz": 3.0,
|
||||
"x": 4.0,
|
||||
"y": 5.0,
|
||||
"z": 6.0
|
||||
},
|
||||
"sBall": 0.1,
|
||||
"f81": 125.0
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.satelliteId").value(22L))
|
||||
|
||||
verify(satelliteIcService).update(eqValue(22L), anyInitialConditions())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete satellite ic delegates to service`() {
|
||||
mockMvc.perform(delete("/api/satellite-ic/22"))
|
||||
.andExpect(status().isNoContent)
|
||||
|
||||
verify(satelliteIcService).delete(22L)
|
||||
}
|
||||
|
||||
private fun sampleIc(satelliteId: Long) = SatelliteICDTO(
|
||||
satelliteId = satelliteId,
|
||||
ic = InitialConditionsDTO(
|
||||
orbPoint = OrbPointDTO(
|
||||
time = LocalDateTime.of(2026, 3, 20, 7, 20),
|
||||
revolution = 42,
|
||||
vx = 1.0,
|
||||
vy = 2.0,
|
||||
vz = 3.0,
|
||||
x = 4.0,
|
||||
y = 5.0,
|
||||
z = 6.0
|
||||
),
|
||||
sBall = 0.1,
|
||||
f81 = 125.0
|
||||
)
|
||||
)
|
||||
|
||||
private fun anySatelliteIc(): SatelliteICDTO =
|
||||
any(SatelliteICDTO::class.java) ?: SatelliteICDTO()
|
||||
|
||||
private fun anyInitialConditions(): InitialConditionsDTO =
|
||||
any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO()
|
||||
|
||||
private fun eqValue(value: Long): Long = eq(value) ?: value
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.doThrow
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
|
||||
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.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class SlotControllerTest {
|
||||
|
||||
private val slotService = mock(SlotService::class.java)
|
||||
private val controller = SlotController().also {
|
||||
ReflectionTestUtils.setField(it, "slotService", slotService)
|
||||
}
|
||||
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(GlobalExceptionHandler())
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `interval endpoint delegates to slot service`() {
|
||||
val satelliteId = 56756L
|
||||
val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0)
|
||||
val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0)
|
||||
doReturn(
|
||||
listOf(
|
||||
SlotDTO(
|
||||
cycle = 2,
|
||||
satelliteId = 56756L,
|
||||
tn = LocalDateTime.of(2026, 4, 20, 1, 0),
|
||||
tk = LocalDateTime.of(2026, 4, 20, 1, 15),
|
||||
roll = 12.5,
|
||||
contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))",
|
||||
revolution = 44L,
|
||||
revolutionSign = RevolutionSign.ASC,
|
||||
slotNumber = 101L
|
||||
)
|
||||
)
|
||||
).`when`(slotService).allByInterval(satelliteId, timeStart, timeStop)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/slots/interval")
|
||||
.param("satelliteId", satelliteId.toString())
|
||||
.param("timeStart", timeStart.toString())
|
||||
.param("timeStop", timeStop.toString())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].satelliteId").value(56756L))
|
||||
.andExpect(jsonPath("$[0].slotNumber").value(101L))
|
||||
|
||||
verify(slotService).allByInterval(satelliteId, timeStart, timeStop)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `poly cover endpoint delegates coverage strategy to slot service`() {
|
||||
val wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0)
|
||||
val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0)
|
||||
doReturn(emptyList<SlotDTO>()).`when`(slotService).polySlots(
|
||||
wkt,
|
||||
timeStart,
|
||||
timeStop,
|
||||
RevolutionSign.ASC,
|
||||
true,
|
||||
null,
|
||||
listOf(22L),
|
||||
12.5,
|
||||
SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/slots/poly-cover")
|
||||
.param("wkt", wkt)
|
||||
.param("timeStart", timeStart.toString())
|
||||
.param("timeStop", timeStop.toString())
|
||||
.param("revSign", "ASC")
|
||||
.param("cov", "true")
|
||||
.param("satellites", "22")
|
||||
.param("sun", "12.5")
|
||||
.param("coverageStrategy", "CONTINUOUS")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
|
||||
verify(slotService).polySlots(
|
||||
wkt,
|
||||
timeStart,
|
||||
timeStop,
|
||||
RevolutionSign.ASC,
|
||||
true,
|
||||
null,
|
||||
listOf(22L),
|
||||
12.5,
|
||||
SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request cover endpoint delegates coverage strategy to slot service`() {
|
||||
val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0)
|
||||
val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0)
|
||||
doReturn(emptyList<SlotDTO>()).`when`(slotService).reqCover(
|
||||
"req-1",
|
||||
timeStart,
|
||||
timeStop,
|
||||
RevolutionSign.ASC,
|
||||
true,
|
||||
listOf(22L),
|
||||
12.5,
|
||||
SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/slots/request-cover")
|
||||
.param("requestId", "req-1")
|
||||
.param("timeStart", timeStart.toString())
|
||||
.param("timeStop", timeStop.toString())
|
||||
.param("revSign", "ASC")
|
||||
.param("cov", "true")
|
||||
.param("satellites", "22")
|
||||
.param("sun", "12.5")
|
||||
.param("coverageStrategy", "CONTINUOUS")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
|
||||
verify(slotService).reqCover(
|
||||
"req-1",
|
||||
timeStart,
|
||||
timeStop,
|
||||
RevolutionSign.ASC,
|
||||
true,
|
||||
listOf(22L),
|
||||
12.5,
|
||||
SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interval endpoint returns bad request on invalid interval`() {
|
||||
val satelliteId = 56756L
|
||||
val timeStart = LocalDateTime.of(2026, 4, 21, 0, 0)
|
||||
val timeStop = LocalDateTime.of(2026, 4, 20, 0, 0)
|
||||
doThrow(CustomValidationException("Параметр timeStop должен быть больше или равен timeStart"))
|
||||
.`when`(slotService).allByInterval(satelliteId, timeStart, timeStop)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/slots/interval")
|
||||
.param("satelliteId", satelliteId.toString())
|
||||
.param("timeStart", timeStart.toString())
|
||||
.param("timeStop", timeStop.toString())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.error").value("Параметр timeStop должен быть больше или равен timeStart"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculation summary endpoint delegates to slot service`() {
|
||||
doReturn(
|
||||
listOf(
|
||||
SlotCalculationSummaryDTO.calculated(
|
||||
satelliteId = 22L,
|
||||
slotCount = 12L,
|
||||
minTime = LocalDateTime.of(2026, 4, 20, 0, 0),
|
||||
maxTime = LocalDateTime.of(2026, 4, 21, 0, 0),
|
||||
minRevolution = 100L,
|
||||
maxRevolution = 112L
|
||||
)
|
||||
)
|
||||
).`when`(slotService).slotCalculationSummaries(listOf(22L, 44L))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/slots/calculation-summary")
|
||||
.param("satelliteIds", "22")
|
||||
.param("satelliteIds", "44")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].satelliteId").value(22L))
|
||||
.andExpect(jsonPath("$[0].calculated").value(true))
|
||||
.andExpect(jsonPath("$[0].slotCount").value(12L))
|
||||
.andExpect(jsonPath("$[0].revolutionInterval").value(12L))
|
||||
|
||||
verify(slotService).slotCalculationSummaries(listOf(22L, 44L))
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package space.nstart.pcp.slots_service.dto
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class CoverageStrategyDTOTest {
|
||||
|
||||
private val mapper = jacksonObjectMapper().registerModule(JavaTimeModule())
|
||||
private val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
private val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
@Test
|
||||
fun `slot coverage batch request defaults strategy to greedy`() {
|
||||
val request = SlotCoverageBatchRequestDTO(
|
||||
timeStart = intervalStart,
|
||||
timeStop = intervalEnd
|
||||
)
|
||||
|
||||
assertEquals(SlotCoverageStrategy.GREEDY, request.coverageStrategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slot coverage batch request copy keeps coverage strategy`() {
|
||||
val request = SlotCoverageBatchRequestDTO(
|
||||
timeStart = intervalStart,
|
||||
timeStop = intervalEnd,
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
|
||||
assertEquals(SlotCoverageStrategy.CONTINUOUS, request.copy(cov = true).coverageStrategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slot coverage batch request serializes coverage strategy`() {
|
||||
val json = mapper.writeValueAsString(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
timeStart = intervalStart,
|
||||
timeStop = intervalEnd,
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(json.contains("\"coverageStrategy\":\"CONTINUOUS\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slot coverage batch request deserializes missing strategy as greedy`() {
|
||||
val request = mapper.readValue<SlotCoverageBatchRequestDTO>(
|
||||
"""
|
||||
{
|
||||
"items": [],
|
||||
"timeStart": "2026-03-26T10:00:00",
|
||||
"timeStop": "2026-03-26T12:00:00"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertEquals(SlotCoverageStrategy.GREEDY, request.coverageStrategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complex plan process request copy keeps coverage strategy`() {
|
||||
val request = ComplexPlanProcessRequestDTO(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
|
||||
assertEquals(SlotCoverageStrategy.CONTINUOUS, request.copy(satelliteId = 22L).coverageStrategy)
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
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.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureNanoTime
|
||||
|
||||
class ContinuousReqCoverSolverBenchmarkTest {
|
||||
|
||||
private val satellitesById: Map<Long, AbstractSatellite> = mapOf(
|
||||
22L to TestSatelliteImpl(id = 22L, maxSurveyDuration = 300, mmi = 10),
|
||||
23L to TestSatelliteImpl(id = 23L, maxSurveyDuration = 300, mmi = 10),
|
||||
24L to TestSatelliteImpl(id = 24L, maxSurveyDuration = 300, mmi = 10)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `benchmark continuous solver on dense strip scenario`() {
|
||||
val solver = ContinuousReqCoverSolver(
|
||||
logger = LoggerFactory.getLogger("ContinuousReqCoverSolverBenchmark"),
|
||||
satellitesById = satellitesById
|
||||
)
|
||||
|
||||
val targetWkt = "POLYGON ((0 0, 40 0, 40 2, 0 2, 0 0))"
|
||||
val slots = buildScenario()
|
||||
repeat(3) { solver.select(targetWkt, slots) }
|
||||
|
||||
val elapsedNanos = measureNanoTime {
|
||||
repeat(20) {
|
||||
solver.select(targetWkt, slots)
|
||||
}
|
||||
}
|
||||
val elapsedMillis = elapsedNanos / 1_000_000.0
|
||||
LoggerFactory.getLogger("ContinuousReqCoverSolverBenchmark")
|
||||
.info("continuous benchmark: iterations=20, slots={}, elapsedMs={}", slots.size, "%.3f".format(elapsedMillis))
|
||||
}
|
||||
|
||||
private fun buildScenario(): List<SlotDTO> {
|
||||
val result = mutableListOf<SlotDTO>()
|
||||
val baseTime = LocalDateTime.of(2026, 4, 1, 10, 0)
|
||||
var slotNumber = 1L
|
||||
|
||||
for (satelliteId in listOf(22L, 23L, 24L)) {
|
||||
for (index in 0 until 60) {
|
||||
val left = index * 0.6
|
||||
val right = left + 1.4
|
||||
val timeStart = index * 18L + satelliteId
|
||||
val timeStop = timeStart + 10L
|
||||
result += SlotDTO(
|
||||
cycle = 1L,
|
||||
satelliteId = satelliteId,
|
||||
tn = baseTime.plusSeconds(timeStart),
|
||||
tk = baseTime.plusSeconds(timeStop),
|
||||
roll = if (satelliteId == 23L) 5.02 else 5.0,
|
||||
contour = "POLYGON (($left 0, $right 0, $right 2, $left 2, $left 0))",
|
||||
revolution = 100 + index.toLong(),
|
||||
revolutionSign = if (satelliteId == 24L && index % 6 < 2) RevolutionSign.DESC else RevolutionSign.ASC,
|
||||
slotNumber = slotNumber++,
|
||||
latitude = 0.0,
|
||||
longitude = left
|
||||
)
|
||||
|
||||
if (index % 5 == 0) {
|
||||
val sliverLeft = left + 1.38
|
||||
val sliverRight = sliverLeft + 0.04
|
||||
result += SlotDTO(
|
||||
cycle = 1L,
|
||||
satelliteId = satelliteId,
|
||||
tn = baseTime.plusSeconds(timeStart + 8L),
|
||||
tk = baseTime.plusSeconds(timeStop + 8L),
|
||||
roll = if (satelliteId == 23L) 5.02 else 5.0,
|
||||
contour = "POLYGON (($sliverLeft 0, $sliverRight 0, $sliverRight 2, $sliverLeft 2, $sliverLeft 0))",
|
||||
revolution = 500 + index.toLong(),
|
||||
revolutionSign = RevolutionSign.ASC,
|
||||
slotNumber = slotNumber++,
|
||||
latitude = 0.0,
|
||||
longitude = sliverLeft
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.slf4j.LoggerFactory
|
||||
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.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
import space.nstart.pcp.slots_service.util.LongitudeWrapGeometry
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class ContinuousReqCoverSolverTest {
|
||||
|
||||
private val satellitesById: Map<Long, AbstractSatellite> = mapOf(
|
||||
22L to TestSatelliteImpl(id = 22L, maxSurveyDuration = 300, mmi = 10),
|
||||
23L to TestSatelliteImpl(id = 23L, maxSurveyDuration = 300, mmi = 10)
|
||||
)
|
||||
|
||||
private val solver = ContinuousReqCoverSolver(
|
||||
logger = LoggerFactory.getLogger("ContinuousReqCoverSolverTest"),
|
||||
satellitesById = satellitesById
|
||||
)
|
||||
|
||||
private val greedySolver = OptimalReqCoverSolver(
|
||||
logger = LoggerFactory.getLogger("ContinuousReqCoverSolverTest.greedy"),
|
||||
satellitesById = satellitesById
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `continuous solver aggregates overlapping slots into chain-aware candidates`() {
|
||||
val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java)
|
||||
mergeMethod.isAccessible = true
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val chains = mergeMethod.invoke(
|
||||
solver,
|
||||
listOf(
|
||||
slot(
|
||||
slotNumber = 1,
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 60,
|
||||
contour = "POLYGON ((1 0, 3 0, 3 1, 1 1, 1 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 3,
|
||||
startOffsetSeconds = 120,
|
||||
endOffsetSeconds = 150,
|
||||
contour = "POLYGON ((4 0, 5 0, 5 1, 4 1, 4 0))"
|
||||
)
|
||||
)
|
||||
) as List<Any>
|
||||
|
||||
assertEquals(2, chains.size)
|
||||
val slotsField = chains.first().javaClass.getDeclaredField("slots").apply { isAccessible = true }
|
||||
val representativeField = chains.first().javaClass.getDeclaredField("representative").apply { isAccessible = true }
|
||||
val firstChainSlots = slotsField.get(chains.first()) as List<*>
|
||||
val firstRepresentative = representativeField.get(chains.first()) as SlotDTO
|
||||
|
||||
assertEquals(2, firstChainSlots.size)
|
||||
assertEquals(1L, firstRepresentative.slotNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start selection uses continuation potential instead of leftmost fragment`() {
|
||||
val result = solver.select(
|
||||
targetWkt = "POLYGON ((0 0, 5 0, 5 1, 0 1, 0 0))",
|
||||
slots = listOf(
|
||||
slot(
|
||||
slotNumber = 1,
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 10,
|
||||
contour = "POLYGON ((0 0, 0.4 0, 0.4 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 3,
|
||||
startOffsetSeconds = 40,
|
||||
endOffsetSeconds = 50,
|
||||
contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 4,
|
||||
startOffsetSeconds = 60,
|
||||
endOffsetSeconds = 70,
|
||||
contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(result.isEmpty())
|
||||
assertEquals(2L, result.first().slotNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next selection prefers meaningful gain over tiny touching continuation`() {
|
||||
val result = solver.select(
|
||||
targetWkt = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))",
|
||||
slots = listOf(
|
||||
slot(
|
||||
slotNumber = 1,
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 10,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0.999 0, 1.001 0, 1.001 1, 0.999 1, 0.999 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 3,
|
||||
startOffsetSeconds = 40,
|
||||
endOffsetSeconds = 50,
|
||||
contour = "POLYGON ((1.25 0, 2.25 0, 2.25 1, 1.25 1, 1.25 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1L, 3L), result.map { it.slotNumber })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuity is evaluated by target-covered geometry rather than full contour`() {
|
||||
val result = solver.select(
|
||||
targetWkt = "POLYGON ((0 0, 3 0, 3 1, 0 1, 0 0))",
|
||||
slots = listOf(
|
||||
slot(
|
||||
slotNumber = 1,
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 10,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 2, 0 2, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "MULTIPOLYGON (((1 1.2, 1.5 1.2, 1.5 2, 1 2, 1 1.2)), ((2 0, 3 0, 3 1, 2 1, 2 0)))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 3,
|
||||
startOffsetSeconds = 40,
|
||||
endOffsetSeconds = 50,
|
||||
contour = "POLYGON ((0.9 0, 2 0, 2 1, 0.9 1, 0.9 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1L, 3L), result.map { it.slotNumber })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same longitude candidate without meaningful gain is rejected`() {
|
||||
val result = solver.select(
|
||||
targetWkt = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))",
|
||||
slots = listOf(
|
||||
slot(
|
||||
slotNumber = 1,
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 10,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0.8 0, 1 0, 1 1, 0.8 1, 0.8 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 3,
|
||||
startOffsetSeconds = 40,
|
||||
endOffsetSeconds = 50,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1L, 3L), result.map { it.slotNumber })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same longitude candidate is allowed only when it adds meaningful new coverage`() {
|
||||
val result = solver.select(
|
||||
targetWkt = "POLYGON ((0 0, 1 0, 1 2, 0 2, 0 0))",
|
||||
slots = listOf(
|
||||
slot(
|
||||
slotNumber = 1,
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 10,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0 1, 1 1, 1 2, 0 2, 0 1))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1L, 2L), result.map { it.slotNumber })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous coverage remains competitive with greedy on long strip`() {
|
||||
val target = "POLYGON ((0 0, 6 0, 6 1, 0 1, 0 0))"
|
||||
val slots = listOf(
|
||||
slot(1, 0, 10, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
|
||||
slot(2, 20, 30, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"),
|
||||
slot(3, 40, 50, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"),
|
||||
slot(4, 60, 70, contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))"),
|
||||
slot(5, 80, 90, contour = "POLYGON ((4 0, 5 0, 5 1, 4 1, 4 0))"),
|
||||
slot(11, 100, 110, contour = "POLYGON ((0.999 0, 1.001 0, 1.001 1, 0.999 1, 0.999 0))"),
|
||||
slot(12, 120, 130, contour = "POLYGON ((1.999 0, 2.001 0, 2.001 1, 1.999 1, 1.999 0))")
|
||||
)
|
||||
|
||||
val greedy = greedySolver.select(target, slots)
|
||||
val continuous = solver.select(target, slots)
|
||||
val greedyMetrics = coverageMetrics(target, greedy)
|
||||
val continuousMetrics = coverageMetrics(target, continuous)
|
||||
|
||||
assertTrue(continuousMetrics.coveragePercent >= greedyMetrics.coveragePercent * 0.95)
|
||||
assertTrue(continuousMetrics.redundancyArea <= greedyMetrics.redundancyArea + 0.1)
|
||||
assertTrue(continuous.size <= greedy.size + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous does not collapse on competing branches fixture`() {
|
||||
val target = "POLYGON ((0 0, 5 0, 5 1, 0 1, 0 0))"
|
||||
val slots = listOf(
|
||||
slot(1, 0, 10, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
|
||||
slot(2, 20, 30, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"),
|
||||
slot(3, 40, 50, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"),
|
||||
slot(10, 15, 25, revolutionSign = RevolutionSign.DESC, contour = "POLYGON ((0.8 0, 2.4 0, 2.4 1, 0.8 1, 0.8 0))"),
|
||||
slot(11, 35, 45, revolutionSign = RevolutionSign.DESC, contour = "POLYGON ((2.4 0, 4.0 0, 4.0 1, 2.4 1, 2.4 0))"),
|
||||
slot(12, 55, 65, contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))")
|
||||
)
|
||||
|
||||
val greedy = greedySolver.select(target, slots)
|
||||
val continuous = solver.select(target, slots)
|
||||
val greedyMetrics = coverageMetrics(target, greedy)
|
||||
val continuousMetrics = coverageMetrics(target, continuous)
|
||||
|
||||
assertTrue(continuousMetrics.coveragePercent >= greedyMetrics.coveragePercent * 0.85)
|
||||
assertTrue(continuousMetrics.coveragePercent >= 75.0)
|
||||
}
|
||||
|
||||
private fun coverageMetrics(targetWkt: String, slots: List<SlotDTO>): CoverageMetrics {
|
||||
val reader = WKTReader()
|
||||
val target = LongitudeWrapGeometry.normalizeToContinuous360(reader.read(targetWkt))
|
||||
val coveredParts = slots.map { slot ->
|
||||
val geometry = LongitudeWrapGeometry.alignToReference(reader.read(slot.contour), target)
|
||||
geometry.intersection(target)
|
||||
}.filter { !it.isEmpty }
|
||||
|
||||
val union = coveredParts.reduceOrNull(Geometry::union)
|
||||
val coveredArea = union?.area ?: 0.0
|
||||
val totalArea = target.area
|
||||
val redundancyArea = coveredParts.sumOf { it.area } - coveredArea
|
||||
|
||||
return CoverageMetrics(
|
||||
coveragePercent = if (totalArea == 0.0) 0.0 else coveredArea * 100.0 / totalArea,
|
||||
redundancyArea = redundancyArea
|
||||
)
|
||||
}
|
||||
|
||||
private data class CoverageMetrics(
|
||||
val coveragePercent: Double,
|
||||
val redundancyArea: Double
|
||||
)
|
||||
|
||||
private fun slot(
|
||||
slotNumber: Long,
|
||||
startOffsetSeconds: Long,
|
||||
endOffsetSeconds: Long,
|
||||
satelliteId: Long = 22L,
|
||||
cycle: Long = 1L,
|
||||
roll: Double = 5.0,
|
||||
revolutionSign: RevolutionSign = RevolutionSign.ASC,
|
||||
contour: String
|
||||
): SlotDTO {
|
||||
val start = LocalDateTime.of(2026, 3, 30, 10, 0)
|
||||
return SlotDTO(
|
||||
cycle = cycle,
|
||||
satelliteId = satelliteId,
|
||||
tn = start.plusSeconds(startOffsetSeconds),
|
||||
tk = start.plusSeconds(endOffsetSeconds),
|
||||
roll = roll,
|
||||
contour = contour,
|
||||
revolution = 101,
|
||||
revolutionSign = revolutionSign,
|
||||
slotNumber = slotNumber,
|
||||
latitude = 0.0,
|
||||
longitude = 0.0
|
||||
)
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package space.nstart.pcp.slots_service.model
|
||||
|
||||
import ch.qos.logback.classic.Logger
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent
|
||||
import ch.qos.logback.core.read.ListAppender
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class OptimalReqCoverSolverTest {
|
||||
|
||||
@Test
|
||||
fun `mergeSlotsToChains preserves representative coordinates`() {
|
||||
val solver = OptimalReqCoverSolver(
|
||||
logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.coordinates"),
|
||||
satellitesById = emptyMap()
|
||||
)
|
||||
val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java)
|
||||
mergeMethod.isAccessible = true
|
||||
val start = LocalDateTime.of(2026, 3, 30, 10, 0)
|
||||
val slots = listOf(
|
||||
SlotDTO(
|
||||
cycle = 1,
|
||||
satelliteId = 22,
|
||||
tn = start,
|
||||
tk = start.plusSeconds(30),
|
||||
roll = 5.0,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
revolution = 101,
|
||||
revolutionSign = RevolutionSign.ASC,
|
||||
slotNumber = 1,
|
||||
latitude = 55.75,
|
||||
longitude = 37.62
|
||||
),
|
||||
SlotDTO(
|
||||
cycle = 1,
|
||||
satelliteId = 22,
|
||||
tn = start.plusSeconds(20),
|
||||
tk = start.plusSeconds(60),
|
||||
roll = 5.0,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
revolution = 101,
|
||||
revolutionSign = RevolutionSign.ASC,
|
||||
slotNumber = 2,
|
||||
latitude = 56.0,
|
||||
longitude = 38.0
|
||||
)
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val chains = mergeMethod.invoke(solver, slots) as List<Any>
|
||||
val representativeField = chains.single().javaClass.getDeclaredField("representative")
|
||||
representativeField.isAccessible = true
|
||||
val representative = representativeField.get(chains.single()) as SlotDTO
|
||||
|
||||
assertEquals(55.75, representative.latitude)
|
||||
assertEquals(37.62, representative.longitude)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeSlotsToChains merges when time overlaps and geometry intersects`() {
|
||||
val solver = OptimalReqCoverSolver(
|
||||
logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.mergeAllowed"),
|
||||
satellitesById = emptyMap()
|
||||
)
|
||||
val chains = mergeChains(
|
||||
solver,
|
||||
listOf(
|
||||
slot(
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 60,
|
||||
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, chains.size)
|
||||
val representative = representative(chains.single())
|
||||
assertTrue(representative.contour.startsWith("POLYGON"))
|
||||
assertFalse(representative.contour.startsWith("MULTIPOLYGON"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeSlotsToChains keeps chains separate when time overlaps but geometry does not intersect`() {
|
||||
val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.noGeometry") as Logger
|
||||
val logs = captureLogs(logger) {
|
||||
val solver = OptimalReqCoverSolver(
|
||||
logger = logger,
|
||||
satellitesById = emptyMap()
|
||||
)
|
||||
val chains = mergeChains(
|
||||
solver,
|
||||
listOf(
|
||||
slot(
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 60,
|
||||
contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, chains.size)
|
||||
}
|
||||
|
||||
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeSlotsToChains keeps chains separate when contours only touch by boundary`() {
|
||||
val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.touchingBoundary") as Logger
|
||||
val logs = captureLogs(logger) {
|
||||
val solver = OptimalReqCoverSolver(
|
||||
logger = logger,
|
||||
satellitesById = emptyMap()
|
||||
)
|
||||
val chains = mergeChains(
|
||||
solver,
|
||||
listOf(
|
||||
slot(
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 60,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, chains.size)
|
||||
}
|
||||
|
||||
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeSlotsToChains does not merge when intervals do not overlap`() {
|
||||
val solver = OptimalReqCoverSolver(
|
||||
logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.noTimeOverlap"),
|
||||
satellitesById = emptyMap()
|
||||
)
|
||||
val chains = mergeChains(
|
||||
solver,
|
||||
listOf(
|
||||
slot(
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 31,
|
||||
endOffsetSeconds = 60,
|
||||
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, chains.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeSlotsToChains skips merge when union returns multipolygon`() {
|
||||
val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.multiPolygon") as Logger
|
||||
val logs = captureLogs(logger) {
|
||||
val solver = OptimalReqCoverSolver(
|
||||
logger = logger,
|
||||
satellitesById = emptyMap()
|
||||
)
|
||||
val method = solver.javaClass.getDeclaredMethod(
|
||||
"unionContoursIfPolygon",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
SlotDTO::class.java,
|
||||
SlotDTO::class.java
|
||||
)
|
||||
method.isAccessible = true
|
||||
|
||||
val result = method.invoke(
|
||||
solver,
|
||||
"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
"POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
|
||||
slot(
|
||||
startOffsetSeconds = 0,
|
||||
endOffsetSeconds = 30,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
slot(
|
||||
slotNumber = 2,
|
||||
startOffsetSeconds = 20,
|
||||
endOffsetSeconds = 60,
|
||||
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(null, result)
|
||||
}
|
||||
|
||||
assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") })
|
||||
}
|
||||
|
||||
private fun mergeChains(solver: OptimalReqCoverSolver, slots: List<SlotDTO>): List<Any> {
|
||||
val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java)
|
||||
mergeMethod.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return mergeMethod.invoke(solver, slots) as List<Any>
|
||||
}
|
||||
|
||||
private fun representative(chain: Any): SlotDTO {
|
||||
val representativeField = chain.javaClass.getDeclaredField("representative")
|
||||
representativeField.isAccessible = true
|
||||
return representativeField.get(chain) as SlotDTO
|
||||
}
|
||||
|
||||
private fun slot(
|
||||
slotNumber: Long = 1,
|
||||
startOffsetSeconds: Long,
|
||||
endOffsetSeconds: Long,
|
||||
contour: String
|
||||
): SlotDTO {
|
||||
val start = LocalDateTime.of(2026, 3, 30, 10, 0)
|
||||
return SlotDTO(
|
||||
cycle = 1,
|
||||
satelliteId = 22,
|
||||
tn = start.plusSeconds(startOffsetSeconds),
|
||||
tk = start.plusSeconds(endOffsetSeconds),
|
||||
roll = 5.0,
|
||||
contour = contour,
|
||||
revolution = 101,
|
||||
revolutionSign = RevolutionSign.ASC,
|
||||
slotNumber = slotNumber,
|
||||
latitude = 55.75,
|
||||
longitude = 37.62
|
||||
)
|
||||
}
|
||||
|
||||
private fun captureLogs(logger: Logger, block: () -> Unit): List<ILoggingEvent> {
|
||||
val appender = ListAppender<ILoggingEvent>()
|
||||
appender.start()
|
||||
logger.addAppender(appender)
|
||||
try {
|
||||
block()
|
||||
return appender.list.toList()
|
||||
} finally {
|
||||
logger.detachAppender(appender)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package space.nstart.pcp.slots_service.repository
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verifyNoInteractions
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import java.sql.Timestamp
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SlotRepositoryImplTest {
|
||||
|
||||
@Test
|
||||
fun `findTargetSlotRows returns empty result without hitting jdbc for empty request batch`() {
|
||||
val jdbcTemplate = mock(JdbcTemplate::class.java)
|
||||
val repository = SlotRepositoryImpl(jdbcTemplate)
|
||||
|
||||
val result = repository.findTargetSlotRows(emptyList(), chunkSize = 256)
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
verifyNoInteractions(jdbcTemplate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch spatial fetch sql filters by satellite time bbox and exact geometry without giant in`() {
|
||||
val repository = SlotRepositoryImpl(mock(JdbcTemplate::class.java))
|
||||
|
||||
val sql = ReflectionTestUtils.invokeMethod<String>(repository, "buildBatchSpatialFetchSql", 2)
|
||||
val params = ReflectionTestUtils.invokeMethod<Array<Any>>(
|
||||
repository,
|
||||
"buildBatchSpatialFetchParams",
|
||||
listOf(
|
||||
BatchCoverageSearchRequest(
|
||||
targetId = 1L,
|
||||
satelliteId = 22L,
|
||||
windowStart = LocalDateTime.of(2026, 4, 1, 10, 0),
|
||||
windowStop = LocalDateTime.of(2026, 4, 1, 12, 0),
|
||||
polygonWkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
BatchCoverageSearchRequest(
|
||||
targetId = 2L,
|
||||
satelliteId = 33L,
|
||||
windowStart = LocalDateTime.of(2026, 4, 2, 10, 0),
|
||||
windowStop = LocalDateTime.of(2026, 4, 2, 12, 0),
|
||||
polygonWkt = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
requireNotNull(sql)
|
||||
requireNotNull(params)
|
||||
assertTrue(sql.contains("slot.satellite_id = search.satellite_id"))
|
||||
assertTrue(sql.contains("slot.tn >= search.window_start"))
|
||||
assertTrue(sql.contains("slot.tk <= search.window_stop"))
|
||||
assertTrue(sql.contains("slot.contour_geom && search.search_geom"))
|
||||
assertTrue(sql.contains("st_intersects(search.search_geom, slot.contour_geom)"))
|
||||
assertFalse(sql.contains("slot.satellite_id in"))
|
||||
assertFalse(sql.contains("slot_id in"))
|
||||
assertEquals(10, params.size)
|
||||
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 1, 10, 0)), params[2])
|
||||
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 1, 12, 0)), params[3])
|
||||
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 2, 10, 0)), params[7])
|
||||
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 2, 12, 0)), params[8])
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
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.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class BookedSlotStatusServiceTest {
|
||||
|
||||
private val repository = mock(BookedSlotsRepository::class.java)
|
||||
private val service = BookedSlotStatusService(repository)
|
||||
|
||||
@Test
|
||||
fun `updates booked slots from booked to processed`() {
|
||||
val entity = bookedSlotEntity(id = 10L, status = BookedSlotStatus.BOOKED)
|
||||
`when`(repository.findAllByBookedSlotIdIn(listOf(10L))).thenReturn(listOf(entity))
|
||||
|
||||
val updated = service.updateBookedSlotsStatus(listOf(10L), BookedSlotStatus.PROCESSED, "test")
|
||||
|
||||
assertEquals(1, updated)
|
||||
assertEquals(BookedSlotStatus.PROCESSED.name, entity.status)
|
||||
verify(repository).saveAll(listOf(entity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates processed slots to terminal state`() {
|
||||
val entity = bookedSlotEntity(id = 15L, status = BookedSlotStatus.PROCESSED)
|
||||
`when`(repository.findAllByBookedSlotIdIn(listOf(15L))).thenReturn(listOf(entity))
|
||||
|
||||
val updated = service.updateBookedSlotsStatus(listOf(15L), BookedSlotStatus.FINISHED, "test")
|
||||
|
||||
assertEquals(1, updated)
|
||||
assertEquals(BookedSlotStatus.FINISHED.name, entity.status)
|
||||
verify(repository).saveAll(listOf(entity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same status delivery is idempotent`() {
|
||||
val entity = bookedSlotEntity(id = 20L, status = BookedSlotStatus.PROCESSED)
|
||||
`when`(repository.findAllByBookedSlotIdIn(listOf(20L))).thenReturn(listOf(entity))
|
||||
|
||||
val updated = service.updateBookedSlotsStatus(listOf(20L), BookedSlotStatus.PROCESSED, "duplicate")
|
||||
|
||||
assertEquals(0, updated)
|
||||
verify(repository, never()).saveAll(org.mockito.ArgumentMatchers.anyList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid transition is ignored`() {
|
||||
val entity = bookedSlotEntity(id = 25L, status = BookedSlotStatus.FAILED_TIMEOUT)
|
||||
`when`(repository.findAllByBookedSlotIdIn(listOf(25L))).thenReturn(listOf(entity))
|
||||
|
||||
val updated = service.updateBookedSlotsStatus(listOf(25L), BookedSlotStatus.PROCESSED, "invalid")
|
||||
|
||||
assertEquals(0, updated)
|
||||
assertEquals(BookedSlotStatus.FAILED_TIMEOUT.name, entity.status)
|
||||
verify(repository, never()).saveAll(org.mockito.ArgumentMatchers.anyList())
|
||||
}
|
||||
|
||||
private fun bookedSlotEntity(id: Long, status: BookedSlotStatus): BookedSlotEntity =
|
||||
BookedSlotEntity(
|
||||
bookedSlotId = id,
|
||||
slot = SlotEntity(
|
||||
slotId = 100L + id,
|
||||
slotNum = id,
|
||||
satelliteId = 77L,
|
||||
tn = LocalDateTime.of(2026, 3, 31, 10, 0),
|
||||
tk = LocalDateTime.of(2026, 3, 31, 10, 10)
|
||||
),
|
||||
cycle = 0,
|
||||
status = status.name
|
||||
)
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.verifyNoInteractions
|
||||
import org.mockito.Mockito.`when`
|
||||
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.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class BookedSlotTimeoutSchedulerTest {
|
||||
|
||||
private val repository = mock(BookedSlotsRepository::class.java)
|
||||
private val statusService = mock(BookedSlotStatusUpdater::class.java)
|
||||
private val timeResolver = mock(BookedSlotTimeResolver::class.java)
|
||||
private val properties = BookedSlotsTimeoutProperties(
|
||||
timeoutCheckInterval = Duration.ofHours(1),
|
||||
timeoutThreshold = Duration.ofHours(1)
|
||||
)
|
||||
private val clock = Clock.fixed(Instant.parse("2026-03-31T12:00:00Z"), ZoneOffset.UTC)
|
||||
private val scheduler = BookedSlotTimeoutScheduler(repository, statusService, timeResolver, properties, clock)
|
||||
|
||||
@Test
|
||||
fun `scheduler marks stale booked slots as failed timeout`() {
|
||||
val booked = bookedSlotEntity(10L)
|
||||
`when`(repository.findAllByStatus(BookedSlotStatus.BOOKED.name)).thenReturn(listOf(booked))
|
||||
`when`(timeResolver.resolveBookedSlotStartTime(booked)).thenReturn(LocalDateTime.of(2026, 3, 31, 9, 30))
|
||||
|
||||
scheduler.markTimedOutBookedSlots()
|
||||
|
||||
verify(statusService).updateBookedSlotsStatus(
|
||||
listOf(10L),
|
||||
BookedSlotStatus.FAILED_TIMEOUT,
|
||||
"timeout-scheduler"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scheduler ignores not yet timed out slots`() {
|
||||
val booked = bookedSlotEntity(20L)
|
||||
`when`(repository.findAllByStatus(BookedSlotStatus.BOOKED.name)).thenReturn(listOf(booked))
|
||||
`when`(timeResolver.resolveBookedSlotStartTime(booked)).thenReturn(LocalDateTime.of(2026, 3, 31, 11, 30))
|
||||
|
||||
scheduler.markTimedOutBookedSlots()
|
||||
|
||||
verifyNoInteractions(statusService)
|
||||
}
|
||||
|
||||
private fun bookedSlotEntity(id: Long): BookedSlotEntity =
|
||||
BookedSlotEntity(
|
||||
bookedSlotId = id,
|
||||
slot = SlotEntity(
|
||||
slotId = id + 100,
|
||||
slotNum = id,
|
||||
satelliteId = 77L,
|
||||
tn = LocalDateTime.of(2026, 3, 31, 8, 0),
|
||||
tk = LocalDateTime.of(2026, 3, 31, 8, 10)
|
||||
),
|
||||
cycle = 0,
|
||||
status = BookedSlotStatus.BOOKED.name
|
||||
)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotsStatusChangedEvent
|
||||
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
class BookedSlotsKafkaListenerTest {
|
||||
|
||||
private val objectMapper = jacksonObjectMapper().findAndRegisterModules()
|
||||
private val bookedSlotStatusService = RecordingBookedSlotStatusUpdater()
|
||||
private val listener = BookedSlotsKafkaListener(SingleObjectProvider(objectMapper), bookedSlotStatusService)
|
||||
|
||||
@Test
|
||||
fun `consumer forwards booked slot status event to status service`() {
|
||||
val payload = objectMapper.writeValueAsString(
|
||||
KafkaMessage(
|
||||
type = PcpKafkaEvent.BookedSlotsStatusChangedEvent,
|
||||
data = BookedSlotsStatusChangedEvent(
|
||||
bookedSlotIds = listOf(10L, 20L),
|
||||
status = BookedSlotStatus.PROCESSED,
|
||||
occurredAt = LocalDateTime.of(2026, 3, 31, 12, 0),
|
||||
sourceService = "pcp-mission-planing-service",
|
||||
missionId = UUID.randomUUID(),
|
||||
modeId = 100L,
|
||||
satelliteId = 77L
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
listener.consume(payload)
|
||||
|
||||
assertEquals(listOf(10L, 20L), bookedSlotStatusService.lastBookedSlotIds)
|
||||
assertEquals(BookedSlotStatus.PROCESSED, bookedSlotStatusService.lastStatus)
|
||||
assertTrue(bookedSlotStatusService.lastContext.startsWith("kafka:pcp-mission-planing-service:"))
|
||||
}
|
||||
|
||||
private class RecordingBookedSlotStatusUpdater : BookedSlotStatusUpdater {
|
||||
var lastBookedSlotIds: List<Long> = emptyList()
|
||||
var lastStatus: BookedSlotStatus? = null
|
||||
var lastContext: String = ""
|
||||
|
||||
override fun updateBookedSlotsStatus(
|
||||
bookedSlotIds: List<Long>,
|
||||
targetStatus: BookedSlotStatus,
|
||||
context: String
|
||||
): Int {
|
||||
lastBookedSlotIds = bookedSlotIds
|
||||
lastStatus = targetStatus
|
||||
lastContext = context
|
||||
return bookedSlotIds.size
|
||||
}
|
||||
}
|
||||
|
||||
private class SingleObjectProvider<T : Any>(private val value: T) : ObjectProvider<T> {
|
||||
override fun getObject(vararg args: Any?): T = value
|
||||
override fun getIfAvailable(): T = value
|
||||
override fun getIfUnique(): T = value
|
||||
override fun getObject(): T = value
|
||||
}
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.reactive.function.client.ClientResponse
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.core.publisher.Mono
|
||||
import java.net.URI
|
||||
import java.util.UUID
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class EarthServiceTest {
|
||||
@Test
|
||||
fun `reqs calls v1 requests list endpoint`() {
|
||||
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
val service = earthService(
|
||||
requestedUris = requestedUris,
|
||||
responses = mutableListOf(
|
||||
requestsPageResponse(requestId),
|
||||
requestDetailsResponse(requestId),
|
||||
)
|
||||
)
|
||||
|
||||
service.reqs().collectList().block()
|
||||
|
||||
assertEquals("/v1/requests", requestedUris.first().path)
|
||||
assertEquals("page=0&size=500", requestedUris.first().query)
|
||||
assertFalse(requestedUris.any { uri -> uri.path == "/api/requests" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reqs unwraps list items and maps request details to current request dto`() {
|
||||
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||
val service = earthService(
|
||||
responses = mutableListOf(
|
||||
requestsPageResponse(requestId, importance = 3.0),
|
||||
requestDetailsResponse(
|
||||
id = requestId,
|
||||
importance = 7.5,
|
||||
geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val request = service.reqs().collectList().block()!!.single()
|
||||
|
||||
assertEquals(requestId, request.requestId)
|
||||
assertEquals("Full request", request.name)
|
||||
assertEquals(7.5, request.importance)
|
||||
assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", request.contour)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reqs reads all pages from v1 requests`() {
|
||||
val firstRequestId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
val secondRequestId = UUID.fromString("00000000-0000-0000-0000-000000000004")
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
val service = earthService(
|
||||
requestedUris = requestedUris,
|
||||
responses = mutableListOf(
|
||||
requestsPageResponse(firstRequestId, page = 0, totalPages = 2),
|
||||
requestsPageResponse(secondRequestId, page = 1, totalPages = 2),
|
||||
requestDetailsResponse(firstRequestId, geometry = "POLYGON EMPTY"),
|
||||
requestDetailsResponse(secondRequestId, geometry = "POLYGON EMPTY"),
|
||||
)
|
||||
)
|
||||
|
||||
val requests = service.reqs().collectList().block()!!
|
||||
|
||||
assertEquals(listOf(firstRequestId, secondRequestId), requests.map { request -> request.requestId })
|
||||
assertEquals(
|
||||
listOf("page=0&size=500", "page=1&size=500"),
|
||||
requestedUris.filter { uri -> uri.path == "/v1/requests" }.map { uri -> uri.query },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reqs loads full request details for contour`() {
|
||||
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000005")
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
val service = earthService(
|
||||
requestedUris = requestedUris,
|
||||
responses = mutableListOf(
|
||||
requestsPageResponse(requestId, name = "Summary request"),
|
||||
requestDetailsResponse(
|
||||
id = requestId,
|
||||
name = "Detailed request",
|
||||
geometry = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val request = service.reqs().collectList().block()!!.single()
|
||||
|
||||
assertEquals("/v1/requests/$requestId", requestedUris[1].path)
|
||||
assertEquals("Detailed request", request.name)
|
||||
assertEquals("POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))", request.contour)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reqcells calls v1 request with cells and maps request and cell contours`() {
|
||||
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000006")
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
val service = earthService(
|
||||
requestedUris = requestedUris,
|
||||
responses = mutableListOf(
|
||||
requestWithCellsResponse(requestId),
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.reqcells(requestId.toString())!!
|
||||
|
||||
assertEquals("/v1/requests/$requestId/with-cells", requestedUris.single().path)
|
||||
assertFalse(requestedUris.any { uri -> uri.path.contains("/api/requests/by-number") })
|
||||
assertEquals(requestId, response.request.requestId)
|
||||
assertEquals("Request with cells", response.request.name)
|
||||
assertEquals("POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))", response.request.contour)
|
||||
assertEquals(123, response.cells.single().id)
|
||||
assertEquals(123, response.cells.single().num)
|
||||
assertEquals(10.0, response.cells.single().importance)
|
||||
assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", response.cells.single().contour)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cells calls v1 cells with min importance and maps wrapper items`() {
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
val service = earthService(
|
||||
requestedUris = requestedUris,
|
||||
responses = mutableListOf(
|
||||
cellsPageResponse(),
|
||||
)
|
||||
)
|
||||
|
||||
val cells = service.cells(4.5).toList()
|
||||
|
||||
assertEquals("/v1/cells", requestedUris.single().path)
|
||||
assertEquals("minImportance=4.5&page=0&size=500", requestedUris.single().query)
|
||||
assertFalse(requestedUris.any { uri -> uri.path == "/api/earth-grid/by-importance" })
|
||||
assertFalse(requestedUris.any { uri -> uri.query?.contains("countLat") == true })
|
||||
assertFalse(requestedUris.any { uri -> uri.query?.contains("countLong") == true })
|
||||
assertEquals(1, cells.single().id)
|
||||
assertEquals(1, cells.single().num)
|
||||
assertEquals(55.5, cells.single().latitude)
|
||||
assertEquals(37.5, cells.single().longitude)
|
||||
assertEquals(4.5, cells.single().importance)
|
||||
assertEquals("POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))", cells.single().contour)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cells reads all pages from v1 cells`() {
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
val service = earthService(
|
||||
requestedUris = requestedUris,
|
||||
responses = mutableListOf(
|
||||
cellsPageResponse(cellNum = 1, page = 0, totalPages = 2),
|
||||
cellsPageResponse(cellNum = 2, page = 1, totalPages = 2),
|
||||
)
|
||||
)
|
||||
|
||||
val cells = service.cells(9.0).toList()
|
||||
|
||||
assertEquals(listOf(1L, 2L), cells.map { cell -> cell.num })
|
||||
assertEquals(
|
||||
listOf("minImportance=9.0&page=0&size=500", "minImportance=9.0&page=1&size=500"),
|
||||
requestedUris.filter { uri -> uri.path == "/v1/cells" }.map { uri -> uri.query },
|
||||
)
|
||||
}
|
||||
|
||||
private fun earthService(
|
||||
requestedUris: MutableList<URI> = mutableListOf(),
|
||||
responses: MutableList<ClientResponse>,
|
||||
): EarthService {
|
||||
val builder = WebClient.builder()
|
||||
.exchangeFunction { request ->
|
||||
requestedUris += request.url()
|
||||
Mono.just(responses.removeAt(0))
|
||||
}
|
||||
return EarthService(provider(builder), url = "http://pcp-request-service")
|
||||
}
|
||||
|
||||
private fun requestsPageResponse(
|
||||
id: UUID,
|
||||
page: Int = 0,
|
||||
totalPages: Int = 1,
|
||||
importance: Double = 1.0,
|
||||
name: String = "Summary request",
|
||||
): ClientResponse =
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "$id",
|
||||
"name": "$name",
|
||||
"status": "ACTIVE",
|
||||
"surveyType": "OPTICS",
|
||||
"importance": $importance,
|
||||
"beginDateTime": "2026-01-01T00:00:00Z",
|
||||
"endDateTime": "2026-01-31T00:00:00Z",
|
||||
"kpp": [1],
|
||||
"highPriorityTransmit": false,
|
||||
"coveragePercent": 0.0,
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"page": $page,
|
||||
"size": 500,
|
||||
"totalItems": $totalPages,
|
||||
"totalPages": $totalPages
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private fun requestDetailsResponse(
|
||||
id: UUID,
|
||||
name: String = "Full request",
|
||||
importance: Double = 1.0,
|
||||
geometry: String = "POLYGON EMPTY",
|
||||
): ClientResponse =
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"id": "$id",
|
||||
"name": "$name",
|
||||
"status": "ACTIVE",
|
||||
"surveyType": "OPTICS",
|
||||
"geometry": "$geometry",
|
||||
"importance": $importance,
|
||||
"beginDateTime": "2026-01-01T00:00:00Z",
|
||||
"endDateTime": "2026-01-31T00:00:00Z",
|
||||
"kpp": [1],
|
||||
"highPriorityTransmit": false,
|
||||
"coverage": {"currentPercent": 0.0},
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private fun requestWithCellsResponse(id: UUID): ClientResponse =
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"request": {
|
||||
"id": "$id",
|
||||
"name": "Request with cells",
|
||||
"status": "ACTIVE",
|
||||
"surveyType": "OPTICS",
|
||||
"geometry": "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
|
||||
"importance": 7.0,
|
||||
"beginDateTime": "2026-01-01T00:00:00Z",
|
||||
"endDateTime": "2026-01-31T00:00:00Z",
|
||||
"kpp": [1],
|
||||
"highPriorityTransmit": false,
|
||||
"coverage": {"currentPercent": 0.0},
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-01T00:00:00Z"
|
||||
},
|
||||
"cells": [
|
||||
{
|
||||
"cellNum": 123,
|
||||
"coveragePercent": 42.5,
|
||||
"importance": 10.0,
|
||||
"contour": "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private fun cellsPageResponse(
|
||||
cellNum: Long = 1,
|
||||
page: Int = 0,
|
||||
totalPages: Int = 1,
|
||||
importance: Double = 4.5,
|
||||
): ClientResponse =
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"cellNum": $cellNum,
|
||||
"latitude": 55.5,
|
||||
"longitude": 37.5,
|
||||
"importance": $importance,
|
||||
"contour": "POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))"
|
||||
}
|
||||
],
|
||||
"page": $page,
|
||||
"size": 500,
|
||||
"totalItems": $totalPages,
|
||||
"totalPages": $totalPages
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private fun jsonResponse(body: String): ClientResponse =
|
||||
ClientResponse.create(HttpStatus.OK)
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(body)
|
||||
.build()
|
||||
|
||||
private fun provider(builder: WebClient.Builder): ObjectProvider<WebClient.Builder> =
|
||||
object : ObjectProvider<WebClient.Builder> {
|
||||
override fun getObject(vararg args: Any?): WebClient.Builder = builder
|
||||
override fun getObject(): WebClient.Builder = builder
|
||||
override fun getIfAvailable(): WebClient.Builder = builder
|
||||
override fun getIfUnique(): WebClient.Builder = builder
|
||||
override fun iterator(): MutableIterator<WebClient.Builder> = mutableListOf(builder).iterator()
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
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.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class OccupiedIntervalPrefilterTest {
|
||||
|
||||
@Test
|
||||
fun `different roll blocks candidate inside occupied mmi window`() {
|
||||
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
|
||||
val candidate = candidateSlot(roll = 12.0, startOffsetSeconds = 44, endOffsetSeconds = 70)
|
||||
val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40))
|
||||
|
||||
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
|
||||
|
||||
assertTrue(filtered.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same roll continuation is allowed when merged duration fits duration max`() {
|
||||
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
|
||||
val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 44, endOffsetSeconds = 70)
|
||||
val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40))
|
||||
|
||||
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
|
||||
|
||||
assertEquals(listOf(candidate), filtered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same roll continuation is rejected when merged duration exceeds duration max`() {
|
||||
val satellite = testSatellite(maxSurveyDuration = 60, mmi = 5)
|
||||
val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 44, endOffsetSeconds = 70)
|
||||
val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40))
|
||||
|
||||
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
|
||||
|
||||
assertTrue(filtered.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occupied intervals with same roll normalize into one chain`() {
|
||||
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
|
||||
val normalized = OccupiedIntervalPrefilter.normalize(
|
||||
occupiedIntervals = listOf(
|
||||
occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40),
|
||||
occupiedInterval(roll = 10.0, startOffsetSeconds = 43, endOffsetSeconds = 60)
|
||||
),
|
||||
satellite = satellite
|
||||
)
|
||||
|
||||
assertEquals(1, normalized.size)
|
||||
assertEquals(baseTime().plusSeconds(0), normalized.single().startTime)
|
||||
assertEquals(baseTime().plusSeconds(60), normalized.single().endTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `candidate can bridge two same-roll occupied chains within duration max`() {
|
||||
val satellite = testSatellite(maxSurveyDuration = 90, mmi = 5)
|
||||
val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 24, endOffsetSeconds = 46)
|
||||
val occupied = OccupiedIntervalPrefilter.normalize(
|
||||
occupiedIntervals = listOf(
|
||||
occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 20),
|
||||
occupiedInterval(roll = 10.0, startOffsetSeconds = 50, endOffsetSeconds = 70)
|
||||
),
|
||||
satellite = satellite
|
||||
)
|
||||
|
||||
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
|
||||
|
||||
assertEquals(listOf(candidate), filtered)
|
||||
}
|
||||
|
||||
private fun testSatellite(maxSurveyDuration: Int, mmi: Long): AbstractSatellite =
|
||||
object : AbstractSatellite(id = 22, maxSurveyDuration = maxSurveyDuration, mmi = mmi) {
|
||||
override fun prepareCoverageSlots(
|
||||
slots: List<SlotEntity>,
|
||||
bookedSlots: List<BookedSlotEntity>,
|
||||
timeStart: LocalDateTime,
|
||||
timeStop: LocalDateTime,
|
||||
sign: RevolutionSign?,
|
||||
states: List<SlotStatus>?
|
||||
): List<PreparedCoverageSlot> = emptyList()
|
||||
}
|
||||
|
||||
private fun occupiedInterval(
|
||||
roll: Double,
|
||||
startOffsetSeconds: Long,
|
||||
endOffsetSeconds: Long
|
||||
) = OccupiedIntervalDTO(
|
||||
satelliteId = 22,
|
||||
startTime = baseTime().plusSeconds(startOffsetSeconds),
|
||||
endTime = baseTime().plusSeconds(endOffsetSeconds),
|
||||
roll = roll,
|
||||
source = "TEST"
|
||||
)
|
||||
|
||||
private fun candidateSlot(
|
||||
roll: Double,
|
||||
startOffsetSeconds: Long,
|
||||
endOffsetSeconds: Long
|
||||
) = SlotDTO(
|
||||
cycle = 0,
|
||||
satelliteId = 22,
|
||||
tn = baseTime().plusSeconds(startOffsetSeconds),
|
||||
tk = baseTime().plusSeconds(endOffsetSeconds),
|
||||
roll = roll,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
)
|
||||
|
||||
private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import space.nstart.pcp.slots_service.repository.SatelliteIcRepository
|
||||
import space.nstart.pcp.slots_service.repository.SlotRepository
|
||||
|
||||
class SatelliteDeletedServiceTest {
|
||||
|
||||
@Test
|
||||
fun `deleteSatelliteData removes slots and initial conditions`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
val satelliteIcRepository = mock(SatelliteIcRepository::class.java)
|
||||
val service = SatelliteDeletedService(slotRepository, bookedSlotsRepository, satelliteIcRepository)
|
||||
|
||||
`when`(bookedSlotsRepository.countBySlot_SatelliteId(56756L)).thenReturn(3L)
|
||||
`when`(slotRepository.countBySatelliteId(56756L)).thenReturn(10L)
|
||||
`when`(slotRepository.deleteBySatId(56756L)).thenReturn(10)
|
||||
`when`(satelliteIcRepository.deleteBySatelliteId(56756L)).thenReturn(1)
|
||||
|
||||
val summary = service.deleteSatelliteData(56756L)
|
||||
|
||||
assertEquals(56756L, summary.satelliteId)
|
||||
assertEquals(10, summary.slotsDeleted)
|
||||
assertEquals(3L, summary.bookedSlotsDeletedByCascade)
|
||||
assertEquals(1, summary.initialConditionsDeleted)
|
||||
verify(slotRepository).deleteBySatId(56756L)
|
||||
verify(satelliteIcRepository).deleteBySatelliteId(56756L)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.apache.kafka.clients.producer.ProducerRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.support.SendResult
|
||||
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 java.nio.charset.StandardCharsets
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
class SatelliteIcKafkaPublisherTest {
|
||||
|
||||
private val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, Any>
|
||||
private val objectMapper = jacksonObjectMapper().findAndRegisterModules()
|
||||
|
||||
@Test
|
||||
fun `publisher sends satellite initial conditions to kafka with ICRVPlacedEvent header`() {
|
||||
val publisher = SatelliteIcKafkaPublisher(
|
||||
kafkaTemplateProvider = SingleObjectProvider(kafkaTemplate),
|
||||
objectMapperProvider = SingleObjectProvider(objectMapper),
|
||||
topic = "pcp.tle",
|
||||
applicationName = "slots-service"
|
||||
)
|
||||
val recordCaptor = ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, Any>>
|
||||
`when`(kafkaTemplate.send(recordCaptor.capture())).thenReturn(CompletableFuture.completedFuture(mock(SendResult::class.java) as SendResult<String, Any>))
|
||||
|
||||
publisher.publish(
|
||||
SatelliteICDTO(
|
||||
satelliteId = 56756L,
|
||||
ic = InitialConditionsDTO(
|
||||
orbPoint = OrbPointDTO(
|
||||
time = LocalDateTime.of(2026, 4, 16, 12, 0),
|
||||
revolution = 42L,
|
||||
vx = 1.0,
|
||||
vy = 2.0,
|
||||
vz = 3.0,
|
||||
x = 4.0,
|
||||
y = 5.0,
|
||||
z = 6.0
|
||||
),
|
||||
sBall = 0.7,
|
||||
f81 = 140.0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
verify(kafkaTemplate).send(recordCaptor.value)
|
||||
assertEquals("pcp.tle", recordCaptor.value.topic())
|
||||
assertEquals("56756", recordCaptor.value.key())
|
||||
val typeHeader = recordCaptor.value.headers().lastHeader("type")
|
||||
assertNotNull(typeHeader)
|
||||
assertEquals("ICRVPlacedEvent", String(typeHeader.value(), StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
private class SingleObjectProvider<T : Any>(private val value: T) : ObjectProvider<T> {
|
||||
override fun getObject(vararg args: Any?): T = value
|
||||
override fun getIfAvailable(): T = value
|
||||
override fun getIfUnique(): T = value
|
||||
override fun getObject(): T = value
|
||||
}
|
||||
}
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import ballistics.types.InitialConditions
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyList
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO
|
||||
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.SlotCoverageTargetDTO
|
||||
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.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
import space.nstart.pcp.slots_service.repository.BatchCoverageSearchRequest
|
||||
import space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow
|
||||
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import space.nstart.pcp.slots_service.repository.SlotRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import javax.sql.DataSource
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SlotServiceBatchTest {
|
||||
|
||||
private fun <T> anyListValue(): List<T> = anyList<T>() ?: emptyList()
|
||||
private fun anyLongValue(): Long = anyLong()
|
||||
private fun anyIntValue(): Int = anyInt()
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch uses single batch spatial fetch and keeps input target order`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
|
||||
val service = createService(
|
||||
slotRepository = slotRepository,
|
||||
bookedSlotsRepository = bookedSlotsRepository,
|
||||
catalogSatellites = listOf(defaultSatellite(22L))
|
||||
)
|
||||
|
||||
`when`(
|
||||
slotRepository.findTargetSlotRows(
|
||||
anyListValue(),
|
||||
anyIntValue()
|
||||
)
|
||||
).thenReturn(
|
||||
listOf(
|
||||
row(targetId = 2L, slotId = 1002L, satelliteId = 22L),
|
||||
row(targetId = 1L, slotId = 1001L, satelliteId = 22L)
|
||||
)
|
||||
)
|
||||
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())).thenReturn(emptyList())
|
||||
|
||||
val response = service.polySlotsBatch(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
items = listOf(
|
||||
SlotCoverageTargetDTO(1, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
|
||||
SlotCoverageTargetDTO(2, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")
|
||||
),
|
||||
timeStart = java.time.LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
timeStop = java.time.LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
cov = false,
|
||||
satellites = listOf(22L)
|
||||
)
|
||||
)
|
||||
|
||||
verify(bookedSlotsRepository, times(1))
|
||||
.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())
|
||||
verify(slotRepository, times(1))
|
||||
.findTargetSlotRows(anyListValue(), anyIntValue())
|
||||
kotlin.test.assertEquals(2, response.size)
|
||||
kotlin.test.assertEquals(listOf(1L, 2L), response.map { it.targetId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch handles large spatial batch without secondary slot id fetch`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
|
||||
val service = createService(
|
||||
slotRepository = slotRepository,
|
||||
bookedSlotsRepository = bookedSlotsRepository,
|
||||
catalogSatellites = listOf(defaultSatellite(22L))
|
||||
)
|
||||
|
||||
val rows = (1L..70_000L).map { slotId ->
|
||||
row(targetId = 1L, slotId = slotId, satelliteId = 22L)
|
||||
}
|
||||
|
||||
`when`(
|
||||
slotRepository.findTargetSlotRows(
|
||||
anyListValue(),
|
||||
anyIntValue()
|
||||
)
|
||||
).thenReturn(rows)
|
||||
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
val response = service.polySlotsBatch(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
|
||||
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
cov = false,
|
||||
satellites = listOf(22L)
|
||||
)
|
||||
)
|
||||
|
||||
kotlin.test.assertEquals(1, response.size)
|
||||
verify(slotRepository, times(1))
|
||||
.findTargetSlotRows(anyListValue(), anyIntValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch precomputes prepared slots once per satellite for whole batch`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
|
||||
val satellite = CountingSatellite(id = 22L)
|
||||
val service = createService(
|
||||
slotRepository = slotRepository,
|
||||
bookedSlotsRepository = bookedSlotsRepository,
|
||||
catalogSatellites = listOf(satellite)
|
||||
)
|
||||
|
||||
`when`(
|
||||
slotRepository.findTargetSlotRows(
|
||||
anyListValue(),
|
||||
anyIntValue()
|
||||
)
|
||||
).thenReturn(
|
||||
listOf(
|
||||
row(targetId = 1L, slotId = 1001L, satelliteId = 22L),
|
||||
row(targetId = 2L, slotId = 1001L, satelliteId = 22L)
|
||||
)
|
||||
)
|
||||
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
val response = service.polySlotsBatch(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
items = listOf(
|
||||
SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
|
||||
SlotCoverageTargetDTO(2L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")
|
||||
),
|
||||
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
cov = false,
|
||||
satellites = listOf(22L)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, satellite.prepareCoverageSlotsCalls)
|
||||
assertEquals(2, response.size)
|
||||
assertEquals(listOf(1, 1), response.map { it.slots.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch pushes satellite and base time windows into spatial fetch request`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
|
||||
val satellite = defaultSatellite(
|
||||
id = 62138L,
|
||||
tnCalc = LocalDateTime.of(LocalDate.of(2025, 10, 27), LocalTime.of(0, 29, 23, 162630000)),
|
||||
cycleRevs = 243,
|
||||
durationCalc = 16,
|
||||
maxChainLength = 3
|
||||
)
|
||||
val service = createService(
|
||||
slotRepository = slotRepository,
|
||||
bookedSlotsRepository = bookedSlotsRepository,
|
||||
catalogSatellites = listOf(satellite)
|
||||
)
|
||||
val baseStart = satellite.tnCalc.plusHours(2)
|
||||
val requestTimeStart = baseStart.minusMinutes(1)
|
||||
val requestTimeStop = baseStart.plusMinutes(11)
|
||||
val capturedRequests = mutableListOf<BatchCoverageSearchRequest>()
|
||||
|
||||
`when`(
|
||||
slotRepository.findTargetSlotRows(
|
||||
anyListValue(),
|
||||
anyIntValue()
|
||||
)
|
||||
).thenAnswer { invocation ->
|
||||
capturedRequests += invocation.getArgument<List<BatchCoverageSearchRequest>>(0)
|
||||
listOf(row(targetId = 1L, slotId = 1001L, satelliteId = satellite.id))
|
||||
}
|
||||
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
service.polySlotsBatch(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
|
||||
timeStart = requestTimeStart,
|
||||
timeStop = requestTimeStop,
|
||||
cov = false,
|
||||
satellites = listOf(satellite.id)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(3, capturedRequests.size)
|
||||
assertEquals(setOf(satellite.id), capturedRequests.map { it.satelliteId }.toSet())
|
||||
assertEquals(setOf(1L), capturedRequests.map { it.targetId }.toSet())
|
||||
assertEquals(
|
||||
setOf(
|
||||
requestTimeStart to requestTimeStop
|
||||
),
|
||||
capturedRequests.map { it.windowStart to it.windowStop }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch applies continuous coverage strategy`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
val service = createService(
|
||||
slotRepository = slotRepository,
|
||||
bookedSlotsRepository = bookedSlotsRepository,
|
||||
catalogSatellites = listOf(CountingSatellite(22L))
|
||||
)
|
||||
|
||||
stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository)
|
||||
|
||||
val response = service.polySlotsBatch(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
items = listOf(SlotCoverageTargetDTO(1L, TARGET_WKT)),
|
||||
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
cov = true,
|
||||
satellites = listOf(22L),
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1L, 3L), response.single().slots.map { it.slotNumber })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlots applies continuous coverage strategy in single target path`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
val service = createService(
|
||||
slotRepository = slotRepository,
|
||||
bookedSlotsRepository = bookedSlotsRepository,
|
||||
catalogSatellites = listOf(CountingSatellite(22L))
|
||||
)
|
||||
|
||||
stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository)
|
||||
|
||||
val response = service.polySlots(
|
||||
wkt = TARGET_WKT,
|
||||
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
sign = null,
|
||||
cov = true,
|
||||
sats = listOf(22L),
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
).toList()
|
||||
|
||||
assertEquals(listOf(1L, 3L), response.map { it.slotNumber })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reqCover applies continuous coverage strategy`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
val earthService = mock(EarthService::class.java)
|
||||
val service = createService(
|
||||
slotRepository = slotRepository,
|
||||
bookedSlotsRepository = bookedSlotsRepository,
|
||||
catalogSatellites = listOf(CountingSatellite(22L)),
|
||||
earthService = earthService
|
||||
)
|
||||
|
||||
stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository)
|
||||
doReturn(RequestWithCellsDTO(request = RequestDTO(contour = TARGET_WKT)))
|
||||
.`when`(earthService).reqcells("req-1")
|
||||
|
||||
val response = service.reqCover(
|
||||
id = "req-1",
|
||||
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
sign = null,
|
||||
cov = true,
|
||||
sats = listOf(22L),
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
).toList()
|
||||
|
||||
assertEquals(listOf(1L, 3L), response.map { it.slotNumber })
|
||||
}
|
||||
|
||||
private fun createService(
|
||||
slotRepository: SlotRepository,
|
||||
bookedSlotsRepository: BookedSlotsRepository,
|
||||
catalogSatellites: List<AbstractSatellite>,
|
||||
earthService: EarthService = mock(EarthService::class.java)
|
||||
): SlotService {
|
||||
val service = SlotService()
|
||||
ReflectionTestUtils.setField(service, "slotRepository", slotRepository)
|
||||
ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository)
|
||||
ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java))
|
||||
ReflectionTestUtils.setField(service, "earthService", earthService)
|
||||
ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java))
|
||||
ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(catalogSatellites))
|
||||
return service
|
||||
}
|
||||
|
||||
private fun testSatelliteCatalogClient(satellites: List<AbstractSatellite>) = object : SatelliteCatalogClient {
|
||||
override fun allSatellites(): List<AbstractSatellite> = satellites
|
||||
|
||||
override fun satellites(ids: List<Long>): List<AbstractSatellite> =
|
||||
ids.distinct().map { satelliteId ->
|
||||
satellites.find { it.id == satelliteId }
|
||||
?: throw CustomValidationException("КА $satelliteId не зарегистрирован")
|
||||
}
|
||||
|
||||
override fun satellite(id: Long): AbstractSatellite =
|
||||
satellites.find { it.id == id }
|
||||
?: throw CustomValidationException("КА $id не зарегистрирован")
|
||||
|
||||
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request
|
||||
}
|
||||
|
||||
private fun defaultSatellite(
|
||||
id: Long,
|
||||
tnCalc: LocalDateTime = LocalDateTime.of(2026, 3, 20, 7, 20),
|
||||
cycleRevs: Long = 275,
|
||||
durationCalc: Long = 18,
|
||||
maxChainLength: Int = 3
|
||||
) = TestSatelliteImpl(
|
||||
id = id,
|
||||
cycleRevs = cycleRevs,
|
||||
tnCalc = tnCalc,
|
||||
durationCalc = durationCalc,
|
||||
maxChainLength = maxChainLength
|
||||
)
|
||||
|
||||
private fun stubRowsForContinuousCoverage(
|
||||
slotRepository: SlotRepository,
|
||||
bookedSlotsRepository: BookedSlotsRepository
|
||||
) {
|
||||
`when`(
|
||||
slotRepository.findTargetSlotRows(
|
||||
anyListValue(),
|
||||
anyIntValue()
|
||||
)
|
||||
).thenReturn(
|
||||
listOf(
|
||||
row(
|
||||
targetId = 1L,
|
||||
slotId = 1L,
|
||||
satelliteId = 22L,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
longitude = 0.0,
|
||||
tn = LocalDateTime.of(2026, 3, 26, 10, 0, 0),
|
||||
tk = LocalDateTime.of(2026, 3, 26, 10, 0, 10)
|
||||
),
|
||||
row(
|
||||
targetId = 1L,
|
||||
slotId = 2L,
|
||||
satelliteId = 22L,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))",
|
||||
longitude = 0.0,
|
||||
tn = LocalDateTime.of(2026, 3, 26, 10, 0, 20),
|
||||
tk = LocalDateTime.of(2026, 3, 26, 10, 0, 30)
|
||||
),
|
||||
row(
|
||||
targetId = 1L,
|
||||
slotId = 3L,
|
||||
satelliteId = 22L,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))",
|
||||
longitude = 1.0,
|
||||
tn = LocalDateTime.of(2026, 3, 26, 10, 0, 40),
|
||||
tk = LocalDateTime.of(2026, 3, 26, 10, 0, 50)
|
||||
)
|
||||
)
|
||||
)
|
||||
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
}
|
||||
|
||||
private fun row(
|
||||
targetId: Long,
|
||||
slotId: Long,
|
||||
satelliteId: Long,
|
||||
contour: String = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
longitude: Double = 37.0,
|
||||
roll: Double = 10.0,
|
||||
revolutionSign: String = "ASC",
|
||||
tn: LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
tk: LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 10)
|
||||
) = BatchCoverageSlotRow(
|
||||
targetId = targetId,
|
||||
slotId = slotId,
|
||||
slotNum = slotId,
|
||||
cycle = 0L,
|
||||
satelliteId = satelliteId,
|
||||
coveringType = 0,
|
||||
tn = tn,
|
||||
tk = tk,
|
||||
roll = roll,
|
||||
contour = contour,
|
||||
revolution = 1L,
|
||||
revolutionSign = revolutionSign,
|
||||
latitude = 55.0,
|
||||
longitude = longitude
|
||||
)
|
||||
|
||||
private class CountingSatellite(id: Long) : AbstractSatellite(
|
||||
id = id,
|
||||
cycleRevs = 243,
|
||||
tnCalc = LocalDateTime.of(LocalDate.of(2025, 10, 27), LocalTime.of(0, 29, 23, 162630000)),
|
||||
durationCalc = 16,
|
||||
maxChainLength = 3,
|
||||
angles = emptyList(),
|
||||
ic = InitialConditions()
|
||||
) {
|
||||
var prepareCoverageSlotsCalls: Int = 0
|
||||
|
||||
override fun prepareCoverageSlots(
|
||||
slots: List<SlotEntity>,
|
||||
bookedSlots: List<BookedSlotEntity>,
|
||||
timeStart: LocalDateTime,
|
||||
timeStop: LocalDateTime,
|
||||
sign: RevolutionSign?,
|
||||
states: List<SlotStatus>?
|
||||
): List<PreparedCoverageSlot> {
|
||||
prepareCoverageSlotsCalls++
|
||||
return slots.map { slot ->
|
||||
PreparedCoverageSlot(
|
||||
sourceSlotId = slot.slotId ?: 0L,
|
||||
slot = SlotDTO(
|
||||
cycle = slot.cycle,
|
||||
satelliteId = slot.satelliteId,
|
||||
tn = slot.tn,
|
||||
tk = slot.tk,
|
||||
roll = slot.roll,
|
||||
contour = slot.contour,
|
||||
revolution = slot.revolution,
|
||||
revolutionSign = RevolutionSign.valueOf(slot.revolutionSign),
|
||||
slotNumber = slot.slotNum,
|
||||
state = SlotStatus.AVAILABLE,
|
||||
latitude = slot.latitude,
|
||||
longitude = slot.longitude
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TARGET_WKT = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))"
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import ballistics.orbitalPoints.timeStepper.AbstractStepper
|
||||
import ballistics.types.OrbitalPoint
|
||||
import ballistics.utils.math.Vector3D
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import space.nstart.pcp.slots_service.model.Mar
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SlotServiceCalcSlotsTest {
|
||||
|
||||
@Test
|
||||
fun `buildSlotCalculationChunks creates aligned half-open windows without gaps`() {
|
||||
val service = SlotService()
|
||||
ReflectionTestUtils.setField(service, "slotCalcChunkDurationSeconds", 20L)
|
||||
ReflectionTestUtils.setField(service, "slotCalcSourceMarginSeconds", 120L)
|
||||
|
||||
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
|
||||
val endExclusive = start.plusSeconds(60)
|
||||
|
||||
val chunks = ReflectionTestUtils.invokeMethod<List<Any>>(
|
||||
service,
|
||||
"buildSlotCalculationChunks",
|
||||
start,
|
||||
endExclusive,
|
||||
10L,
|
||||
60L
|
||||
).orEmpty()
|
||||
|
||||
assertEquals(3, chunks.size)
|
||||
assertEquals(start, ReflectionTestUtils.getField(chunks[0], "generationStart"))
|
||||
assertEquals(start.plusSeconds(20), ReflectionTestUtils.getField(chunks[0], "generationEndExclusive"))
|
||||
assertEquals(start.plusSeconds(20), ReflectionTestUtils.getField(chunks[1], "generationStart"))
|
||||
assertEquals(start.plusSeconds(40), ReflectionTestUtils.getField(chunks[1], "generationEndExclusive"))
|
||||
assertEquals(start.plusSeconds(40), ReflectionTestUtils.getField(chunks[2], "generationStart"))
|
||||
assertEquals(endExclusive, ReflectionTestUtils.getField(chunks[2], "generationEndExclusive"))
|
||||
assertEquals(5L, ReflectionTestUtils.getField(chunks[0], "generationStepSeconds"))
|
||||
|
||||
val sourceWindowStartSeconds = ReflectionTestUtils.getField(chunks[0], "sourceWindowStartSeconds") as Double
|
||||
val sourceWindowEndSeconds = ReflectionTestUtils.getField(chunks[0], "sourceWindowEndSeconds") as Double
|
||||
assertTrue(sourceWindowStartSeconds < ballistics.utils.fromDateTime(start))
|
||||
assertTrue(sourceWindowEndSeconds > ballistics.utils.fromDateTime(start.plusSeconds(30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drainReadyChunkResults preserves deterministic chunk order`() {
|
||||
val service = SlotService()
|
||||
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
|
||||
val pending = linkedMapOf(
|
||||
2 to pendingChunk(2, start.plusSeconds(40), start.plusSeconds(60)),
|
||||
0 to pendingChunk(0, start, start.plusSeconds(20)),
|
||||
1 to pendingChunk(1, start.plusSeconds(20), start.plusSeconds(40))
|
||||
)
|
||||
|
||||
val drained = ReflectionTestUtils.invokeMethod<Pair<List<Any>, Int>>(
|
||||
service,
|
||||
"drainReadyChunkResults",
|
||||
pending,
|
||||
0
|
||||
)!!
|
||||
|
||||
assertEquals(
|
||||
listOf(0, 1, 2),
|
||||
drained.first.map { ReflectionTestUtils.getField(ReflectionTestUtils.getField(it, "completion")!!, "index") }
|
||||
)
|
||||
assertEquals(3, drained.second)
|
||||
assertTrue(pending.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepared orbital window is reused across multiple contours`() {
|
||||
val service = SlotService()
|
||||
val stepper = CountingStepper()
|
||||
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
|
||||
val endInclusive = start.plusSeconds(10)
|
||||
|
||||
val window = prepareOrbitalWindow(service, stepper, start, endInclusive)
|
||||
|
||||
val sampledPoints = ReflectionTestUtils.getField(window, "points") as List<*>
|
||||
assertEquals(3, sampledPoints.size)
|
||||
assertEquals(3, stepper.requestedTimes.size)
|
||||
|
||||
val firstContour = contourFromPreparedWindow(service, window, 20.0, 2.0)
|
||||
val secondContour = contourFromPreparedWindow(service, window, 24.0, 2.0)
|
||||
|
||||
assertTrue(firstContour.isNotBlank())
|
||||
assertTrue(secondContour.isNotBlank())
|
||||
assertEquals(3, stepper.requestedTimes.size)
|
||||
assertEquals(
|
||||
listOf(5.0, 5.0),
|
||||
stepper.requestedTimes.zipWithNext { left, right -> right - left }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareOrbitalWindow reuses initial tn point when provided`() {
|
||||
val service = SlotService()
|
||||
val stepper = CountingStepper()
|
||||
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
|
||||
val endInclusive = start.plusSeconds(10)
|
||||
|
||||
val initialPoint = stepper.calculate(ballistics.utils.fromDateTime(start))
|
||||
val callsAfterInitialPoint = stepper.requestedTimes.size
|
||||
|
||||
val window = prepareOrbitalWindow(service, stepper, start, endInclusive, initialPoint)
|
||||
|
||||
val sampledPoints = ReflectionTestUtils.getField(window, "points") as List<*>
|
||||
assertEquals(3, sampledPoints.size)
|
||||
assertEquals(callsAfterInitialPoint + 2, stepper.requestedTimes.size)
|
||||
}
|
||||
|
||||
private fun pendingChunk(
|
||||
index: Int,
|
||||
start: LocalDateTime,
|
||||
endExclusive: LocalDateTime
|
||||
): Any {
|
||||
val completionClass = Class.forName(
|
||||
"space.nstart.pcp.slots_service.service.SlotService\$SlotCalculationChunkComplete"
|
||||
)
|
||||
val completionConstructor = completionClass.declaredConstructors.single { it.parameterCount == 11 }
|
||||
completionConstructor.isAccessible = true
|
||||
val completion = completionConstructor.newInstance(
|
||||
index,
|
||||
10L,
|
||||
start,
|
||||
endExclusive,
|
||||
1,
|
||||
1,
|
||||
5L,
|
||||
2L,
|
||||
3L,
|
||||
1,
|
||||
2
|
||||
)
|
||||
|
||||
val pendingChunkClass = Class.forName(
|
||||
"space.nstart.pcp.slots_service.service.SlotService\$SlotCalculationPendingChunk"
|
||||
)
|
||||
val pendingChunkConstructor = pendingChunkClass.declaredConstructors.single { it.parameterCount == 2 }
|
||||
pendingChunkConstructor.isAccessible = true
|
||||
return pendingChunkConstructor.newInstance(
|
||||
mutableListOf(listOf(Mar(sat = 1L, tn = start, tk = endExclusive))),
|
||||
completion
|
||||
)
|
||||
}
|
||||
|
||||
private fun prepareOrbitalWindow(
|
||||
service: SlotService,
|
||||
stepper: CountingStepper,
|
||||
start: LocalDateTime,
|
||||
endInclusive: LocalDateTime,
|
||||
initialPoint: OrbitalPoint? = null
|
||||
): Any {
|
||||
val method = SlotService::class.java.getDeclaredMethod(
|
||||
"prepareOrbitalWindow",
|
||||
AbstractStepper::class.java,
|
||||
LocalDateTime::class.java,
|
||||
LocalDateTime::class.java,
|
||||
OrbitalPoint::class.java
|
||||
)
|
||||
method.isAccessible = true
|
||||
return method.invoke(service, stepper, start, endInclusive, initialPoint)!!
|
||||
}
|
||||
|
||||
private fun contourFromPreparedWindow(
|
||||
service: SlotService,
|
||||
window: Any,
|
||||
roll: Double,
|
||||
capture: Double
|
||||
): String {
|
||||
val preparedWindowClass = Class.forName(
|
||||
"space.nstart.pcp.slots_service.service.SlotService\$PreparedOrbitalWindow"
|
||||
)
|
||||
val method = SlotService::class.java.getDeclaredMethod(
|
||||
"contour",
|
||||
preparedWindowClass,
|
||||
java.lang.Double.TYPE,
|
||||
java.lang.Double.TYPE
|
||||
)
|
||||
method.isAccessible = true
|
||||
return method.invoke(service, window, roll, capture) as String
|
||||
}
|
||||
|
||||
private class CountingStepper : AbstractStepper {
|
||||
val requestedTimes = mutableListOf<Double>()
|
||||
|
||||
override fun calculate(t: Double): OrbitalPoint {
|
||||
requestedTimes += t
|
||||
val phase = t / 600.0
|
||||
return OrbitalPoint(
|
||||
t,
|
||||
42,
|
||||
Vector3D(7_000_000.0 * cos(phase), 7_000_000.0 * sin(phase), 1_000_000.0),
|
||||
Vector3D(-7_500.0 * sin(phase), 7_500.0 * cos(phase), 10.0)
|
||||
)
|
||||
}
|
||||
|
||||
override fun calculate(t: Double, prev: OrbitalPoint): OrbitalPoint = calculate(t)
|
||||
|
||||
override fun clear() = Unit
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import ch.qos.logback.classic.Logger
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent
|
||||
import ch.qos.logback.core.read.ListAppender
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
|
||||
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
|
||||
class SlotServiceChainAggregationTest {
|
||||
|
||||
private val service = SlotService()
|
||||
private val method = service.javaClass.getDeclaredMethod(
|
||||
"buildSurveyChains",
|
||||
Class.forName("space.nstart.pcp.slots_service.model.satellite.AbstractSatellite"),
|
||||
List::class.java,
|
||||
LocalDateTime::class.java,
|
||||
LocalDateTime::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
@Test
|
||||
fun `buildSurveyChains merges when time overlaps and geometry intersects`() {
|
||||
val satellite = satellite()
|
||||
val chains = buildSurveyChains(
|
||||
satellite,
|
||||
listOf(
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 100,
|
||||
slotNumber = 1,
|
||||
start = baseTime(),
|
||||
end = baseTime().plusSeconds(30),
|
||||
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
|
||||
),
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 101,
|
||||
slotNumber = 2,
|
||||
start = baseTime().plusSeconds(20),
|
||||
end = baseTime().plusSeconds(60),
|
||||
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, chains.size)
|
||||
assertTrue(chains.single().contour.startsWith("POLYGON"))
|
||||
assertFalse(chains.single().contour.startsWith("MULTIPOLYGON"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildSurveyChains keeps routes separate when time overlaps but geometry does not intersect`() {
|
||||
val satellite = satellite()
|
||||
val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger
|
||||
val logs = captureLogs(logger) {
|
||||
val chains = buildSurveyChains(
|
||||
satellite,
|
||||
listOf(
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 100,
|
||||
slotNumber = 1,
|
||||
start = baseTime(),
|
||||
end = baseTime().plusSeconds(30),
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 101,
|
||||
slotNumber = 2,
|
||||
start = baseTime().plusSeconds(20),
|
||||
end = baseTime().plusSeconds(60),
|
||||
contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, chains.size)
|
||||
}
|
||||
|
||||
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildSurveyChains keeps routes separate when contours only touch by boundary`() {
|
||||
val satellite = satellite()
|
||||
val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger
|
||||
val logs = captureLogs(logger) {
|
||||
val chains = buildSurveyChains(
|
||||
satellite,
|
||||
listOf(
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 100,
|
||||
slotNumber = 1,
|
||||
start = baseTime(),
|
||||
end = baseTime().plusSeconds(30),
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 101,
|
||||
slotNumber = 2,
|
||||
start = baseTime().plusSeconds(20),
|
||||
end = baseTime().plusSeconds(60),
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, chains.size)
|
||||
}
|
||||
|
||||
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildSurveyChains merges adjacent routes when contours are continuous`() {
|
||||
val satellite = satellite()
|
||||
val chains = buildSurveyChains(
|
||||
satellite,
|
||||
listOf(
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 100,
|
||||
slotNumber = 1,
|
||||
start = baseTime(),
|
||||
end = baseTime().plusSeconds(30),
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
),
|
||||
bookedSlot(
|
||||
satelliteId = satellite.id,
|
||||
bookedSlotId = 101,
|
||||
slotNumber = 2,
|
||||
start = baseTime().plusSeconds(30),
|
||||
end = baseTime().plusSeconds(60),
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, chains.size)
|
||||
assertEquals(baseTime(), chains.single().tn)
|
||||
assertEquals(baseTime().plusSeconds(60), chains.single().tk)
|
||||
assertEquals(listOf(100L, 101L), chains.single().slotIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildSurveyChains keeps routes separate when union returns multipolygon`() {
|
||||
val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger
|
||||
val logs = captureLogs(logger) {
|
||||
val method = service.javaClass.getDeclaredMethod(
|
||||
"unionContoursIfPolygon",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
Long::class.javaPrimitiveType,
|
||||
LocalDateTime::class.java,
|
||||
LocalDateTime::class.java,
|
||||
Double::class.javaPrimitiveType,
|
||||
List::class.java,
|
||||
Class.forName("space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO"),
|
||||
Long::class.javaPrimitiveType
|
||||
)
|
||||
method.isAccessible = true
|
||||
|
||||
val result = method.invoke(
|
||||
service,
|
||||
"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
"POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
|
||||
22L,
|
||||
baseTime(),
|
||||
baseTime().plusSeconds(30),
|
||||
5.0,
|
||||
listOf(100L),
|
||||
bookedSlot(
|
||||
satelliteId = 22,
|
||||
bookedSlotId = 101,
|
||||
slotNumber = 2,
|
||||
start = baseTime().plusSeconds(20),
|
||||
end = baseTime().plusSeconds(60),
|
||||
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
|
||||
).slot.toDTO(),
|
||||
101L
|
||||
)
|
||||
|
||||
assertEquals(null, result)
|
||||
}
|
||||
|
||||
assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") })
|
||||
}
|
||||
|
||||
private fun buildSurveyChains(
|
||||
satellite: TestSatelliteImpl,
|
||||
bookedSlots: List<BookedSlotEntity>
|
||||
): List<SurveySlotDTO> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return method.invoke(
|
||||
service,
|
||||
satellite,
|
||||
bookedSlots,
|
||||
baseTime().minusMinutes(5),
|
||||
baseTime().plusMinutes(10)
|
||||
) as List<SurveySlotDTO>
|
||||
}
|
||||
|
||||
private fun satellite() = TestSatelliteImpl(
|
||||
id = 22,
|
||||
tnCalc = LocalDateTime.of(LocalDate.of(2026, 3, 31), LocalTime.of(0, 0)),
|
||||
durationCalc = 16
|
||||
)
|
||||
|
||||
private fun bookedSlot(
|
||||
satelliteId: Long,
|
||||
bookedSlotId: Long,
|
||||
slotNumber: Long,
|
||||
start: LocalDateTime,
|
||||
end: LocalDateTime,
|
||||
contour: String
|
||||
) = BookedSlotEntity(
|
||||
bookedSlotId = bookedSlotId,
|
||||
slot = SlotEntity(
|
||||
slotId = bookedSlotId,
|
||||
slotNum = slotNumber,
|
||||
cycle = 0,
|
||||
satelliteId = satelliteId,
|
||||
coveringType = 0,
|
||||
tn = start,
|
||||
tk = end,
|
||||
roll = 5.0,
|
||||
contour = contour,
|
||||
revolution = 101,
|
||||
revolutionSign = RevolutionSign.ASC.toString(),
|
||||
latitude = 55.75,
|
||||
longitude = 37.62
|
||||
),
|
||||
cycle = 0,
|
||||
status = BookedSlotStatus.BOOKED.name
|
||||
)
|
||||
|
||||
private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 31, 10, 0)
|
||||
|
||||
private fun captureLogs(logger: Logger, block: () -> Unit): List<ILoggingEvent> {
|
||||
val appender = ListAppender<ILoggingEvent>()
|
||||
appender.start()
|
||||
logger.addAppender(appender)
|
||||
try {
|
||||
block()
|
||||
return appender.list.toList()
|
||||
} finally {
|
||||
logger.detachAppender(appender)
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import space.nstart.pcp.slots_service.repository.SlotRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import javax.sql.DataSource
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class SlotServiceIntervalTest {
|
||||
|
||||
private fun anyDateTimeValue(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN
|
||||
private fun eqLongValue(value: Long): Long = eq(value) ?: value
|
||||
private fun eqDateTimeValue(value: LocalDateTime): LocalDateTime = eq(value) ?: value
|
||||
|
||||
private data class TestSlotCalculationSummaryProjection(
|
||||
override val satelliteId: Long,
|
||||
override val slotCount: Long,
|
||||
override val minTime: LocalDateTime?,
|
||||
override val maxTime: LocalDateTime?,
|
||||
override val minRevolution: Long?,
|
||||
override val maxRevolution: Long?,
|
||||
) : SlotRepository.SlotCalculationSummaryProjection
|
||||
|
||||
@Test
|
||||
fun `allByInterval shifts base slots by closure cycle`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val satellite = TestSatelliteImpl(
|
||||
id = 22L,
|
||||
cycleRevs = 243L,
|
||||
tnCalc = LocalDateTime.of(LocalDate.of(2026, 3, 31), LocalTime.of(0, 0)),
|
||||
durationCalc = 16
|
||||
)
|
||||
val service = slotServiceFixture(slotRepository, satellite)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 4, 16, 9, 59)
|
||||
val intervalStop = LocalDateTime.of(2026, 4, 16, 10, 11)
|
||||
val baseWindowStart = intervalStart.minusDays(satellite.durationCalc)
|
||||
val baseWindowStop = intervalStop.minusDays(satellite.durationCalc)
|
||||
val baseSlot = SlotEntity(
|
||||
slotId = 100L,
|
||||
slotNum = 7L,
|
||||
cycle = 0L,
|
||||
satelliteId = satellite.id,
|
||||
coveringType = 0,
|
||||
tn = LocalDateTime.of(2026, 3, 31, 10, 0),
|
||||
tk = LocalDateTime.of(2026, 3, 31, 10, 10),
|
||||
roll = 5.0,
|
||||
contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))",
|
||||
revolution = 11L,
|
||||
revolutionSign = RevolutionSign.ASC.toString(),
|
||||
latitude = 55.75,
|
||||
longitude = 37.62
|
||||
)
|
||||
|
||||
`when`(
|
||||
slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
|
||||
eqLongValue(satellite.id),
|
||||
eqDateTimeValue(baseWindowStart),
|
||||
eqDateTimeValue(baseWindowStop)
|
||||
)
|
||||
).thenReturn(listOf(baseSlot))
|
||||
`when`(
|
||||
slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
|
||||
eqLongValue(satellite.id),
|
||||
anyDateTimeValue(),
|
||||
anyDateTimeValue()
|
||||
)
|
||||
).thenReturn(emptyList())
|
||||
`when`(
|
||||
slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
|
||||
eqLongValue(satellite.id),
|
||||
eqDateTimeValue(baseWindowStart),
|
||||
eqDateTimeValue(baseWindowStop)
|
||||
)
|
||||
).thenReturn(listOf(baseSlot))
|
||||
|
||||
val actual = service.allByInterval(satellite.id, intervalStart, intervalStop)
|
||||
|
||||
assertEquals(1, actual.size)
|
||||
assertEquals(1L, actual.first().cycle)
|
||||
assertEquals(LocalDateTime.of(2026, 4, 16, 10, 0), actual.first().tn)
|
||||
assertEquals(LocalDateTime.of(2026, 4, 16, 10, 10), actual.first().tk)
|
||||
assertEquals(11L + satellite.cycleRevs, actual.first().revolution)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `allByInterval rejects reversed interval`() {
|
||||
val service = slotServiceFixture(mock(SlotRepository::class.java), TestSatelliteImpl(id = 22L))
|
||||
|
||||
assertFailsWith<CustomValidationException> {
|
||||
service.allByInterval(
|
||||
satelliteId = 22L,
|
||||
timeStart = LocalDateTime.of(2026, 4, 17, 0, 0),
|
||||
timeStop = LocalDateTime.of(2026, 4, 16, 0, 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slotCalculationSummaries returns empty summary for satellites without slots`() {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val service = slotServiceFixture(slotRepository, TestSatelliteImpl(id = 22L))
|
||||
val summary = TestSlotCalculationSummaryProjection(
|
||||
satelliteId = 22L,
|
||||
slotCount = 3L,
|
||||
minTime = LocalDateTime.of(2026, 4, 20, 0, 0),
|
||||
maxTime = LocalDateTime.of(2026, 4, 21, 0, 0),
|
||||
minRevolution = 10L,
|
||||
maxRevolution = 15L
|
||||
)
|
||||
`when`(slotRepository.slotCalculationSummary(22L)).thenReturn(summary)
|
||||
`when`(slotRepository.slotCalculationSummary(44L)).thenReturn(
|
||||
TestSlotCalculationSummaryProjection(
|
||||
satelliteId = 44L,
|
||||
slotCount = 0L,
|
||||
minTime = null,
|
||||
maxTime = null,
|
||||
minRevolution = null,
|
||||
maxRevolution = null
|
||||
)
|
||||
)
|
||||
|
||||
val actual = service.slotCalculationSummaries(listOf(22L, 44L, 22L))
|
||||
|
||||
assertEquals(2, actual.size)
|
||||
assertEquals(true, actual[0].calculated)
|
||||
assertEquals(5L, actual[0].revolutionInterval)
|
||||
assertEquals(44L, actual[1].satelliteId)
|
||||
assertEquals(false, actual[1].calculated)
|
||||
assertEquals(0L, actual[1].slotCount)
|
||||
}
|
||||
|
||||
private fun slotServiceFixture(slotRepository: SlotRepository, satellite: AbstractSatellite): SlotService {
|
||||
val service = SlotService()
|
||||
ReflectionTestUtils.setField(service, "slotRepository", slotRepository)
|
||||
ReflectionTestUtils.setField(service, "bookedSlotsRepository", mock(BookedSlotsRepository::class.java))
|
||||
ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java))
|
||||
ReflectionTestUtils.setField(service, "earthService", mock(EarthService::class.java))
|
||||
ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java))
|
||||
ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(listOf(satellite)))
|
||||
return service
|
||||
}
|
||||
|
||||
private fun testSatelliteCatalogClient(satellites: List<AbstractSatellite>) = object : SatelliteCatalogClient {
|
||||
override fun allSatellites(): List<AbstractSatellite> = satellites
|
||||
|
||||
override fun satellites(ids: List<Long>): List<AbstractSatellite> =
|
||||
ids.distinct().map { satelliteId ->
|
||||
satellites.find { it.id == satelliteId }
|
||||
?: throw CustomValidationException("КА $satelliteId не зарегистрирован")
|
||||
}
|
||||
|
||||
override fun satellite(id: Long): AbstractSatellite =
|
||||
satellites.find { it.id == id }
|
||||
?: throw CustomValidationException("КА $id не зарегистрирован")
|
||||
|
||||
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import ballistics.types.EarthType
|
||||
import ballistics.utils.astro.AstronomerJ2000
|
||||
import ballistics.utils.fromDateTime
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyList
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.entity.SlotEntity
|
||||
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
|
||||
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
|
||||
import space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow
|
||||
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
|
||||
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
|
||||
import space.nstart.pcp.slots_service.repository.SlotRepository
|
||||
import java.time.LocalDateTime
|
||||
import javax.sql.DataSource
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SlotServiceSunFilterTest {
|
||||
|
||||
private fun anyStringValue(): String = anyString()
|
||||
private fun <T> anyListValue(): List<T> = anyList<T>() ?: emptyList()
|
||||
private fun anyLongValue(): Long = anyLong()
|
||||
private fun anyIntValue(): Int = anyInt()
|
||||
private fun <T> eqValue(value: T): T = eq(value) ?: value
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch does not apply sun filter when sun is null`() {
|
||||
val fixture = slotServiceFixture()
|
||||
val satellite = fixture.satellite
|
||||
val templateStart = satellite.tnCalc.plusHours(2)
|
||||
val slot = templateSlot(satellite, templateStart)
|
||||
|
||||
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
|
||||
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
val response = fixture.service.polySlotsBatch(
|
||||
request(
|
||||
satellite = satellite,
|
||||
timeStart = templateStart.minusMinutes(1),
|
||||
timeStop = templateStart.plusMinutes(11),
|
||||
sun = null
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, response.size)
|
||||
assertEquals(1, response.single().slots.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch drops slot when sun angle is less than or equal to requested threshold`() {
|
||||
val fixture = slotServiceFixture()
|
||||
val satellite = fixture.satellite
|
||||
val templateStart = satellite.tnCalc.plusHours(2)
|
||||
val slot = templateSlot(satellite, templateStart)
|
||||
val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude)
|
||||
|
||||
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
|
||||
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
val response = fixture.service.polySlotsBatch(
|
||||
request(
|
||||
satellite = satellite,
|
||||
timeStart = templateStart.minusMinutes(1),
|
||||
timeStop = templateStart.plusMinutes(11),
|
||||
sun = actualAngle
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(response.single().slots.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch keeps slot when sun angle is above requested threshold`() {
|
||||
val fixture = slotServiceFixture()
|
||||
val satellite = fixture.satellite
|
||||
val templateStart = satellite.tnCalc.plusHours(2)
|
||||
val slot = templateSlot(satellite, templateStart)
|
||||
val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude)
|
||||
|
||||
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
|
||||
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
val response = fixture.service.polySlotsBatch(
|
||||
request(
|
||||
satellite = satellite,
|
||||
timeStart = templateStart.minusMinutes(1),
|
||||
timeStop = templateStart.plusMinutes(11),
|
||||
sun = actualAngle - 0.1
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, response.single().slots.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch uses actual slot time when filtering by sun`() {
|
||||
val fixture = slotServiceFixture()
|
||||
val satellite = fixture.satellite
|
||||
val templateStart = satellite.tnCalc.plusHours(2)
|
||||
val slot = templateSlot(satellite, templateStart)
|
||||
val (cycle, templateAngle, actualAngle) = findCycleWithDifferentSunAngle(satellite, slot)
|
||||
val actualStart = templateStart.plusDays(cycle * satellite.durationCalc)
|
||||
val actualStop = actualStart.plusMinutes(10)
|
||||
val requestedSun = (templateAngle + actualAngle) / 2.0
|
||||
|
||||
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
|
||||
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
val response = fixture.service.polySlotsBatch(
|
||||
request(
|
||||
satellite = satellite,
|
||||
timeStart = actualStart.minusMinutes(1),
|
||||
timeStop = actualStop.plusMinutes(1),
|
||||
sun = requestedSun
|
||||
)
|
||||
)
|
||||
|
||||
val slots = response.single().slots
|
||||
if (actualAngle > templateAngle) {
|
||||
assertEquals(1, slots.size)
|
||||
assertEquals(actualStart, slots.single().tn)
|
||||
} else {
|
||||
assertTrue(slots.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polySlotsBatch keeps target list when sun filter removes all slots`() {
|
||||
val fixture = slotServiceFixture()
|
||||
val satellite = fixture.satellite
|
||||
val templateStart = satellite.tnCalc.plusHours(2)
|
||||
val slot = templateSlot(satellite, templateStart)
|
||||
val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude)
|
||||
|
||||
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
|
||||
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
|
||||
.thenReturn(emptyList())
|
||||
|
||||
val response = fixture.service.polySlotsBatch(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
items = listOf(
|
||||
SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
|
||||
SlotCoverageTargetDTO(2L, "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))")
|
||||
),
|
||||
timeStart = templateStart.minusMinutes(1),
|
||||
timeStop = templateStart.plusMinutes(11),
|
||||
cov = false,
|
||||
satellites = listOf(satellite.id),
|
||||
sun = actualAngle
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, response.size)
|
||||
assertTrue(response.all { it.slots.isEmpty() })
|
||||
}
|
||||
|
||||
private fun slotServiceFixture(): SlotServiceFixture {
|
||||
val slotRepository = mock(SlotRepository::class.java)
|
||||
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
|
||||
val satellite = TestSatelliteImpl(id = 22L)
|
||||
|
||||
val service = SlotService()
|
||||
ReflectionTestUtils.setField(service, "slotRepository", slotRepository)
|
||||
ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository)
|
||||
ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java))
|
||||
ReflectionTestUtils.setField(service, "earthService", mock(EarthService::class.java))
|
||||
ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java))
|
||||
ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(listOf(satellite)))
|
||||
return SlotServiceFixture(service, slotRepository, bookedSlotsRepository, satellite)
|
||||
}
|
||||
|
||||
private fun testSatelliteCatalogClient(satellites: List<AbstractSatellite>) = object : SatelliteCatalogClient {
|
||||
override fun allSatellites(): List<AbstractSatellite> = satellites
|
||||
|
||||
override fun satellites(ids: List<Long>): List<AbstractSatellite> =
|
||||
ids.distinct().map { satelliteId ->
|
||||
satellites.find { it.id == satelliteId }
|
||||
?: throw CustomValidationException("КА $satelliteId не зарегистрирован")
|
||||
}
|
||||
|
||||
override fun satellite(id: Long): AbstractSatellite =
|
||||
satellites.find { it.id == id }
|
||||
?: throw CustomValidationException("КА $id не зарегистрирован")
|
||||
|
||||
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request
|
||||
}
|
||||
|
||||
private fun request(
|
||||
satellite: AbstractSatellite,
|
||||
timeStart: LocalDateTime,
|
||||
timeStop: LocalDateTime,
|
||||
sun: Double?
|
||||
) = SlotCoverageBatchRequestDTO(
|
||||
items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
|
||||
timeStart = timeStart,
|
||||
timeStop = timeStop,
|
||||
cov = false,
|
||||
satellites = listOf(satellite.id),
|
||||
sun = sun
|
||||
)
|
||||
|
||||
private fun templateSlot(
|
||||
satellite: AbstractSatellite,
|
||||
templateStart: LocalDateTime
|
||||
) = SlotEntity(
|
||||
slotId = 101L,
|
||||
slotNum = 1L,
|
||||
cycle = 0L,
|
||||
satelliteId = satellite.id,
|
||||
coveringType = 0,
|
||||
tn = templateStart,
|
||||
tk = templateStart.plusMinutes(10),
|
||||
roll = 10.0,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
revolution = 500L,
|
||||
revolutionSign = "ASC",
|
||||
latitude = 55.75,
|
||||
longitude = 37.62
|
||||
)
|
||||
|
||||
private fun stubSingleSlotSpatialFetch(
|
||||
fixture: SlotServiceFixture,
|
||||
satelliteId: Long,
|
||||
slot: SlotEntity
|
||||
) {
|
||||
`when`(
|
||||
fixture.slotRepository.findTargetSlotRows(
|
||||
anyListValue(),
|
||||
anyIntValue()
|
||||
)
|
||||
).thenReturn(
|
||||
listOf(
|
||||
BatchCoverageSlotRow(
|
||||
targetId = 1L,
|
||||
slotId = slot.slotId ?: -1L,
|
||||
slotNum = slot.slotNum,
|
||||
cycle = slot.cycle,
|
||||
satelliteId = slot.satelliteId,
|
||||
coveringType = slot.coveringType,
|
||||
tn = slot.tn,
|
||||
tk = slot.tk,
|
||||
roll = slot.roll,
|
||||
contour = slot.contour,
|
||||
revolution = slot.revolution,
|
||||
revolutionSign = slot.revolutionSign,
|
||||
latitude = slot.latitude,
|
||||
longitude = slot.longitude
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun findCycleWithDifferentSunAngle(
|
||||
satellite: AbstractSatellite,
|
||||
slot: SlotEntity
|
||||
): Triple<Long, Double, Double> {
|
||||
val templateAngle = sunAngleDegrees(slot.tn, slot.latitude, slot.longitude)
|
||||
for (cycle in 1L..40L) {
|
||||
val actualTime = slot.tn.plusDays(cycle * satellite.durationCalc)
|
||||
val actualAngle = sunAngleDegrees(actualTime, slot.latitude, slot.longitude)
|
||||
if (abs(actualAngle - templateAngle) > 0.5) {
|
||||
return Triple(cycle, templateAngle, actualAngle)
|
||||
}
|
||||
}
|
||||
error("Unable to find cycle with distinct sun angle for actual slot time test")
|
||||
}
|
||||
|
||||
private fun sunAngleDegrees(time: LocalDateTime, latitudeDegrees: Double, longitudeDegrees: Double): Double {
|
||||
val astronomer = AstronomerJ2000(EarthType.PZ90d02)
|
||||
val position = astronomer.earth.blh2xyz(
|
||||
latitudeDegrees / 180.0 * PI,
|
||||
longitudeDegrees / 180.0 * PI,
|
||||
0.0
|
||||
)
|
||||
return astronomer.sunAngle(fromDateTime(time), position) * 180.0 / PI
|
||||
}
|
||||
|
||||
private data class SlotServiceFixture(
|
||||
val service: SlotService,
|
||||
val slotRepository: SlotRepository,
|
||||
val bookedSlotsRepository: BookedSlotsRepository,
|
||||
val satellite: AbstractSatellite
|
||||
)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
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
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class SlotServiceUpdateSlotsProfileTest {
|
||||
|
||||
@Test
|
||||
fun `updateSlotsProfile sends current satellite slot profile to catalog`() {
|
||||
val service = SlotService()
|
||||
val catalogClient = RecordingSatelliteCatalogClient()
|
||||
ReflectionTestUtils.setField(service, "satelliteCatalogClient", catalogClient)
|
||||
val satellite = TestSatelliteImpl(
|
||||
id = 22L,
|
||||
cycleRevs = 275L,
|
||||
tnCalc = LocalDateTime.of(2026, 4, 20, 11, 35),
|
||||
durationCalc = 18L,
|
||||
slotDuration = 12L,
|
||||
maxChainLength = 7,
|
||||
angles = listOf(18.5, 21.5, 23.0, 26.0)
|
||||
)
|
||||
|
||||
invokeUpdateSlotsProfile(service, satellite)
|
||||
|
||||
assertEquals(22L, catalogClient.updatedSatelliteId)
|
||||
assertEquals(
|
||||
SatelliteSlotProfileDTO(
|
||||
cycleRevs = 275L,
|
||||
tnCalc = LocalDateTime.of(2026, 4, 20, 11, 35),
|
||||
slotDuration = 12L,
|
||||
durationCalcDays = 18L,
|
||||
maxChainLength = 7,
|
||||
defaultAngles = listOf(
|
||||
SatelliteSlotAngleDTO(angleBegin = 18.5, angleEnd = 21.5),
|
||||
SatelliteSlotAngleDTO(angleBegin = 23.0, angleEnd = 26.0)
|
||||
)
|
||||
),
|
||||
catalogClient.updatedProfile
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateSlotsProfile rejects odd number of slot angle boundaries`() {
|
||||
val service = SlotService()
|
||||
ReflectionTestUtils.setField(service, "satelliteCatalogClient", RecordingSatelliteCatalogClient())
|
||||
val satellite = TestSatelliteImpl(
|
||||
id = 22L,
|
||||
angles = listOf(18.5, 21.5, 23.0)
|
||||
)
|
||||
|
||||
assertFailsWith<CustomValidationException> {
|
||||
invokeUpdateSlotsProfile(service, satellite)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeUpdateSlotsProfile(service: SlotService, satellite: AbstractSatellite) {
|
||||
val method = SlotService::class.java.getDeclaredMethod(
|
||||
"updateSlotsProfile",
|
||||
AbstractSatellite::class.java
|
||||
)
|
||||
method.isAccessible = true
|
||||
try {
|
||||
method.invoke(service, satellite)
|
||||
} catch (ex: InvocationTargetException) {
|
||||
throw ex.targetException
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingSatelliteCatalogClient : SatelliteCatalogClient {
|
||||
var updatedSatelliteId: Long? = null
|
||||
var updatedProfile: SatelliteSlotProfileDTO? = null
|
||||
|
||||
override fun allSatellites(): List<AbstractSatellite> = emptyList()
|
||||
|
||||
override fun satellites(ids: List<Long>): List<AbstractSatellite> = emptyList()
|
||||
|
||||
override fun satellite(id: Long): AbstractSatellite =
|
||||
throw UnsupportedOperationException("Not used in this test")
|
||||
|
||||
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO {
|
||||
updatedSatelliteId = id
|
||||
updatedProfile = request
|
||||
return request
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package space.nstart.pcp.slots_service.util
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.locationtech.jts.geom.MultiPolygon
|
||||
import org.locationtech.jts.geom.Polygon
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LongitudeWrapGeometryTest {
|
||||
|
||||
private val reader = WKTReader()
|
||||
|
||||
@Test
|
||||
fun `normalizes prime meridian polygon into continuous longitude range`() {
|
||||
val geometry = LongitudeWrapGeometry.normalizeToContinuous360(
|
||||
reader.read("POLYGON ((-1 1, 1 1, 1 -1, -1 -1, -1 1))")
|
||||
) as Polygon
|
||||
|
||||
val xs = geometry.coordinates.map { it.x }
|
||||
|
||||
assertEquals(359.0, xs.min())
|
||||
assertEquals(361.0, xs.max())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equivalent polygons around zero meridian intersect after normalization`() {
|
||||
val slotGeometry = LongitudeWrapGeometry.normalizeToContinuous360(
|
||||
reader.read("POLYGON ((359 1, 1 1, 1 -1, 359 -1, 359 1))")
|
||||
)
|
||||
val queryGeometry = LongitudeWrapGeometry.normalizeToContinuous360(
|
||||
reader.read("POLYGON ((-0.5 0.5, 0.5 0.5, 0.5 -0.5, -0.5 -0.5, -0.5 0.5))")
|
||||
)
|
||||
|
||||
assertTrue(slotGeometry.intersects(queryGeometry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `builds search variants for polygons crossing zero meridian`() {
|
||||
val variants = LongitudeWrapGeometry.buildSearchVariantsWkt(
|
||||
"POLYGON ((355 1, 361 1, 361 -1, 355 -1, 355 1))"
|
||||
).map { reader.read(it) as Polygon }
|
||||
|
||||
val bounds = variants.map { listOf(it.envelopeInternal.minX, it.envelopeInternal.maxX) }
|
||||
|
||||
assertContentEquals(
|
||||
listOf(
|
||||
listOf(-5.0, 1.0),
|
||||
listOf(355.0, 361.0),
|
||||
listOf(715.0, 721.0)
|
||||
),
|
||||
bounds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aligns eastern slot copy to western continuous target band`() {
|
||||
val target = reader.read("POLYGON ((355 1, 361 1, 361 -1, 355 -1, 355 1))")
|
||||
val easternSlot = reader.read("POLYGON ((0.2 0.5, 0.8 0.5, 0.8 -0.5, 0.2 -0.5, 0.2 0.5))")
|
||||
|
||||
val aligned = LongitudeWrapGeometry.alignToReference(easternSlot, target) as Polygon
|
||||
|
||||
assertEquals(360.2, aligned.envelopeInternal.minX)
|
||||
assertEquals(360.8, aligned.envelopeInternal.maxX)
|
||||
assertTrue(aligned.intersects(LongitudeWrapGeometry.normalizeToContinuous360(target)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repairs wrap induced self intersection into valid multipolygon`() {
|
||||
val geometry = LongitudeWrapGeometry.normalizeToContinuous360(
|
||||
reader.read("POLYGON ((179 2, -179 2, 179 0, -179 0, 179 -2, -179 -2, 179 2))")
|
||||
)
|
||||
|
||||
assertTrue(geometry is MultiPolygon)
|
||||
assertTrue(geometry.isValid)
|
||||
assertEquals(179.0, geometry.envelopeInternal.minX)
|
||||
assertEquals(181.0, geometry.envelopeInternal.maxX)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
spring:
|
||||
application:
|
||||
name: slots-service
|
||||
cloud:
|
||||
config:
|
||||
enabled: false
|
||||
lifecycle.timeout-per-shutdown-phase: 40s
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://192.168.100.160:35400/pcp_slots
|
||||
username: postgres
|
||||
password: password
|
||||
hikari:
|
||||
maximum-pool-size: 2
|
||||
minimum-idle: 0
|
||||
connection-timeout: 5000
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
jdbc:
|
||||
lob:
|
||||
non_contextual_creation: true
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
codec:
|
||||
max-in-memory-size: 20MB
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
layout: BaseLayout
|
||||
path: /swagger/ui
|
||||
api-docs:
|
||||
enabled: true
|
||||
path: /api-docs
|
||||
|
||||
logging:
|
||||
level:
|
||||
.: ERROR
|
||||
file:
|
||||
name: ./logs/application.log
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "*"
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
info:
|
||||
enabled: true
|
||||
|
||||
settings:
|
||||
earth-grid-service: http://192.168.60.68:7005
|
||||
|
||||
server:
|
||||
port: 7006
|
||||
Reference in New Issue
Block a user