This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -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)
}
@@ -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)
)
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
}
@@ -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."
)
}
}
@@ -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)
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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)
}
@@ -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 = "",
) {
}
@@ -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?
}
@@ -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
)
}
@@ -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
)
@@ -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
)
}
@@ -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
)
@@ -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
}
}
@@ -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,
){
}
@@ -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
)
}
}
@@ -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
}
}
@@ -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
@@ -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 }
}
@@ -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
}
}
}
@@ -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>
}
@@ -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
}
@@ -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
}
@@ -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>
}
@@ -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>
}
@@ -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()
}
}
@@ -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
}
@@ -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)
}
@@ -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
)
}
}
@@ -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)
}
@@ -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 = "",
)
@@ -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
}
@@ -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)
}
}
@@ -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()
}
@@ -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
)
@@ -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"
}
}
@@ -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
)
}
@@ -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);
@@ -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;
@@ -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);
@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS idx_slot_satellite_tn_tk ON slot(satellite_id, tn, tk);
@@ -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() {
}
}
@@ -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)
}
}
@@ -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 не зарегистрирован"))
}
}
@@ -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
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
)
}
}
@@ -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)
}
}
}
@@ -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])
}
}
@@ -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
)
}
@@ -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
)
}
@@ -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
}
}
@@ -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()
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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
}
}
@@ -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))"
}
}
@@ -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
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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
)
}
@@ -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
}
}
}
@@ -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