Init
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp.pcp_satellites_service
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableAsync
|
||||
|
||||
@EnableAsync
|
||||
@SpringBootApplication
|
||||
class PcpComplexMissionServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<PcpComplexMissionServiceApplication>(*args)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.configuration
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "complex-plan")
|
||||
class ComplexPlanProperties {
|
||||
var batchChunkSize: Int = 100
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package space.nstart.pcp.pcp_satellites_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)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.configuration
|
||||
|
||||
import org.apache.kafka.clients.consumer.ConsumerConfig
|
||||
import org.apache.kafka.common.serialization.StringDeserializer
|
||||
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
|
||||
|
||||
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
|
||||
@Configuration
|
||||
class KafkaConfig {
|
||||
|
||||
@Value("\${spring.kafka.bootstrap-servers}")
|
||||
private lateinit var bootstrapServers: String
|
||||
|
||||
@Value("\${spring.kafka.consumer.group-id}")
|
||||
private lateinit var groupId: String
|
||||
|
||||
@Bean
|
||||
fun consumerFactory(): ConsumerFactory<String, String> {
|
||||
val props = hashMapOf<String, Any>()
|
||||
props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers
|
||||
props[ConsumerConfig.GROUP_ID_CONFIG] = groupId
|
||||
props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = "latest"
|
||||
props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false
|
||||
props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java
|
||||
props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java
|
||||
|
||||
return DefaultKafkaConsumerFactory(props)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory<String, String> =
|
||||
ConcurrentKafkaListenerContainerFactory<String, String>()
|
||||
.apply { setConsumerFactory(consumerFactory()) }
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.configuration
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Clock
|
||||
|
||||
@Configuration
|
||||
class TimeConfiguration {
|
||||
|
||||
@Bean
|
||||
fun clock(): Clock = Clock.systemUTC()
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package space.nstart.pcp.pcp_satellites_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
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.configuration
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.util.unit.DataSize
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
|
||||
@Configuration
|
||||
class WebClientConfig(
|
||||
@param:Value("\${spring.codec.max-in-memory-size:20MB}")
|
||||
private val maxInMemorySize: DataSize
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun webClientBuilder(): WebClient.Builder =
|
||||
WebClient.builder()
|
||||
.codecs { configurer ->
|
||||
configurer.defaultCodecs().maxInMemorySize(maxInMemorySize.toBytes().toInt())
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.http.HttpStatus
|
||||
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.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
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_satellites_service.dto.ComplexPlanRunFilterDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistenceService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunQueryService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.SatelliteModeQueryService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
|
||||
import tools.jackson.databind.json.JsonMapper
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/com-plan")
|
||||
class ComplexPlanController {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val objectMapper = JsonMapper.builder().findAndAddModules().build()
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexMissionService: ComplexMissionService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunOrchestrationService: ComplexPlanRunOrchestrationService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunQueryService: ComplexPlanRunQueryService
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeQueryService: SatelliteModeQueryService
|
||||
|
||||
@PostMapping("/process")
|
||||
fun calc(
|
||||
@Valid @RequestBody body: ComplexPlanProcessRequestDTO,
|
||||
@RequestHeader(name = "X-Requested-By", required = false) requestedByHeader: String?,
|
||||
@RequestHeader(name = "X-User", required = false) xUserHeader: String?,
|
||||
@RequestHeader(name = "X-Forwarded-User", required = false) forwardedUserHeader: String?
|
||||
): ComplexPlanRunResponseDTO {
|
||||
val requestedBy = requestedByHeader
|
||||
?: xUserHeader
|
||||
?: forwardedUserHeader
|
||||
val request = body.copy(coverageStrategy = body.coverageStrategy ?: SlotCoverageStrategy.GREEDY)
|
||||
logger.info(
|
||||
"Complex plan process request started: interval=[{} - {}], satelliteId={}, satelliteIds={}, coverageSource={}, countLat={}, countLong={}, coverageStrategy={}, requestedBy={}",
|
||||
request.intervalStart,
|
||||
request.intervalEnd,
|
||||
request.satelliteId,
|
||||
request.satelliteIds,
|
||||
request.coverageSource,
|
||||
request.countLat,
|
||||
request.countLong,
|
||||
request.coverageStrategy,
|
||||
requestedBy
|
||||
)
|
||||
lateinit var response: ComplexPlanRunResponseDTO
|
||||
val durationMs = measureTimeMillis {
|
||||
response = complexPlanRunOrchestrationService.process(
|
||||
request = request,
|
||||
requestedBy = requestedBy
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan process completed: runId={}, status={}, durationMs={}, responseBody={}",
|
||||
response.runId,
|
||||
response.status,
|
||||
durationMs,
|
||||
objectMapper.writeValueAsString(response)
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
@PostMapping("/clear")
|
||||
fun clear() = complexMissionService.clear()
|
||||
|
||||
@GetMapping("/runs/{id}")
|
||||
fun run(@PathVariable id: Long) =
|
||||
complexPlanRunQueryService.getRun(id)
|
||||
|
||||
@GetMapping("/runs/{id}/modes")
|
||||
fun runModes(@PathVariable id: Long) =
|
||||
satelliteModeQueryService.findModesByRun(id)
|
||||
|
||||
@DeleteMapping("/runs/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun deleteRun(@PathVariable id: Long) {
|
||||
complexPlanRunPersistenceService.deleteRun(id)
|
||||
}
|
||||
|
||||
@GetMapping("/runs")
|
||||
fun runs(
|
||||
@RequestParam(required = false) status: ComplexPlanRunStatus?,
|
||||
@RequestParam(required = false) createdFrom: LocalDateTime?,
|
||||
@RequestParam(required = false) createdTo: LocalDateTime?,
|
||||
@RequestParam(required = false) satelliteId: Long?,
|
||||
@RequestParam(required = false) intervalStart: LocalDateTime?,
|
||||
@RequestParam(required = false) intervalEnd: LocalDateTime?
|
||||
) = complexPlanRunQueryService.findRuns(
|
||||
ComplexPlanRunFilterDTO(
|
||||
status = status,
|
||||
createdFrom = createdFrom,
|
||||
createdTo = createdTo,
|
||||
satelliteId = satelliteId,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd
|
||||
)
|
||||
)
|
||||
|
||||
@GetMapping("/cell-with-mars/{cell_number}")
|
||||
fun paintCellWithMars(@PathVariable("cell_number") cellNumber: Long) =
|
||||
satelliteModeQueryService.buildCellPaintGeometry(cellNumber)
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.controller
|
||||
|
||||
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_satellites_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanQueryService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.SatelliteService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellCalculationRequestDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/satellites")
|
||||
class SatelliteController {
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexMissionService: ComplexMissionService
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteService: SatelliteService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanQueryService: ComplexPlanQueryService
|
||||
|
||||
@GetMapping
|
||||
fun all() = satelliteService.all()
|
||||
|
||||
@GetMapping("/{satellite_id}")
|
||||
fun byId(@PathVariable("satellite_id") id: Long) = satelliteService.byId(id)
|
||||
|
||||
@GetMapping("/{satellite_id}/mission")
|
||||
fun planById(@PathVariable("satellite_id") id: Long) =
|
||||
complexPlanQueryService.findMissionModes(requireSatelliteId(id))
|
||||
|
||||
@GetMapping("/{satellite_id}/mission/statistics")
|
||||
fun missionStatistics(@PathVariable("satellite_id") id: Long) =
|
||||
complexPlanQueryService.missionStatistics(requireSatelliteId(id))
|
||||
|
||||
@GetMapping("/{satellite_id}/mission-for-paint")
|
||||
fun planByIdForPaint(@PathVariable("satellite_id") id: Long) =
|
||||
complexPlanQueryService.buildPaintGeometry(requireSatelliteId(id))
|
||||
|
||||
@GetMapping("/{satellite_id}/modes")
|
||||
fun modesBySatelliteAndInterval(
|
||||
@PathVariable("satellite_id") id: Long,
|
||||
@RequestParam("intervalStart") intervalStart: LocalDateTime,
|
||||
@RequestParam("intervalEnd") intervalEnd: LocalDateTime,
|
||||
@RequestParam("runId", required = false) runId: Long?
|
||||
) = complexPlanQueryService.findModesByInterval(
|
||||
listOf(requireSatelliteId(id)),
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
runId
|
||||
)
|
||||
|
||||
@GetMapping("/{satellite_id}/snapshots")
|
||||
fun snapshotsBySatellite(
|
||||
@PathVariable("satellite_id") id: Long,
|
||||
@RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?,
|
||||
@RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime?,
|
||||
@RequestParam("activeOnly", defaultValue = "false") activeOnly: Boolean
|
||||
) = complexPlanQueryService.findSnapshots(
|
||||
satelliteId = requireSatelliteId(id),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
activeOnly = activeOnly
|
||||
)
|
||||
|
||||
@GetMapping("/{satellite_id}/snapshots/active")
|
||||
fun activeSnapshotBySatellite(
|
||||
@PathVariable("satellite_id") id: Long,
|
||||
@RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?,
|
||||
@RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime?
|
||||
) = complexPlanQueryService.findActiveSnapshot(
|
||||
satelliteId = requireSatelliteId(id),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd
|
||||
)
|
||||
|
||||
@GetMapping("/snapshots")
|
||||
fun snapshots(
|
||||
@RequestParam("satelliteId", required = false) satelliteId: Long?,
|
||||
@RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?,
|
||||
@RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime?,
|
||||
@RequestParam("activeOnly", defaultValue = "false") activeOnly: Boolean
|
||||
) = complexPlanQueryService.findSnapshots(
|
||||
satelliteId = satelliteId,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
activeOnly = activeOnly
|
||||
)
|
||||
|
||||
@GetMapping("/snapshots/{snapshot_id}")
|
||||
fun snapshot(
|
||||
@PathVariable("snapshot_id") snapshotId: Long
|
||||
) = complexPlanQueryService.findSnapshot(snapshotId)
|
||||
|
||||
@GetMapping("/snapshots/{snapshot_id}/modes")
|
||||
fun modesBySnapshot(
|
||||
@PathVariable("snapshot_id") snapshotId: Long,
|
||||
@RequestParam("intervalStart", required = false) intervalStart: LocalDateTime?,
|
||||
@RequestParam("intervalEnd", required = false) intervalEnd: LocalDateTime?
|
||||
) = complexPlanQueryService.findModesBySnapshot(snapshotId, intervalStart, intervalEnd)
|
||||
|
||||
@GetMapping("/modes")
|
||||
fun modesByInterval(
|
||||
@RequestParam("intervalStart") intervalStart: LocalDateTime,
|
||||
@RequestParam("intervalEnd") intervalEnd: LocalDateTime,
|
||||
@RequestParam("satelliteId", required = false) satelliteId: Long?,
|
||||
@RequestParam("satelliteIds", required = false) satelliteIds: List<Long>?
|
||||
) = complexPlanQueryService.findModesByInterval(
|
||||
buildSatelliteFilter(satelliteId, satelliteIds),
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
|
||||
@PostMapping("prepare")
|
||||
fun prepare() = complexMissionService.clear()
|
||||
|
||||
private fun buildSatelliteFilter(satelliteId: Long?, satelliteIds: List<Long>?): List<Long> {
|
||||
val ids = buildList {
|
||||
satelliteId?.let { add(it) }
|
||||
satelliteIds?.let { addAll(it) }
|
||||
}.distinct()
|
||||
|
||||
if (ids.isEmpty()) {
|
||||
throw CustomValidationException("Необходимо задать satelliteId или satelliteIds")
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
private fun requireSatelliteId(id: Long): Long {
|
||||
satelliteService.byId(id) ?: throw CustomErrorException("Нет КА с заданным идентификаторм $id")
|
||||
return id
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.dto
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class ComplexPlanRunCreateDTO(
|
||||
val intervalStart: LocalDateTime,
|
||||
val intervalEnd: LocalDateTime,
|
||||
val satelliteIds: List<Long>,
|
||||
val requestedBy: String?,
|
||||
val requestPayload: String?
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.dto
|
||||
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class ComplexPlanRunFilterDTO(
|
||||
val status: ComplexPlanRunStatus? = null,
|
||||
val createdFrom: LocalDateTime? = null,
|
||||
val createdTo: LocalDateTime? = null,
|
||||
val satelliteId: Long? = null,
|
||||
val intervalStart: LocalDateTime? = null,
|
||||
val intervalEnd: LocalDateTime? = null
|
||||
)
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.dto
|
||||
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class ComplexPlanRunResponseDTO(
|
||||
val runId: Long,
|
||||
val status: ComplexPlanRunStatus,
|
||||
val intervalStart: LocalDateTime,
|
||||
val intervalEnd: LocalDateTime,
|
||||
val satelliteIds: List<Long>,
|
||||
val requestedBy: String?,
|
||||
val createdAt: OffsetDateTime,
|
||||
val startedAt: OffsetDateTime?,
|
||||
val finishedAt: OffsetDateTime?,
|
||||
val durationMs: Long?,
|
||||
val errorMessage: String?,
|
||||
val calculatedModesCount: Int,
|
||||
val bookedModesCount: Int,
|
||||
val complanModesCount: Int,
|
||||
val mixedModesCount: Int,
|
||||
val affectedSatellitesCount: Int,
|
||||
val processedCellsCount: Int,
|
||||
val bookedSlotLinksCount: Int,
|
||||
val snapshotIds: List<Long>,
|
||||
val requestPayload: String?,
|
||||
val totalRequestsArea: Double = 0.0,
|
||||
val coveredArea: Double = 0.0,
|
||||
val coveredAreaPercent: Double = 0.0
|
||||
)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.dto
|
||||
|
||||
data class ComplexPlanRunStatisticsDTO(
|
||||
val calculatedModesCount: Int = 0,
|
||||
val bookedModesCount: Int = 0,
|
||||
val complanModesCount: Int = 0,
|
||||
val mixedModesCount: Int = 0,
|
||||
val affectedSatellitesCount: Int = 0,
|
||||
val processedCellsCount: Int = 0,
|
||||
val bookedSlotLinksCount: Int = 0,
|
||||
val totalRequestsArea: Double = 0.0,
|
||||
val coveredArea: Double = 0.0,
|
||||
val coveredAreaPercent: Double = 0.0
|
||||
)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.dto
|
||||
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class ComplexPlanRunSummaryDTO(
|
||||
val runId: Long,
|
||||
val status: ComplexPlanRunStatus,
|
||||
val intervalStart: LocalDateTime,
|
||||
val intervalEnd: LocalDateTime,
|
||||
val satelliteIds: List<Long>,
|
||||
val requestedBy: String?,
|
||||
val createdAt: OffsetDateTime,
|
||||
val startedAt: OffsetDateTime?,
|
||||
val finishedAt: OffsetDateTime?,
|
||||
val durationMs: Long?,
|
||||
val errorMessage: String?,
|
||||
val calculatedModesCount: Int,
|
||||
val bookedModesCount: Int,
|
||||
val complanModesCount: Int,
|
||||
val mixedModesCount: Int,
|
||||
val affectedSatellitesCount: Int,
|
||||
val processedCellsCount: Int,
|
||||
val bookedSlotLinksCount: Int,
|
||||
val snapshotIds: List<Long>,
|
||||
val totalRequestsArea: Double = 0.0,
|
||||
val coveredArea: Double = 0.0,
|
||||
val coveredAreaPercent: Double = 0.0
|
||||
)
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
import jakarta.persistence.CascadeType
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Index
|
||||
import jakarta.persistence.OneToMany
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "complex_plan_run",
|
||||
indexes = [
|
||||
Index(name = "idx_complex_plan_run_status", columnList = "status"),
|
||||
Index(name = "idx_complex_plan_run_created_at", columnList = "created_at"),
|
||||
Index(name = "idx_complex_plan_run_interval_start", columnList = "interval_start"),
|
||||
Index(name = "idx_complex_plan_run_interval_end", columnList = "interval_end")
|
||||
]
|
||||
)
|
||||
class ComplexPlanRunEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: ComplexPlanRunStatus = ComplexPlanRunStatus.CREATED,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "started_at")
|
||||
var startedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "finished_at")
|
||||
var finishedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "duration_ms")
|
||||
var durationMs: Long? = null,
|
||||
|
||||
@Column(name = "interval_start", nullable = false)
|
||||
var intervalStart: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "interval_end", nullable = false)
|
||||
var intervalEnd: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "requested_by")
|
||||
var requestedBy: String? = null,
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
var errorMessage: String? = null,
|
||||
|
||||
@Column(name = "calculated_modes_count", nullable = false)
|
||||
var calculatedModesCount: Int = 0,
|
||||
|
||||
@Column(name = "booked_modes_count", nullable = false)
|
||||
var bookedModesCount: Int = 0,
|
||||
|
||||
@Column(name = "complan_modes_count", nullable = false)
|
||||
var complanModesCount: Int = 0,
|
||||
|
||||
@Column(name = "mixed_modes_count", nullable = false)
|
||||
var mixedModesCount: Int = 0,
|
||||
|
||||
@Column(name = "affected_satellites_count", nullable = false)
|
||||
var affectedSatellitesCount: Int = 0,
|
||||
|
||||
@Column(name = "processed_cells_count", nullable = false)
|
||||
var processedCellsCount: Int = 0,
|
||||
|
||||
@Column(name = "booked_slot_links_count", nullable = false)
|
||||
var bookedSlotLinksCount: Int = 0,
|
||||
|
||||
@Column(name = "total_requests_area", nullable = false)
|
||||
var totalRequestsArea: Double = 0.0,
|
||||
|
||||
@Column(name = "covered_area", nullable = false)
|
||||
var coveredArea: Double = 0.0,
|
||||
|
||||
@Column(name = "covered_area_percent", nullable = false)
|
||||
var coveredAreaPercent: Double = 0.0,
|
||||
|
||||
@Column(name = "request_payload", columnDefinition = "TEXT")
|
||||
var requestPayload: String? = null,
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "run",
|
||||
cascade = [CascadeType.ALL],
|
||||
orphanRemoval = true,
|
||||
fetch = FetchType.LAZY
|
||||
)
|
||||
var satellites: MutableList<ComplexPlanRunSatelliteEntity> = mutableListOf(),
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "complexPlanRun",
|
||||
fetch = FetchType.LAZY
|
||||
)
|
||||
var snapshots: MutableList<SatelliteModeSnapshotEntity> = mutableListOf()
|
||||
)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
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.Index
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.Table
|
||||
import jakarta.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "complex_plan_run_satellite",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uq_complex_plan_run_satellite_run_satellite",
|
||||
columnNames = ["run_id", "satellite_id"]
|
||||
)
|
||||
],
|
||||
indexes = [
|
||||
Index(name = "idx_complex_plan_run_satellite_run_id", columnList = "run_id"),
|
||||
Index(name = "idx_complex_plan_run_satellite_satellite_id", columnList = "satellite_id")
|
||||
]
|
||||
)
|
||||
class ComplexPlanRunSatelliteEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "run_id", nullable = false)
|
||||
var run: ComplexPlanRunEntity? = null,
|
||||
|
||||
@Column(name = "satellite_id", nullable = false)
|
||||
var satelliteId: Long = 0
|
||||
)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
enum class ComplexPlanRunStatus {
|
||||
CREATED,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
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.Table
|
||||
import jakarta.persistence.UniqueConstraint
|
||||
import jakarta.persistence.Index
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "satellite_mode_booked_slot",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uq_satellite_mode_booked_slot_mode_booked_slot",
|
||||
columnNames = ["mode_id", "booked_slot_id"]
|
||||
)
|
||||
],
|
||||
indexes = [
|
||||
Index(name = "idx_satellite_mode_booked_slot_mode_id", columnList = "mode_id"),
|
||||
Index(name = "idx_satellite_mode_booked_slot_booked_slot_id", columnList = "booked_slot_id")
|
||||
]
|
||||
)
|
||||
class SatelliteModeBookedSlotEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "mode_id", nullable = false)
|
||||
var mode: SatelliteModeEntity? = null,
|
||||
|
||||
@Column(name = "booked_slot_id", nullable = false)
|
||||
var bookedSlotId: Long = 0
|
||||
)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
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.Table
|
||||
import jakarta.persistence.UniqueConstraint
|
||||
import jakarta.persistence.Index
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "satellite_mode_cell",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uq_satellite_mode_cell_mode_cell",
|
||||
columnNames = ["mode_id", "cell_num"]
|
||||
)
|
||||
],
|
||||
indexes = [
|
||||
Index(name = "idx_satellite_mode_cell_mode_id", columnList = "mode_id"),
|
||||
Index(name = "idx_satellite_mode_cell_cell_num", columnList = "cell_num")
|
||||
]
|
||||
)
|
||||
class SatelliteModeCellEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "mode_id", nullable = false)
|
||||
var mode: SatelliteModeEntity? = null,
|
||||
|
||||
@Column(name = "cell_num", nullable = false)
|
||||
var cellNum: Long = 0
|
||||
)
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
import jakarta.persistence.CascadeType
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.OneToMany
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.Table
|
||||
import jakarta.persistence.Index
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "satellite_modes",
|
||||
indexes = [
|
||||
Index(name = "idx_satellite_modes_satellite_id", columnList = "satellite_id"),
|
||||
Index(name = "idx_satellite_modes_start_time", columnList = "start_time"),
|
||||
Index(name = "idx_satellite_modes_end_time", columnList = "end_time"),
|
||||
Index(name = "idx_satellite_modes_source", columnList = "source")
|
||||
]
|
||||
)
|
||||
class SatelliteModeEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null,
|
||||
|
||||
@Column(name = "satellite_id", nullable = false)
|
||||
var satelliteId: Long = 0,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source", nullable = false, length = 20)
|
||||
var source: SatelliteModeSource = SatelliteModeSource.COMPLAN,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type", nullable = false, length = 20)
|
||||
var type: SatelliteModeType = SatelliteModeType.SURVEY,
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
var startTime: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "end_time", nullable = false)
|
||||
var endTime: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "revolution", nullable = false)
|
||||
var revolution: Long = 0,
|
||||
|
||||
@Column(name = "lat", nullable = false)
|
||||
var lat: Double = 0.0,
|
||||
|
||||
@Column(name = "longitude", nullable = false)
|
||||
var longitude: Double = 0.0,
|
||||
|
||||
@Column(name = "duration", nullable = false)
|
||||
var duration: Double = 0.0,
|
||||
|
||||
@Column(name = "contour_wkt", columnDefinition = "TEXT")
|
||||
var contourWkt: String? = null,
|
||||
|
||||
@Column(name = "roll", nullable = false)
|
||||
var roll: Double = 0.0,
|
||||
|
||||
@Column(name = "cell_num", nullable = false)
|
||||
var cellNum: Long = -1,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "snapshot_id", nullable = false)
|
||||
var snapshot: SatelliteModeSnapshotEntity? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "complex_plan_run_id")
|
||||
var complexPlanRun: ComplexPlanRunEntity? = null,
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "mode",
|
||||
cascade = [CascadeType.ALL],
|
||||
orphanRemoval = true,
|
||||
fetch = FetchType.LAZY
|
||||
)
|
||||
var bookedSlots: MutableList<SatelliteModeBookedSlotEntity> = mutableListOf(),
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "mode",
|
||||
cascade = [CascadeType.ALL],
|
||||
orphanRemoval = true,
|
||||
fetch = FetchType.LAZY
|
||||
)
|
||||
var cells: MutableList<SatelliteModeCellEntity> = mutableListOf()
|
||||
)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package space.nstart.pcp.pcp_satellites_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.Index
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import jakarta.persistence.OneToMany
|
||||
import jakarta.persistence.Table
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "satellite_mode_snapshots",
|
||||
indexes = [
|
||||
Index(name = "idx_satellite_mode_snapshots_satellite_id", columnList = "satellite_id"),
|
||||
Index(name = "idx_satellite_mode_snapshots_interval_start", columnList = "interval_start"),
|
||||
Index(name = "idx_satellite_mode_snapshots_interval_end", columnList = "interval_end"),
|
||||
Index(name = "idx_satellite_mode_snapshots_is_active", columnList = "is_active"),
|
||||
Index(name = "idx_satellite_mode_snapshots_run_id", columnList = "complex_plan_run_id")
|
||||
]
|
||||
)
|
||||
class SatelliteModeSnapshotEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null,
|
||||
|
||||
@Column(name = "satellite_id", nullable = false)
|
||||
var satelliteId: Long = 0,
|
||||
|
||||
@Column(name = "interval_start", nullable = false)
|
||||
var intervalStart: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "interval_end", nullable = false)
|
||||
var intervalEnd: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
var isActive: Boolean = false,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "complex_plan_run_id")
|
||||
var complexPlanRun: ComplexPlanRunEntity? = null,
|
||||
|
||||
@Column(name = "comment", columnDefinition = "TEXT")
|
||||
var comment: String? = null,
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "snapshot",
|
||||
cascade = [CascadeType.ALL],
|
||||
orphanRemoval = true,
|
||||
fetch = FetchType.LAZY
|
||||
)
|
||||
var modes: MutableList<SatelliteModeEntity> = mutableListOf()
|
||||
)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
enum class SatelliteModeSource {
|
||||
SLOTS,
|
||||
COMPLAN,
|
||||
MIXED,
|
||||
MANUAL
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.entity
|
||||
|
||||
enum class SatelliteModeType {
|
||||
SURVEY
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.model
|
||||
|
||||
class BLS(
|
||||
val captureAngle : Double = 1.5,
|
||||
val sunAngleMin : Double = -90.0,
|
||||
val durationMin : Double = 10.0,
|
||||
val durationMax : Double = 300.0,
|
||||
val mmi : Double = 10.0,
|
||||
val dailyMaxDuration : Double = 35.0 * 60,
|
||||
val revolutionMaxDuration : Double = 5.0 * 60
|
||||
){
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.model
|
||||
|
||||
import org.locationtech.jts.index.bintree.Interval
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class LongTermMission(
|
||||
val surveys : MutableList<SurveyMode> = mutableListOf()
|
||||
) {
|
||||
|
||||
fun hasResources(tStart : LocalDateTime, tStop : LocalDateTime, bls: BLS) : Boolean{
|
||||
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.model
|
||||
|
||||
import ballistics.flightLine.PointOnEarthCalculator
|
||||
import ballistics.orbitalPoints.timeStepper.RungeStepper
|
||||
import ballistics.types.EarthType
|
||||
import ballistics.types.ModDVType
|
||||
import ballistics.types.OrbitalPoint
|
||||
import ballistics.types.Orientation
|
||||
import ballistics.types.THBLPoint
|
||||
import ballistics.types.WorkCSType
|
||||
import org.locationtech.jts.geom.Coordinate
|
||||
import org.locationtech.jts.geom.GeometryFactory
|
||||
import org.locationtech.jts.io.WKTWriter
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.div
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.PI
|
||||
import kotlin.times
|
||||
|
||||
|
||||
@Component
|
||||
class SatelliteModel(
|
||||
val satelliteId : Long = 0,
|
||||
val name : String = "",
|
||||
val red : Short = 255,
|
||||
val green : Short = 0,
|
||||
val blue : Short = 0,
|
||||
val longMission : LongTermMission = LongTermMission(),
|
||||
val bls: BLS = BLS(),
|
||||
val scanTLE : Boolean = false
|
||||
|
||||
) {
|
||||
|
||||
fun toDTO() = SatelliteInfoDTO(
|
||||
satelliteId,
|
||||
name,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
bls.captureAngle,
|
||||
scanTLE,
|
||||
bls.durationMax.toLong(),
|
||||
ceil(bls.mmi).toLong()
|
||||
)
|
||||
|
||||
|
||||
fun contourByTime(
|
||||
tn : Double,
|
||||
roll: Double,
|
||||
capture : Double,
|
||||
points : List<OrbitalPoint>
|
||||
) : String {
|
||||
|
||||
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
fun contour(
|
||||
tn : Double,
|
||||
tk : Double,
|
||||
roll: Double,
|
||||
capture : Double,
|
||||
points : List<OrbitalPoint>) : String{
|
||||
//val vp = points.filter { p -> p.t>=tn && p.t <= tk }
|
||||
val stepper: RungeStepper = RungeStepper(points.toMutableList(), EarthType.PZ90d02)
|
||||
var t = tn
|
||||
val vp = mutableListOf<OrbitalPoint>()
|
||||
while(t <= tk) {
|
||||
stepper.calculate(t)?.let { vp.add(it) }
|
||||
|
||||
t += 1.0
|
||||
}
|
||||
val r = vp.map {
|
||||
viewParams(it, roll + capture, 0.0) ?: THBLPoint(
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
)
|
||||
}
|
||||
val l = vp.map {
|
||||
viewParams(it, roll - capture, 0.0) ?: THBLPoint(
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
)
|
||||
}
|
||||
val geom = mutableListOf<Coordinate>()
|
||||
for (rp in r)
|
||||
geom.add(Coordinate(rp.long * 180 / PI, rp.lat * 180 / PI, 0.0))
|
||||
for (lp in l.reversed())
|
||||
geom.add(Coordinate(lp.long * 180 / PI, lp.lat * 180 / PI, 0.0))
|
||||
if (!geom.isEmpty())
|
||||
geom.add(Coordinate(geom.first().x, geom.first().y))
|
||||
val geometryFactory = GeometryFactory()
|
||||
val shell = geometryFactory.createLinearRing(geom.toTypedArray())
|
||||
val polygon = geometryFactory.createPolygon(shell)
|
||||
val wktWriter = WKTWriter()
|
||||
return wktWriter.write(polygon)
|
||||
}
|
||||
|
||||
fun viewParams(pos : OrbitalPoint, gamma : Double, tang : Double) : THBLPoint?{
|
||||
val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
|
||||
return calculator.pointOnEarth(pos, Orientation(tang * PI / 180.0, gamma * PI / 180.0, 0.0))
|
||||
}
|
||||
|
||||
fun hasResources(dat : LocalDate) : Boolean{
|
||||
if (longMission.surveys
|
||||
.filter { it.time.toLocalDate() == dat }
|
||||
.sumOf { view -> view.duration } >= bls.dailyMaxDuration - bls.durationMin
|
||||
)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
fun canBeUsed(view : SquareViewParamDTO) : Boolean{
|
||||
|
||||
if (longMission.surveys
|
||||
.filter { it.time.toLocalDate() == view.timeBegin.toLocalDate() }
|
||||
.sumOf { view -> view.duration } > bls.dailyMaxDuration
|
||||
)
|
||||
return false
|
||||
if (longMission.surveys
|
||||
.filter { it.revolution == view.revolutionBegin }
|
||||
.sumOf { view -> view.duration } > bls.revolutionMaxDuration
|
||||
)
|
||||
return false
|
||||
// val intersections = longMission.surveys
|
||||
// .filter { surv ->
|
||||
// val tn1 = surv.time.plusSeconds(surv.duration.toLong() + bls.mmi.toLong())
|
||||
// val tk1 = surv.time.minusSeconds(bls.mmi.toLong())
|
||||
// val tn2 = view.timeBegin
|
||||
// val tk2 = view.timeEnd
|
||||
//
|
||||
// val intersects = (tk1 <= tn2 && tk2 <= tn1)
|
||||
// intersects
|
||||
// }
|
||||
// return intersections.isEmpty()
|
||||
return true
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.model.mode
|
||||
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class SurveyMode(
|
||||
val revolution : Long = 1,
|
||||
val time : LocalDateTime = LocalDateTime.now(),
|
||||
val timeStop : LocalDateTime = LocalDateTime.now(),
|
||||
val roll : Double = 0.0,
|
||||
val latitude : Double = 0.0,
|
||||
val longitude : Double = 0.0,
|
||||
val duration : Double = 0.0,
|
||||
val contourWKT : String = "",
|
||||
val cellNum : Long = 0,
|
||||
val source: SatelliteModeSource = SatelliteModeSource.COMPLAN,
|
||||
val bookedSlotIds: List<Long> = emptyList(),
|
||||
val cellNums: List<Long> = emptyList()
|
||||
) {
|
||||
fun toDTO() = SurveyModeInfoDTO(
|
||||
revolution = revolution,
|
||||
time = time,
|
||||
timStop = timeStop,
|
||||
roll = roll,
|
||||
duration = duration,
|
||||
contourWKT = contourWKT,
|
||||
slotIds = bookedSlotIds,
|
||||
latitude = latitude,
|
||||
longitude = longitude
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.repository
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface ComplexPlanRunRepository : JpaRepository<ComplexPlanRunEntity, Long>, JpaSpecificationExecutor<ComplexPlanRunEntity> {
|
||||
|
||||
@EntityGraph(attributePaths = ["satellites"])
|
||||
@Query("select run from ComplexPlanRunEntity run where run.id = :id")
|
||||
fun findDetailedById(@Param("id") id: Long): ComplexPlanRunEntity?
|
||||
|
||||
@EntityGraph(attributePaths = ["satellites"])
|
||||
fun findAllByStatusIn(statuses: Collection<ComplexPlanRunStatus>): List<ComplexPlanRunEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select distinct run.id
|
||||
from ComplexPlanRunEntity run
|
||||
join run.satellites satellite
|
||||
where run.status = :status
|
||||
and run.id <> :excludedRunId
|
||||
and satellite.satelliteId in :satelliteIds
|
||||
and run.intervalStart < :intervalEnd
|
||||
and :intervalStart < run.intervalEnd
|
||||
order by run.id
|
||||
"""
|
||||
)
|
||||
fun findOverlappingRunIdsByStatusAndSatelliteIds(
|
||||
@Param("status") status: ComplexPlanRunStatus,
|
||||
@Param("satelliteIds") satelliteIds: Collection<Long>,
|
||||
@Param("intervalStart") intervalStart: LocalDateTime,
|
||||
@Param("intervalEnd") intervalEnd: LocalDateTime,
|
||||
@Param("excludedRunId") excludedRunId: Long
|
||||
): List<Long>
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.repository
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunSatelliteEntity
|
||||
|
||||
interface ComplexPlanRunSatelliteRepository : JpaRepository<ComplexPlanRunSatelliteEntity, Long> {
|
||||
|
||||
fun countBySatelliteId(satelliteId: Long): Long
|
||||
|
||||
fun deleteAllBySatelliteId(satelliteId: Long): Long
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package space.nstart.pcp.pcp_satellites_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.pcp_satellites_service.entity.SatelliteModeBookedSlotEntity
|
||||
|
||||
interface SatelliteModeBookedSlotRepository : JpaRepository<SatelliteModeBookedSlotEntity, Long> {
|
||||
fun findAllByMode_IdIn(modeIds: Collection<Long>): List<SatelliteModeBookedSlotEntity>
|
||||
|
||||
@Modifying
|
||||
@Query("delete from SatelliteModeBookedSlotEntity entity where entity.mode.id in :modeIds")
|
||||
fun deleteAllByModeIds(@Param("modeIds") modeIds: Collection<Long>): Int
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package space.nstart.pcp.pcp_satellites_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.pcp_satellites_service.entity.SatelliteModeCellEntity
|
||||
|
||||
interface SatelliteModeCellRepository : JpaRepository<SatelliteModeCellEntity, Long> {
|
||||
fun findAllByMode_IdIn(modeIds: Collection<Long>): List<SatelliteModeCellEntity>
|
||||
|
||||
@Modifying
|
||||
@Query("delete from SatelliteModeCellEntity entity where entity.mode.id in :modeIds")
|
||||
fun deleteAllByModeIds(@Param("modeIds") modeIds: Collection<Long>): Int
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package space.nstart.pcp.pcp_satellites_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.pcp_satellites_service.entity.SatelliteModeEntity
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface SatelliteModeRepository : JpaRepository<SatelliteModeEntity, Long> {
|
||||
|
||||
fun findAllBySnapshot_IdInOrderByStartTimeAsc(
|
||||
snapshotIds: Collection<Long>
|
||||
): List<SatelliteModeEntity>
|
||||
|
||||
fun findAllBySnapshot_IdOrderByStartTimeAsc(snapshotId: Long): List<SatelliteModeEntity>
|
||||
|
||||
fun findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): List<SatelliteModeEntity>
|
||||
|
||||
fun findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc(
|
||||
satelliteIds: Collection<Long>,
|
||||
revolutions: Collection<Long>
|
||||
): List<SatelliteModeEntity>
|
||||
|
||||
fun findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc(
|
||||
satelliteId: Long,
|
||||
revolutions: Collection<Long>
|
||||
): List<SatelliteModeEntity>
|
||||
|
||||
fun findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId: Long): List<SatelliteModeEntity>
|
||||
|
||||
fun findAllByCellNumOrderByStartTimeAsc(cellNum: Long): List<SatelliteModeEntity>
|
||||
|
||||
fun findAllByComplexPlanRun_IdOrderByStartTimeAsc(runId: Long): List<SatelliteModeEntity>
|
||||
|
||||
fun countByComplexPlanRun_Id(runId: Long): Long
|
||||
|
||||
fun countBySatelliteId(satelliteId: Long): Long
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select entity.id
|
||||
from SatelliteModeEntity entity
|
||||
where entity.satelliteId in :satelliteIds
|
||||
and entity.startTime < :intervalEnd
|
||||
and entity.endTime > :intervalStart
|
||||
"""
|
||||
)
|
||||
fun findIntersectingIdsBySatelliteIdsAndInterval(
|
||||
@Param("satelliteIds") satelliteIds: Collection<Long>,
|
||||
@Param("intervalStart") intervalStart: LocalDateTime,
|
||||
@Param("intervalEnd") intervalEnd: LocalDateTime
|
||||
): List<Long>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select distinct entity
|
||||
from SatelliteModeEntity entity
|
||||
join entity.cells cell
|
||||
where cell.cellNum = :cellNum
|
||||
and entity.snapshot.isActive = true
|
||||
order by entity.startTime asc
|
||||
"""
|
||||
)
|
||||
fun findAllByRelatedCellNumOrderByStartTimeAsc(@Param("cellNum") cellNum: Long): List<SatelliteModeEntity>
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM satellite_modes
|
||||
WHERE satellite_id IN (:satelliteIds)
|
||||
AND start_time < :intervalEnd
|
||||
AND end_time > :intervalStart
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun deleteIntersectingBySatelliteIdsAndInterval(
|
||||
@Param("satelliteIds") satelliteIds: Collection<Long>,
|
||||
@Param("intervalStart") intervalStart: LocalDateTime,
|
||||
@Param("intervalEnd") intervalEnd: LocalDateTime
|
||||
): Int
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.repository
|
||||
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
|
||||
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.pcp_satellites_service.entity.SatelliteModeSnapshotEntity
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface SatelliteModeSnapshotRepository :
|
||||
JpaRepository<SatelliteModeSnapshotEntity, Long>,
|
||||
JpaSpecificationExecutor<SatelliteModeSnapshotEntity> {
|
||||
|
||||
fun findAllByComplexPlanRun_IdOrderByIdAsc(runId: Long): List<SatelliteModeSnapshotEntity>
|
||||
|
||||
fun findAllByComplexPlanRun_IdAndSatelliteIdInOrderBySatelliteIdAscCreatedAtDesc(
|
||||
runId: Long,
|
||||
satelliteIds: Collection<Long>
|
||||
): List<SatelliteModeSnapshotEntity>
|
||||
|
||||
fun findAllBySatelliteIdInAndIsActiveTrueOrderBySatelliteIdAscCreatedAtDesc(
|
||||
satelliteIds: Collection<Long>
|
||||
): List<SatelliteModeSnapshotEntity>
|
||||
|
||||
fun findFirstBySatelliteIdAndIsActiveTrueOrderByCreatedAtDescIdDesc(satelliteId: Long): SatelliteModeSnapshotEntity?
|
||||
|
||||
fun countBySatelliteId(satelliteId: Long): Long
|
||||
|
||||
fun deleteAllBySatelliteId(satelliteId: Long): Long
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"""
|
||||
update SatelliteModeSnapshotEntity snapshot
|
||||
set snapshot.isActive = false
|
||||
where snapshot.satelliteId = :satelliteId
|
||||
and snapshot.isActive = true
|
||||
"""
|
||||
)
|
||||
fun deactivateActiveSnapshots(@Param("satelliteId") satelliteId: Long): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select snapshot.id
|
||||
from SatelliteModeSnapshotEntity snapshot
|
||||
where snapshot.satelliteId in :satelliteIds
|
||||
and snapshot.intervalStart < :intervalEnd
|
||||
and snapshot.intervalEnd > :intervalStart
|
||||
"""
|
||||
)
|
||||
fun findIntersectingIdsBySatelliteIdsAndInterval(
|
||||
@Param("satelliteIds") satelliteIds: Collection<Long>,
|
||||
@Param("intervalStart") intervalStart: LocalDateTime,
|
||||
@Param("intervalEnd") intervalEnd: LocalDateTime
|
||||
): List<Long>
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamsWithPointsDTO
|
||||
|
||||
|
||||
@Service
|
||||
class BallisticsService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>){
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
|
||||
@Value("\${settings.ballistics-service:ic-service}")
|
||||
val url = ""
|
||||
|
||||
private fun client() = webClientBuilder.baseUrl(url).build()
|
||||
|
||||
|
||||
fun mplSquare(req : ObjViewRequestDTO) =
|
||||
client()
|
||||
.post()
|
||||
.uri("/api/obj-view/mpl-square")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(req)
|
||||
.retrieve()
|
||||
.bodyToFlux(SquareViewParamDTO::class.java)
|
||||
.toIterable()
|
||||
|
||||
|
||||
|
||||
fun cellCovering(cell : Long) =
|
||||
client()
|
||||
.get()
|
||||
.uri("/api/obj-view/cell-coverage/{cell}", cell)
|
||||
.retrieve()
|
||||
.bodyToFlux(SquareViewParamsWithPointsDTO::class.java)
|
||||
.toIterable()
|
||||
|
||||
|
||||
fun exactTime(sat : Long, req : ExactTimePositionRequestDTO) =
|
||||
client()
|
||||
.post()
|
||||
.uri("/api/satellites/{satelliteId}/extract-time", sat)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(req)
|
||||
.retrieve()
|
||||
.bodyToFlux(OrbPointDTO::class.java)
|
||||
.toIterable()
|
||||
|
||||
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import ballistics.types.OrbitalPoint
|
||||
import ballistics.utils.fromDateTime
|
||||
import ballistics.utils.math.Vector3D
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageResponseStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellCalculationRequestDTO
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
@Service
|
||||
class ComplexMissionService {
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteService: SatelliteService
|
||||
@Autowired
|
||||
private lateinit var ballisticsService: BallisticsService
|
||||
private val logger : Logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanCalculationService: ComplexPlanCalculationService
|
||||
|
||||
fun clear() =
|
||||
complexPlanCalculationService.clear()
|
||||
|
||||
fun process(tn : LocalDateTime, tk : LocalDateTime, satsAvail : List<Long>?){
|
||||
complexPlanCalculationService.process(tn, tk, satsAvail)
|
||||
}
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.locationtech.jts.io.WKTWriter
|
||||
import org.locationtech.jts.operation.union.UnaryUnionOp
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.ComplexPlanProperties
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.LongTermMission
|
||||
import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class ComplexPlanCalculationService(
|
||||
private val coverageBatchClient: CoverageBatchClient,
|
||||
private val coverageCalculationClientResolver: CoverageCalculationClientResolver,
|
||||
private val earthGridService: EarthGridService,
|
||||
private val satelliteService: SatelliteService,
|
||||
private val complexPlanQueryService: ComplexPlanQueryService,
|
||||
private val complexPlanPersistenceService: ComplexPlanPersistenceService,
|
||||
private val complexPlanProperties: ComplexPlanProperties,
|
||||
private val surveyPlacementService: SurveyPlacementService
|
||||
) {
|
||||
|
||||
private val logger: Logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun process(
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
satelliteIds: List<Long>?,
|
||||
sun: Double? = null,
|
||||
coverageSource: CoverageCalculationSource = CoverageCalculationSource.SLOTS,
|
||||
countLat: Int? = null,
|
||||
countLong: Int? = null,
|
||||
coverageStrategy: SlotCoverageStrategy? = null
|
||||
): ComplexPlanCalculationResult =
|
||||
process(
|
||||
runId = null,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = satelliteIds,
|
||||
sun = sun,
|
||||
coverageSource = coverageSource,
|
||||
countLat = countLat,
|
||||
countLong = countLong,
|
||||
coverageStrategy = coverageStrategy
|
||||
)
|
||||
|
||||
fun process(
|
||||
runId: Long?,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
satelliteIds: List<Long>?,
|
||||
sun: Double? = null,
|
||||
coverageSource: CoverageCalculationSource = CoverageCalculationSource.SLOTS,
|
||||
countLat: Int? = null,
|
||||
countLong: Int? = null,
|
||||
coverageStrategy: SlotCoverageStrategy? = null
|
||||
): ComplexPlanCalculationResult {
|
||||
val calculationStartedAtMs = System.currentTimeMillis()
|
||||
val selectedSatelliteIds = (satelliteIds ?: satelliteService.satellites.map { it.satelliteId }).distinct()
|
||||
if (selectedSatelliteIds.isEmpty()) {
|
||||
logger.info("Расчет комплексного плана пропущен: runId={}, не задано ни одного спутника", runId)
|
||||
return ComplexPlanCalculationResult()
|
||||
}
|
||||
lateinit var workingSatellites: List<SatelliteModel>
|
||||
val buildSatellitesMs = measureTimeMillis {
|
||||
workingSatellites = buildWorkingSatellites(selectedSatelliteIds)
|
||||
}
|
||||
logger.info(
|
||||
"Stage satellites-build: runId={}, requestedSatellites={}, workingSatellites={}, durationMs={}",
|
||||
runId,
|
||||
selectedSatelliteIds.size,
|
||||
workingSatellites.size,
|
||||
buildSatellitesMs
|
||||
)
|
||||
val coverageCalculationClient = coverageCalculationClientResolver.resolve(coverageSource)
|
||||
|
||||
logger.info(
|
||||
"Старт расчета комплексного плана: runId={}, satellites={}, interval=[{} - {}], chunkSize={}, sun={}, coverageSource={}, countLat={}, countLong={}, coverageStrategy={}",
|
||||
runId,
|
||||
selectedSatelliteIds.size,
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
complexPlanProperties.batchChunkSize,
|
||||
sun,
|
||||
coverageSource,
|
||||
countLat,
|
||||
countLong,
|
||||
coverageStrategy
|
||||
)
|
||||
logger.info("Шаг snapshot: runId={}, загрузка рабочего состояния вне пересчитываемого интервала", runId)
|
||||
val snapshotMs = measureTimeMillis {
|
||||
initializeWorkingModes(workingSatellites, intervalStart, intervalEnd)
|
||||
}
|
||||
logger.info(
|
||||
"Stage snapshot-load: runId={}, satellites={}, durationMs={}",
|
||||
runId,
|
||||
workingSatellites.size,
|
||||
snapshotMs
|
||||
)
|
||||
|
||||
try {
|
||||
var cells = emptyList<EarthCellWithRequestsDTO>()
|
||||
val cellsLoadMs = measureTimeMillis {
|
||||
cells = earthGridService.cells(countLat, countLong)
|
||||
.toList()
|
||||
.sortedWith(
|
||||
compareByDescending<EarthCellWithRequestsDTO> { it.importance }
|
||||
.thenByDescending { it.requests.count() }
|
||||
.thenBy { it.num }
|
||||
)
|
||||
}
|
||||
logger.info("Stage cells-load: runId={}, cells={}, durationMs={}", runId, cells.size, cellsLoadMs)
|
||||
val requestGeometry = buildRequestsGeometry(cells)
|
||||
val totalRequestsArea = requestGeometry?.area ?: 0.0
|
||||
logger.info(
|
||||
"Stage requests-area: runId={}, totalRequestsArea={}",
|
||||
runId,
|
||||
totalRequestsArea
|
||||
)
|
||||
|
||||
logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId)
|
||||
val plannedModesMs = measureTimeMillis {
|
||||
addPlannedModes(workingSatellites, intervalStart, intervalEnd)
|
||||
}
|
||||
logger.info(
|
||||
"Stage planned-modes-load: runId={}, satellites={}, durationMs={}",
|
||||
runId,
|
||||
workingSatellites.size,
|
||||
plannedModesMs
|
||||
)
|
||||
|
||||
val chunkSize = complexPlanProperties.batchChunkSize.coerceAtLeast(1)
|
||||
val chunkCount = (cells.size + chunkSize - 1) / chunkSize
|
||||
logger.info("Stage chunk-prepare: runId={}, chunkSize={}, chunks={}", runId, chunkSize, chunkCount)
|
||||
|
||||
var processedCells = 0
|
||||
var acceptedSlots = 0
|
||||
for ((chunkIndex, chunk) in cells.chunked(chunkSize).withIndex()) {
|
||||
if (!hasRemainingCapacity(workingSatellites, intervalStart, intervalEnd)) {
|
||||
logger.info(
|
||||
"Ранняя остановка перед chunk {}: runId={}, ресурс ОГ исчерпан, processedCells={}",
|
||||
chunkIndex + 1,
|
||||
runId,
|
||||
processedCells
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
val targetsChunk = buildTargetsChunk(chunk)
|
||||
var coverageByTargetId = emptyMap<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>()
|
||||
val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites)
|
||||
val coverageFetchMs = measureTimeMillis {
|
||||
coverageByTargetId = coverageCalculationClient.coverageModes(
|
||||
targets = targetsChunk,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
occupiedIntervals = occupiedIntervals,
|
||||
sun = sun,
|
||||
coverageStrategy = coverageStrategy
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Stage chunk-coverage-fetch: runId={}, chunk={}/{}, cells={}, targets={}, occupiedIntervals={}, returnedTargets={}, durationMs={}",
|
||||
runId,
|
||||
chunkIndex + 1,
|
||||
chunkCount,
|
||||
chunk.size,
|
||||
targetsChunk.size,
|
||||
occupiedIntervals.size,
|
||||
coverageByTargetId.size,
|
||||
coverageFetchMs
|
||||
)
|
||||
|
||||
var acceptedForChunk = 0
|
||||
val chunkProcessMs = measureTimeMillis {
|
||||
acceptedForChunk = processChunk(
|
||||
workingSatellites = workingSatellites,
|
||||
cells = chunk,
|
||||
coverageByTargetId = coverageByTargetId,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd
|
||||
)
|
||||
}
|
||||
processedCells += chunk.size
|
||||
acceptedSlots += acceptedForChunk
|
||||
logger.info(
|
||||
"Stage chunk-process: runId={}, chunk={}/{}, cells={}, acceptedModes={}, durationMs={}",
|
||||
runId,
|
||||
chunkIndex + 1,
|
||||
chunkCount,
|
||||
chunk.size,
|
||||
acceptedForChunk,
|
||||
chunkProcessMs
|
||||
)
|
||||
coverageByTargetId = emptyMap()
|
||||
|
||||
if (shouldStop(workingSatellites, intervalStart, intervalEnd)) {
|
||||
logger.info(
|
||||
"Ранняя остановка после chunk {}: runId={}, ресурс ОГ исчерпан, processedCells={}, acceptedModes={}",
|
||||
chunkIndex + 1,
|
||||
runId,
|
||||
processedCells,
|
||||
acceptedSlots
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var saveResult = SatelliteModeSaveResult()
|
||||
val persistMs = measureTimeMillis {
|
||||
saveResult = saveCalculatedModes(
|
||||
runId = runId,
|
||||
workingSatellites = workingSatellites,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Stage persist-modes: runId={}, processedCells={}, acceptedModes={}, persistedModes={}, durationMs={}",
|
||||
runId,
|
||||
processedCells,
|
||||
acceptedSlots,
|
||||
saveResult.calculatedModesCount,
|
||||
persistMs
|
||||
)
|
||||
var coveredArea = 0.0
|
||||
val coverageAreaMs = measureTimeMillis {
|
||||
coveredArea = calculateCoveredArea(workingSatellites, intervalStart, intervalEnd)
|
||||
}
|
||||
val coveredAreaPercent = coveredAreaPercent(totalRequestsArea, coveredArea)
|
||||
logger.info(
|
||||
"Stage coverage-area: runId={}, totalRequestsArea={}, coveredArea={}, coveredAreaPercent={}, durationMs={}",
|
||||
runId,
|
||||
totalRequestsArea,
|
||||
coveredArea,
|
||||
coveredAreaPercent,
|
||||
coverageAreaMs
|
||||
)
|
||||
logger.info(
|
||||
"Расчет комплексного плана завершен успешно: runId={}, calculatedModes={}, bookedModes={}, complanModes={}, mixedModes={}, affectedSatellites={}, processedCells={}, bookedSlotLinks={}, totalRequestsArea={}, coveredArea={}, coveredAreaPercent={}, durationMs={}",
|
||||
runId,
|
||||
saveResult.calculatedModesCount,
|
||||
saveResult.bookedModesCount,
|
||||
saveResult.complanModesCount,
|
||||
saveResult.mixedModesCount,
|
||||
saveResult.affectedSatellitesCount,
|
||||
saveResult.processedCellsCount,
|
||||
saveResult.bookedSlotLinksCount,
|
||||
totalRequestsArea,
|
||||
coveredArea,
|
||||
coveredAreaPercent,
|
||||
System.currentTimeMillis() - calculationStartedAtMs
|
||||
)
|
||||
return ComplexPlanCalculationResult(
|
||||
calculatedModesCount = saveResult.calculatedModesCount,
|
||||
bookedModesCount = saveResult.bookedModesCount,
|
||||
complanModesCount = saveResult.complanModesCount,
|
||||
mixedModesCount = saveResult.mixedModesCount,
|
||||
affectedSatellitesCount = saveResult.affectedSatellitesCount,
|
||||
processedCellsCount = saveResult.processedCellsCount,
|
||||
bookedSlotLinksCount = saveResult.bookedSlotLinksCount,
|
||||
totalRequestsArea = totalRequestsArea,
|
||||
coveredArea = coveredArea,
|
||||
coveredAreaPercent = coveredAreaPercent
|
||||
)
|
||||
} finally {
|
||||
logger.info(
|
||||
"Очистка локального рабочего состояния расчета: runId={}, elapsedMs={}",
|
||||
runId,
|
||||
System.currentTimeMillis() - calculationStartedAtMs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
logger.info("Полная очистка persisted и временного состояния complex plan")
|
||||
complexPlanPersistenceService.clearAll()
|
||||
satelliteService.clear()
|
||||
}
|
||||
|
||||
private fun addPlannedModes(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
) {
|
||||
val satelliteIds = workingSatellites.map { it.satelliteId }
|
||||
val plannedModesBySatelliteId = coverageBatchClient.plannedModes(satelliteIds, intervalStart, intervalEnd)
|
||||
val totalPlannedModes = plannedModesBySatelliteId.values.sumOf { it.size }
|
||||
logger.info(
|
||||
"Получено {} booked-маршрутов из slots-service для {} спутников",
|
||||
totalPlannedModes,
|
||||
plannedModesBySatelliteId.size
|
||||
)
|
||||
|
||||
workingSatellites.forEach { satellite ->
|
||||
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
|
||||
plannedModesBySatelliteId[satellite.satelliteId]
|
||||
.orEmpty()
|
||||
.forEach { slot ->
|
||||
surveyPlacementService.mergeSurvey(
|
||||
existingSurveys = satellite.longMission.surveys,
|
||||
candidateSurvey = SurveyMode(
|
||||
revolution = slot.revolution,
|
||||
time = slot.tn,
|
||||
timeStop = slot.tk,
|
||||
roll = slot.roll,
|
||||
latitude = slot.latitude,
|
||||
longitude = slot.longitude,
|
||||
duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
|
||||
contourWKT = slot.contour,
|
||||
cellNum = -1,
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
bookedSlotIds = slot.slotIds,
|
||||
cellNums = emptyList()
|
||||
),
|
||||
satellite = satellite
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Добавлены booked-маршруты для satelliteId={}: count={}",
|
||||
satellite.satelliteId,
|
||||
plannedForSatellite.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCoverageTargets(cells: List<EarthCellWithRequestsDTO>): List<SlotCoverageTargetDTO> =
|
||||
cells.map { cell ->
|
||||
SlotCoverageTargetDTO(
|
||||
targetId = cell.targetId(),
|
||||
wkt = cellTargetContour(cell)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTargetsChunk(cells: List<EarthCellWithRequestsDTO>): List<SlotCoverageTargetDTO> =
|
||||
buildCoverageTargets(cells)
|
||||
|
||||
private fun buildOccupiedIntervalsSnapshot(workingSatellites: List<SatelliteModel>): List<OccupiedIntervalDTO> =
|
||||
workingSatellites
|
||||
.asSequence()
|
||||
.flatMap { satellite ->
|
||||
satellite.longMission.surveys.asSequence().map { survey ->
|
||||
OccupiedIntervalDTO(
|
||||
satelliteId = satellite.satelliteId,
|
||||
startTime = survey.time,
|
||||
endTime = survey.timeStop,
|
||||
roll = survey.roll,
|
||||
source = survey.source.name
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime })
|
||||
.toList()
|
||||
|
||||
private fun processChunk(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
cells: List<EarthCellWithRequestsDTO>,
|
||||
coverageByTargetId: Map<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Int {
|
||||
var acceptedModes = 0
|
||||
|
||||
for (cell in cells) {
|
||||
val resourceAvailableSatelliteIds = collectResourceAvailableSatelliteIds(workingSatellites, intervalStart, intervalEnd)
|
||||
if (resourceAvailableSatelliteIds.isEmpty()) {
|
||||
break
|
||||
}
|
||||
|
||||
val activeSatelliteIds = resourceAvailableSatelliteIds.toSet()
|
||||
coverageByTargetId[cell.targetId()]
|
||||
.orEmpty()
|
||||
.asSequence()
|
||||
.filter { slot -> activeSatelliteIds.contains(slot.satelliteId) }
|
||||
.forEach { slot ->
|
||||
val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId }
|
||||
?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван")
|
||||
|
||||
val decision = surveyPlacementService.tryPlaceSurvey(
|
||||
existingSurveys = satellite.longMission.surveys,
|
||||
candidateSurvey = SurveyMode(
|
||||
revolution = slot.revolution,
|
||||
time = slot.tn,
|
||||
timeStop = slot.tk,
|
||||
roll = slot.roll,
|
||||
latitude = slot.latitude,
|
||||
longitude = slot.longitude,
|
||||
duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
|
||||
contourWKT = slot.contour,
|
||||
cellNum = cell.num,
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
cellNums = listOf(cell.num)
|
||||
),
|
||||
satellite = satellite
|
||||
)
|
||||
if (decision.accepted) {
|
||||
acceptedModes++
|
||||
} else {
|
||||
logger.debug(
|
||||
"Candidate rejected by placement: satelliteId={}, cellNum={}, revolution={}, reason={}",
|
||||
slot.satelliteId,
|
||||
cell.num,
|
||||
slot.revolution,
|
||||
decision.rejectionReason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acceptedModes
|
||||
}
|
||||
|
||||
private fun hasRemainingCapacity(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Boolean =
|
||||
collectResourceAvailableSatelliteIds(workingSatellites, intervalStart, intervalEnd).isNotEmpty()
|
||||
|
||||
private fun shouldStop(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Boolean = !hasRemainingCapacity(workingSatellites, intervalStart, intervalEnd)
|
||||
|
||||
private fun initializeWorkingModes(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
) {
|
||||
val workingModes = complexPlanQueryService.loadConstraintModes(
|
||||
workingSatellites.map { it.satelliteId },
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
workingSatellites.forEach { satellite ->
|
||||
satellite.longMission.surveys.clear()
|
||||
satellite.longMission.surveys.addAll(workingModes[satellite.satelliteId]
|
||||
.orEmpty()
|
||||
.filter { mode -> mode.timeStop <= intervalStart || mode.time >= intervalEnd || mode.source == SatelliteModeSource.MANUAL})
|
||||
logger.info(
|
||||
"Загружено рабочее состояние для satelliteId={}: modesOutsideInterval={}",
|
||||
satellite.satelliteId,
|
||||
satellite.longMission.surveys.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveCalculatedModes(
|
||||
runId: Long?,
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): SatelliteModeSaveResult {
|
||||
val satelliteIds = workingSatellites.map { it.satelliteId }
|
||||
val surveysBySatelliteId = workingSatellites
|
||||
.associate { satellite -> satellite.satelliteId to satellite.longMission.surveys.toList() }
|
||||
val totalModes = surveysBySatelliteId.values.sumOf { it.size }
|
||||
logger.info(
|
||||
"Подготовлены данные к сохранению: runId={}, satellites={}, totalModes={}",
|
||||
runId,
|
||||
surveysBySatelliteId.keys,
|
||||
totalModes
|
||||
)
|
||||
|
||||
return complexPlanPersistenceService.replaceModesForInterval(
|
||||
satelliteIds = satelliteIds,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = surveysBySatelliteId,
|
||||
complexPlanRunId = runId
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildWorkingSatellites(satelliteIds: List<Long>): List<SatelliteModel> =
|
||||
satelliteService.satellites
|
||||
.filter { satelliteIds.contains(it.satelliteId) }
|
||||
.map { satellite ->
|
||||
SatelliteModel(
|
||||
satelliteId = satellite.satelliteId,
|
||||
name = satellite.name,
|
||||
red = satellite.red,
|
||||
green = satellite.green,
|
||||
blue = satellite.blue,
|
||||
longMission = LongTermMission(),
|
||||
bls = satellite.bls,
|
||||
scanTLE = satellite.scanTLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun collectResourceAvailableSatelliteIds(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): List<Long> {
|
||||
val availableSatelliteIds = linkedSetOf<Long>()
|
||||
var day = intervalStart.toLocalDate()
|
||||
val lastDay = intervalEnd.toLocalDate().minusDays(1)
|
||||
|
||||
while (day <= lastDay) {
|
||||
workingSatellites.forEach { satellite ->
|
||||
if (satellite.hasResources(day)) {
|
||||
availableSatelliteIds += satellite.satelliteId
|
||||
}
|
||||
}
|
||||
day = day.plusDays(1)
|
||||
}
|
||||
|
||||
return availableSatelliteIds.toList()
|
||||
}
|
||||
|
||||
private fun cellTargetContour(cell: EarthCellWithRequestsDTO): String {
|
||||
val requestContours = cell.requests
|
||||
.map { it.contour.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
if (requestContours.isEmpty()) {
|
||||
return cell.contour
|
||||
}
|
||||
|
||||
return requestContours.drop(1).fold(requestContours.first()) { merged, contour ->
|
||||
unionContours(merged, contour)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unionContours(baseContour: String, contourToAdd: String): String {
|
||||
return try {
|
||||
val reader = WKTReader()
|
||||
val writer = WKTWriter()
|
||||
writer.write(reader.read(baseContour).union(reader.read(contourToAdd)))
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Не удалось объединить контуры маршрутов: {}", e.message)
|
||||
baseContour
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRequestsGeometry(cells: List<EarthCellWithRequestsDTO>): Geometry? {
|
||||
val requestContours = cells
|
||||
.asSequence()
|
||||
.flatMap { cell -> cell.requests.asSequence() }
|
||||
.distinctBy { request -> requestFragmentKey(request.id, request.requestId, request.contour) }
|
||||
.map { request -> request.contour }
|
||||
.filter { contour -> contour.isNotBlank() }
|
||||
.toList()
|
||||
|
||||
return unionGeometries(requestContours, "request")
|
||||
}
|
||||
|
||||
private fun requestFragmentKey(id: Long, requestId: String, contour: String): String =
|
||||
if (id > 0) {
|
||||
"fragment:$id"
|
||||
} else {
|
||||
"request:${requestId.takeIf { it.isNotBlank() } ?: "unknown"}:$contour"
|
||||
}
|
||||
|
||||
private fun calculateCoveredArea(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Double {
|
||||
val coveredContours = workingSatellites
|
||||
.asSequence()
|
||||
.flatMap { satellite -> satellite.longMission.surveys.asSequence() }
|
||||
.filter { survey -> survey.source != SatelliteModeSource.MANUAL }
|
||||
.filter { survey -> survey.time < intervalEnd && intervalStart < survey.timeStop }
|
||||
.map { survey -> survey.contourWKT }
|
||||
.filter { contour -> contour.isNotBlank() }
|
||||
.toList()
|
||||
|
||||
return unionGeometries(coveredContours, "covered")?.area ?: 0.0
|
||||
}
|
||||
|
||||
private fun unionGeometries(contours: List<String>, context: String): Geometry? {
|
||||
val reader = WKTReader()
|
||||
val geometries = contours.mapNotNull { contour ->
|
||||
runCatching { reader.read(contour) }
|
||||
.onFailure { e -> logger.warn("Не удалось прочитать WKT {} geometry: {}", context, e.message) }
|
||||
.getOrNull()
|
||||
?.takeIf { geometry -> !geometry.isEmpty }
|
||||
}
|
||||
|
||||
if (geometries.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
UnaryUnionOp.union(geometries)
|
||||
}.onFailure { e ->
|
||||
logger.warn("Не удалось объединить WKT {} geometries: {}", context, e.message)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun coveredAreaPercent(totalRequestsArea: Double, coveredArea: Double): Double =
|
||||
if (totalRequestsArea <= 0.0 || coveredArea <= 0.0) {
|
||||
0.0
|
||||
} else {
|
||||
coveredArea / totalRequestsArea * 100.0
|
||||
}
|
||||
|
||||
private fun EarthCellWithRequestsDTO.targetId(): Long = if (id > 0) id else num
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.sql.Connection
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.sql.DataSource
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class ComplexPlanExecutionLockService(
|
||||
private val dataSource: DataSource
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val LOCK_NAMESPACE = 4_204_202_411L
|
||||
private val fallbackLocks = ConcurrentHashMap.newKeySet<Long>()
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun tryAcquire(satelliteIds: Collection<Long>): ExecutionLockHandle? {
|
||||
val normalizedSatelliteIds = satelliteIds.distinct().sorted()
|
||||
if (normalizedSatelliteIds.isEmpty()) {
|
||||
logger.info("Complex plan execution lock skipped: empty satellite list")
|
||||
return ExecutionLockHandle {}
|
||||
}
|
||||
|
||||
var handle: ExecutionLockHandle? = null
|
||||
val durationMs = measureTimeMillis {
|
||||
val connection = dataSource.connection
|
||||
handle = try {
|
||||
val postgres = connection.metaData.databaseProductName.contains("PostgreSQL", ignoreCase = true)
|
||||
if (postgres) {
|
||||
acquirePostgresLocks(connection, normalizedSatelliteIds)
|
||||
} else {
|
||||
connection.close()
|
||||
acquireFallbackLocks(normalizedSatelliteIds)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
runCatching { connection.close() }
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan execution lock acquire completed: satellites={}, acquired={}, durationMs={}",
|
||||
normalizedSatelliteIds,
|
||||
handle != null,
|
||||
durationMs
|
||||
)
|
||||
return handle
|
||||
}
|
||||
|
||||
private fun acquirePostgresLocks(connection: Connection, satelliteIds: List<Long>): ExecutionLockHandle? {
|
||||
val acquiredKeys = mutableListOf<Long>()
|
||||
try {
|
||||
satelliteIds.forEach { satelliteId ->
|
||||
val lockKey = advisoryKey(satelliteId)
|
||||
if (!tryAcquirePostgresLock(connection, lockKey)) {
|
||||
releasePostgresLocks(connection, acquiredKeys.asReversed())
|
||||
connection.close()
|
||||
logger.info(
|
||||
"Complex plan execution lock busy: satelliteId={}, acquiredSatelliteIds={}",
|
||||
satelliteId,
|
||||
acquiredKeys.map(::satelliteIdFromKey)
|
||||
)
|
||||
return null
|
||||
}
|
||||
acquiredKeys += lockKey
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
releasePostgresLocks(connection, acquiredKeys.asReversed())
|
||||
connection.close()
|
||||
throw ex
|
||||
}
|
||||
|
||||
return ExecutionLockHandle {
|
||||
releasePostgresLocks(connection, acquiredKeys.asReversed())
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireFallbackLocks(satelliteIds: List<Long>): ExecutionLockHandle? {
|
||||
val acquiredKeys = mutableListOf<Long>()
|
||||
satelliteIds.forEach { satelliteId ->
|
||||
val lockKey = advisoryKey(satelliteId)
|
||||
if (!fallbackLocks.add(lockKey)) {
|
||||
acquiredKeys.forEach(fallbackLocks::remove)
|
||||
logger.info(
|
||||
"Complex plan execution fallback lock busy: satelliteId={}, acquiredSatelliteIds={}",
|
||||
satelliteId,
|
||||
acquiredKeys.map(::satelliteIdFromKey)
|
||||
)
|
||||
return null
|
||||
}
|
||||
acquiredKeys += lockKey
|
||||
}
|
||||
|
||||
return ExecutionLockHandle {
|
||||
acquiredKeys.forEach(fallbackLocks::remove)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryAcquirePostgresLock(connection: Connection, lockKey: Long): Boolean =
|
||||
connection.prepareStatement("select pg_try_advisory_lock(?)").use { statement ->
|
||||
statement.setLong(1, lockKey)
|
||||
statement.executeQuery().use { resultSet ->
|
||||
resultSet.next()
|
||||
resultSet.getBoolean(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releasePostgresLocks(connection: Connection, lockKeys: List<Long>) {
|
||||
lockKeys.forEach { lockKey ->
|
||||
runCatching {
|
||||
connection.prepareStatement("select pg_advisory_unlock(?)").use { statement ->
|
||||
statement.setLong(1, lockKey)
|
||||
statement.executeQuery().use { resultSet -> resultSet.next() }
|
||||
}
|
||||
}.onFailure { ex ->
|
||||
logger.warn("Failed to release complex plan advisory lock: key={}", lockKey, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun advisoryKey(satelliteId: Long): Long = LOCK_NAMESPACE * 1_000_003L + satelliteId
|
||||
|
||||
private fun satelliteIdFromKey(lockKey: Long): Long = lockKey - LOCK_NAMESPACE * 1_000_003L
|
||||
|
||||
fun interface ExecutionLockHandle : AutoCloseable {
|
||||
override fun close()
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class ComplexPlanPersistenceService(
|
||||
private val satelliteModePersistenceService: SatelliteModePersistenceService,
|
||||
private val complexPlanRunRepository: ComplexPlanRunRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun clearModesForInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
) {
|
||||
val durationMs = measureTimeMillis {
|
||||
satelliteModePersistenceService.deleteModesForInterval(
|
||||
satelliteIds = satelliteIds,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan persistence clear-modes completed: satellites={}, interval=[{} - {}], durationMs={}",
|
||||
satelliteIds.distinct(),
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
durationMs
|
||||
)
|
||||
}
|
||||
|
||||
fun saveModesForInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
surveysBySatelliteId: Map<Long, List<SurveyMode>>,
|
||||
complexPlanRunId: Long? = null
|
||||
): SatelliteModeSaveResult {
|
||||
lateinit var result: SatelliteModeSaveResult
|
||||
val durationMs = measureTimeMillis {
|
||||
result = satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = satelliteIds,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = surveysBySatelliteId,
|
||||
complexPlanRun = complexPlanRunId?.let { complexPlanRunRepository.getReferenceById(it) }
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan persistence save-modes completed: runId={}, satellites={}, modes={}, durationMs={}",
|
||||
complexPlanRunId,
|
||||
satelliteIds.distinct(),
|
||||
result.calculatedModesCount,
|
||||
durationMs
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
fun replaceModesForInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
surveysBySatelliteId: Map<Long, List<SurveyMode>>,
|
||||
complexPlanRunId: Long? = null
|
||||
): SatelliteModeSaveResult {
|
||||
lateinit var result: SatelliteModeSaveResult
|
||||
val durationMs = measureTimeMillis {
|
||||
result = satelliteModePersistenceService.replaceModesForInterval(
|
||||
satelliteIds = satelliteIds,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = surveysBySatelliteId,
|
||||
complexPlanRun = complexPlanRunId?.let { complexPlanRunRepository.getReferenceById(it) }
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan persistence replace-modes completed: runId={}, satellites={}, modes={}, durationMs={}",
|
||||
complexPlanRunId,
|
||||
satelliteIds.distinct(),
|
||||
result.calculatedModesCount,
|
||||
durationMs
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
val durationMs = measureTimeMillis {
|
||||
satelliteModePersistenceService.clearAll()
|
||||
}
|
||||
logger.info("Complex plan persistence clear-all completed: durationMs={}", durationMs)
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionStatisticsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class ComplexPlanQueryService(
|
||||
private val satelliteModeQueryService: SatelliteModeQueryService
|
||||
) {
|
||||
|
||||
fun findMissionModes(satelliteId: Long): List<SurveyModeInfoDTO> =
|
||||
satelliteModeQueryService.findMissionModes(satelliteId)
|
||||
|
||||
fun missionStatistics(satelliteId: Long): MissionStatisticsDTO =
|
||||
satelliteModeQueryService.missionStatistics(satelliteId)
|
||||
|
||||
fun buildPaintGeometry(satelliteId: Long): String =
|
||||
satelliteModeQueryService.buildPaintGeometry(satelliteId)
|
||||
|
||||
fun buildCellPaintGeometry(cellNum: Long): String =
|
||||
satelliteModeQueryService.buildCellPaintGeometry(cellNum)
|
||||
|
||||
fun findModesByInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
runId: Long? = null
|
||||
): List<SatelliteModeResponseDTO> =
|
||||
satelliteModeQueryService.findModesByInterval(satelliteIds, intervalStart, intervalEnd, runId)
|
||||
|
||||
fun findSnapshot(snapshotId: Long): SatelliteModeSnapshotResponseDTO =
|
||||
satelliteModeQueryService.findSnapshot(snapshotId)
|
||||
|
||||
fun findSnapshots(
|
||||
satelliteId: Long?,
|
||||
intervalStart: LocalDateTime?,
|
||||
intervalEnd: LocalDateTime?,
|
||||
activeOnly: Boolean
|
||||
): List<SatelliteModeSnapshotResponseDTO> =
|
||||
satelliteModeQueryService.findSnapshots(
|
||||
satelliteId = satelliteId,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
activeOnly = activeOnly
|
||||
)
|
||||
|
||||
fun findActiveSnapshot(
|
||||
satelliteId: Long,
|
||||
intervalStart: LocalDateTime? = null,
|
||||
intervalEnd: LocalDateTime? = null
|
||||
): SatelliteModeSnapshotResponseDTO? =
|
||||
satelliteModeQueryService.findActiveSnapshot(satelliteId, intervalStart, intervalEnd)
|
||||
|
||||
fun findModesBySnapshot(
|
||||
snapshotId: Long,
|
||||
intervalStart: LocalDateTime? = null,
|
||||
intervalEnd: LocalDateTime? = null
|
||||
): List<SatelliteModeResponseDTO> =
|
||||
satelliteModeQueryService.findModesBySnapshot(snapshotId, intervalStart, intervalEnd)
|
||||
|
||||
fun loadWorkingModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> =
|
||||
satelliteModeQueryService.loadWorkingModes(satelliteIds, intervalStart, intervalEnd)
|
||||
|
||||
fun loadConstraintModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> =
|
||||
satelliteModeQueryService.loadConstraintModes(satelliteIds, intervalStart, intervalEnd)
|
||||
|
||||
fun loadDayConstraintModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> =
|
||||
satelliteModeQueryService.loadDayConstraintModes(satelliteIds, intervalStart, intervalEnd)
|
||||
|
||||
fun loadRevolutionConstraintModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> =
|
||||
satelliteModeQueryService.loadRevolutionConstraintModes(satelliteIds, intervalStart, intervalEnd)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@Component
|
||||
class ComplexPlanRunMapper {
|
||||
|
||||
fun toResponse(entity: ComplexPlanRunEntity): ComplexPlanRunResponseDTO =
|
||||
ComplexPlanRunResponseDTO(
|
||||
runId = entity.id ?: 0,
|
||||
status = entity.status,
|
||||
intervalStart = entity.intervalStart,
|
||||
intervalEnd = entity.intervalEnd,
|
||||
satelliteIds = satelliteIds(entity),
|
||||
requestedBy = entity.requestedBy,
|
||||
createdAt = entity.createdAt.toUtcOffset(),
|
||||
startedAt = entity.startedAt?.toUtcOffset(),
|
||||
finishedAt = entity.finishedAt?.toUtcOffset(),
|
||||
durationMs = entity.durationMs,
|
||||
errorMessage = entity.errorMessage,
|
||||
calculatedModesCount = entity.calculatedModesCount,
|
||||
bookedModesCount = entity.bookedModesCount,
|
||||
complanModesCount = entity.complanModesCount,
|
||||
mixedModesCount = entity.mixedModesCount,
|
||||
affectedSatellitesCount = entity.affectedSatellitesCount,
|
||||
processedCellsCount = entity.processedCellsCount,
|
||||
bookedSlotLinksCount = entity.bookedSlotLinksCount,
|
||||
snapshotIds = snapshotIds(entity),
|
||||
requestPayload = entity.requestPayload,
|
||||
totalRequestsArea = entity.totalRequestsArea,
|
||||
coveredArea = entity.coveredArea,
|
||||
coveredAreaPercent = entity.coveredAreaPercent
|
||||
)
|
||||
|
||||
fun toSummary(entity: ComplexPlanRunEntity): ComplexPlanRunSummaryDTO =
|
||||
ComplexPlanRunSummaryDTO(
|
||||
runId = entity.id ?: 0,
|
||||
status = entity.status,
|
||||
intervalStart = entity.intervalStart,
|
||||
intervalEnd = entity.intervalEnd,
|
||||
satelliteIds = satelliteIds(entity),
|
||||
requestedBy = entity.requestedBy,
|
||||
createdAt = entity.createdAt.toUtcOffset(),
|
||||
startedAt = entity.startedAt?.toUtcOffset(),
|
||||
finishedAt = entity.finishedAt?.toUtcOffset(),
|
||||
durationMs = entity.durationMs,
|
||||
errorMessage = entity.errorMessage,
|
||||
calculatedModesCount = entity.calculatedModesCount,
|
||||
bookedModesCount = entity.bookedModesCount,
|
||||
complanModesCount = entity.complanModesCount,
|
||||
mixedModesCount = entity.mixedModesCount,
|
||||
affectedSatellitesCount = entity.affectedSatellitesCount,
|
||||
processedCellsCount = entity.processedCellsCount,
|
||||
bookedSlotLinksCount = entity.bookedSlotLinksCount,
|
||||
snapshotIds = snapshotIds(entity),
|
||||
totalRequestsArea = entity.totalRequestsArea,
|
||||
coveredArea = entity.coveredArea,
|
||||
coveredAreaPercent = entity.coveredAreaPercent
|
||||
)
|
||||
|
||||
private fun satelliteIds(entity: ComplexPlanRunEntity): List<Long> =
|
||||
entity.satellites
|
||||
.map { it.satelliteId }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
private fun snapshotIds(entity: ComplexPlanRunEntity): List<Long> =
|
||||
entity.snapshots
|
||||
.mapNotNull { it.id }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
private fun LocalDateTime.toUtcOffset(): OffsetDateTime =
|
||||
atOffset(ZoneOffset.UTC)
|
||||
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import tools.jackson.databind.json.JsonMapper
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class ComplexPlanRunOrchestrationService(
|
||||
private val complexPlanRunPersistenceService: ComplexPlanRunPersistenceService,
|
||||
private val complexPlanRunQueryService: ComplexPlanRunQueryService,
|
||||
private val complexPlanCalculationService: ComplexPlanCalculationService,
|
||||
private val satelliteService: SatelliteService,
|
||||
private val complexPlanExecutionLockService: ComplexPlanExecutionLockService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val objectMapper = JsonMapper.builder().findAndAddModules().build()
|
||||
|
||||
fun process(request: ComplexPlanProcessRequestDTO, requestedBy: String?): ComplexPlanRunResponseDTO {
|
||||
lateinit var selectedSatelliteIds: List<Long>
|
||||
val resolveSatelliteIdsMs = measureTimeMillis {
|
||||
selectedSatelliteIds = resolveSatelliteIds(request)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan orchestration stage resolve-satellites completed: satellites={}, durationMs={}",
|
||||
selectedSatelliteIds,
|
||||
resolveSatelliteIdsMs
|
||||
)
|
||||
|
||||
lateinit var createdRun: ComplexPlanRunEntity
|
||||
val createRunMs = measureTimeMillis {
|
||||
createdRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
requestedBy = requestedBy,
|
||||
requestPayload = objectMapper.writeValueAsString(request)
|
||||
)
|
||||
)
|
||||
}
|
||||
val runId = createdRun.id ?: error("Complex plan run id is null after create")
|
||||
logger.info(
|
||||
"Complex plan orchestration stage create-run completed: runId={}, durationMs={}",
|
||||
runId,
|
||||
createRunMs
|
||||
)
|
||||
|
||||
var startupLock: ComplexPlanExecutionLockService.ExecutionLockHandle? = null
|
||||
val acquireLockMs = measureTimeMillis {
|
||||
startupLock = complexPlanExecutionLockService.tryAcquire(selectedSatelliteIds)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan orchestration stage acquire-lock completed: runId={}, acquired={}, durationMs={}",
|
||||
runId,
|
||||
startupLock != null,
|
||||
acquireLockMs
|
||||
)
|
||||
if (startupLock == null) {
|
||||
val errorMessage = "Complex plan recalculation is already running for at least one requested satellite"
|
||||
complexPlanRunPersistenceService.markFailed(runId, errorMessage)
|
||||
throw CustomValidationException(errorMessage)
|
||||
}
|
||||
|
||||
try {
|
||||
lateinit var overlappingRunIds: List<Long>
|
||||
val overlapCheckMs = measureTimeMillis {
|
||||
overlappingRunIds = complexPlanRunPersistenceService.findOverlappingRunningRunIds(
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
excludedRunId = runId
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan orchestration stage overlap-check completed: runId={}, overlaps={}, durationMs={}",
|
||||
runId,
|
||||
overlappingRunIds,
|
||||
overlapCheckMs
|
||||
)
|
||||
if (overlappingRunIds.isNotEmpty()) {
|
||||
val errorMessage =
|
||||
"Complex plan recalculation is already running for overlapping interval, runIds=$overlappingRunIds"
|
||||
complexPlanRunPersistenceService.markFailed(runId, errorMessage)
|
||||
throw CustomValidationException(errorMessage)
|
||||
}
|
||||
|
||||
val markRunningMs = measureTimeMillis {
|
||||
complexPlanRunPersistenceService.markRunning(runId)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan orchestration stage mark-running completed: runId={}, durationMs={}",
|
||||
runId,
|
||||
markRunningMs
|
||||
)
|
||||
} finally {
|
||||
startupLock?.close()
|
||||
}
|
||||
|
||||
val result = try {
|
||||
lateinit var calculationResult: ComplexPlanCalculationResult
|
||||
val calculationMs = measureTimeMillis {
|
||||
calculationResult = complexPlanCalculationService.process(
|
||||
runId = runId,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
sun = request.sun,
|
||||
coverageSource = request.coverageSource ?: CoverageCalculationSource.SLOTS,
|
||||
countLat = request.countLat,
|
||||
countLong = request.countLong,
|
||||
coverageStrategy = request.coverageStrategy
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan orchestration stage calculation completed: runId={}, calculatedModes={}, durationMs={}",
|
||||
runId,
|
||||
calculationResult.calculatedModesCount,
|
||||
calculationMs
|
||||
)
|
||||
calculationResult
|
||||
} catch (ex: Exception) {
|
||||
complexPlanRunPersistenceService.markFailed(runId, ex.message ?: ex.javaClass.simpleName)
|
||||
logger.error("Complex plan execution failed: runId={}", runId, ex)
|
||||
throw ex
|
||||
}
|
||||
val markCompletedMs = measureTimeMillis {
|
||||
complexPlanRunPersistenceService.markCompleted(
|
||||
runId = runId,
|
||||
statistics = result.toStatistics()
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan orchestration stage mark-completed completed: runId={}, durationMs={}",
|
||||
runId,
|
||||
markCompletedMs
|
||||
)
|
||||
|
||||
lateinit var response: ComplexPlanRunResponseDTO
|
||||
val loadResponseMs = measureTimeMillis {
|
||||
response = complexPlanRunQueryService.getRun(runId)
|
||||
}
|
||||
logger.info(
|
||||
"Complex plan orchestration stage load-response completed: runId={}, durationMs={}",
|
||||
runId,
|
||||
loadResponseMs
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
private fun resolveSatelliteIds(request: ComplexPlanProcessRequestDTO): List<Long> =
|
||||
buildList {
|
||||
request.satelliteId?.let { add(it) }
|
||||
addAll(request.satelliteIds)
|
||||
}.distinct().ifEmpty { satelliteService.satellites.map { it.satelliteId }.distinct() }
|
||||
}
|
||||
|
||||
data class ComplexPlanCalculationResult(
|
||||
val calculatedModesCount: Int = 0,
|
||||
val bookedModesCount: Int = 0,
|
||||
val complanModesCount: Int = 0,
|
||||
val mixedModesCount: Int = 0,
|
||||
val affectedSatellitesCount: Int = 0,
|
||||
val processedCellsCount: Int = 0,
|
||||
val bookedSlotLinksCount: Int = 0,
|
||||
val totalRequestsArea: Double = 0.0,
|
||||
val coveredArea: Double = 0.0,
|
||||
val coveredAreaPercent: Double = 0.0
|
||||
) {
|
||||
fun toStatistics(): ComplexPlanRunStatisticsDTO =
|
||||
ComplexPlanRunStatisticsDTO(
|
||||
calculatedModesCount = calculatedModesCount,
|
||||
bookedModesCount = bookedModesCount,
|
||||
complanModesCount = complanModesCount,
|
||||
mixedModesCount = mixedModesCount,
|
||||
affectedSatellitesCount = affectedSatellitesCount,
|
||||
processedCellsCount = processedCellsCount,
|
||||
bookedSlotLinksCount = bookedSlotLinksCount,
|
||||
totalRequestsArea = totalRequestsArea,
|
||||
coveredArea = coveredArea,
|
||||
coveredAreaPercent = coveredAreaPercent
|
||||
)
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunSatelliteEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class ComplexPlanRunPersistenceService(
|
||||
private val complexPlanRunRepository: ComplexPlanRunRepository,
|
||||
private val clock: Clock
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val APPLICATION_SHUTDOWN_ERROR_MESSAGE = "Run marked as FAILED because the application was shutting down"
|
||||
const val STARTUP_RECOVERY_ERROR_MESSAGE = "Marked as FAILED on startup: previous process terminated before completion"
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Transactional
|
||||
fun createRun(createDTO: ComplexPlanRunCreateDTO): ComplexPlanRunEntity {
|
||||
validateInterval(createDTO.intervalStart, createDTO.intervalEnd)
|
||||
val now = LocalDateTime.now(clock)
|
||||
val entity = ComplexPlanRunEntity(
|
||||
status = ComplexPlanRunStatus.CREATED,
|
||||
createdAt = now,
|
||||
intervalStart = createDTO.intervalStart,
|
||||
intervalEnd = createDTO.intervalEnd,
|
||||
requestedBy = createDTO.requestedBy,
|
||||
requestPayload = createDTO.requestPayload
|
||||
)
|
||||
entity.satellites = createDTO.satelliteIds
|
||||
.distinct()
|
||||
.sorted()
|
||||
.map { satelliteId ->
|
||||
ComplexPlanRunSatelliteEntity(
|
||||
run = entity,
|
||||
satelliteId = satelliteId
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
val saved = complexPlanRunRepository.save(entity)
|
||||
logger.info(
|
||||
"complexPlanRun created: runId={}, satellites={}, interval=[{} - {}], requestedBy={}",
|
||||
saved.id,
|
||||
saved.satellites.map { it.satelliteId },
|
||||
saved.intervalStart,
|
||||
saved.intervalEnd,
|
||||
saved.requestedBy
|
||||
)
|
||||
return saved
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markRunning(runId: Long): ComplexPlanRunEntity {
|
||||
val entity = getRunOrThrow(runId)
|
||||
entity.status = ComplexPlanRunStatus.RUNNING
|
||||
entity.startedAt = LocalDateTime.now(clock)
|
||||
entity.finishedAt = null
|
||||
entity.durationMs = null
|
||||
entity.errorMessage = null
|
||||
logger.info("complexPlanRun started: runId={}, startedAt={}", runId, entity.startedAt)
|
||||
return entity
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markCompleted(runId: Long, statistics: ComplexPlanRunStatisticsDTO): ComplexPlanRunEntity {
|
||||
val entity = getRunOrThrow(runId)
|
||||
entity.status = ComplexPlanRunStatus.COMPLETED
|
||||
entity.finishedAt = LocalDateTime.now(clock)
|
||||
entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt)
|
||||
entity.errorMessage = null
|
||||
applyStatistics(entity, statistics)
|
||||
logger.info(
|
||||
"complexPlanRun completed: runId={}, durationMs={}, calculatedModes={}, bookedModes={}, complanModes={}, mixedModes={}, satellites={}, processedCells={}, bookedSlotLinks={}",
|
||||
runId,
|
||||
entity.durationMs,
|
||||
entity.calculatedModesCount,
|
||||
entity.bookedModesCount,
|
||||
entity.complanModesCount,
|
||||
entity.mixedModesCount,
|
||||
entity.affectedSatellitesCount,
|
||||
entity.processedCellsCount,
|
||||
entity.bookedSlotLinksCount
|
||||
)
|
||||
return entity
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markFailed(runId: Long, errorMessage: String?): ComplexPlanRunEntity {
|
||||
val entity = getRunOrThrow(runId)
|
||||
entity.status = ComplexPlanRunStatus.FAILED
|
||||
entity.finishedAt = LocalDateTime.now(clock)
|
||||
entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt)
|
||||
entity.errorMessage = errorMessage?.take(4000)
|
||||
logger.info(
|
||||
"complexPlanRun failed: runId={}, durationMs={}, error={}",
|
||||
runId,
|
||||
entity.durationMs,
|
||||
entity.errorMessage
|
||||
)
|
||||
return entity
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markActiveRunsFailedOnShutdown(
|
||||
errorMessage: String = APPLICATION_SHUTDOWN_ERROR_MESSAGE
|
||||
): Int {
|
||||
val interruptedStatuses = listOf(
|
||||
ComplexPlanRunStatus.CREATED,
|
||||
ComplexPlanRunStatus.RUNNING
|
||||
)
|
||||
val now = LocalDateTime.now(clock)
|
||||
val interruptedRuns = complexPlanRunRepository.findAllByStatusIn(interruptedStatuses)
|
||||
|
||||
interruptedRuns.forEach { entity ->
|
||||
entity.status = ComplexPlanRunStatus.FAILED
|
||||
entity.finishedAt = now
|
||||
entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt)
|
||||
entity.errorMessage = errorMessage.take(4000)
|
||||
}
|
||||
|
||||
if (interruptedRuns.isNotEmpty()) {
|
||||
logger.info(
|
||||
"complexPlanRun shutdown-fail: affectedRuns={}, runIds={}",
|
||||
interruptedRuns.size,
|
||||
interruptedRuns.mapNotNull { it.id }.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
return interruptedRuns.size
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markRunningRunsFailedOnStartup(
|
||||
errorMessage: String = STARTUP_RECOVERY_ERROR_MESSAGE
|
||||
): Int {
|
||||
val now = LocalDateTime.now(clock)
|
||||
val staleRuns = complexPlanRunRepository.findAllByStatusIn(listOf(ComplexPlanRunStatus.RUNNING))
|
||||
|
||||
staleRuns.forEach { entity ->
|
||||
entity.status = ComplexPlanRunStatus.FAILED
|
||||
entity.finishedAt = entity.finishedAt ?: now
|
||||
entity.durationMs = calculateDurationMs(entity.startedAt, entity.finishedAt)
|
||||
entity.errorMessage = errorMessage.take(4000)
|
||||
}
|
||||
|
||||
if (staleRuns.isNotEmpty()) {
|
||||
logger.info(
|
||||
"complexPlanRun startup-recovery: affectedRuns={}, runIds={}",
|
||||
staleRuns.size,
|
||||
staleRuns.mapNotNull { it.id }.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
return staleRuns.size
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getRun(runId: Long): ComplexPlanRunEntity {
|
||||
val entity = complexPlanRunRepository.findDetailedById(runId)
|
||||
?: throw CustomValidationException("Complex plan run $runId not found")
|
||||
entity.snapshots.size
|
||||
return entity
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findOverlappingRunningRunIds(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
excludedRunId: Long
|
||||
): List<Long> {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
if (satelliteIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return complexPlanRunRepository.findOverlappingRunIdsByStatusAndSatelliteIds(
|
||||
status = ComplexPlanRunStatus.RUNNING,
|
||||
satelliteIds = satelliteIds.distinct(),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
excludedRunId = excludedRunId
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteRun(runId: Long) {
|
||||
if (!complexPlanRunRepository.existsById(runId)) {
|
||||
throw CustomValidationException("Complex plan run $runId not found")
|
||||
}
|
||||
|
||||
complexPlanRunRepository.deleteById(runId)
|
||||
logger.info("complexPlanRun deleted: runId={}", runId)
|
||||
}
|
||||
|
||||
private fun getRunOrThrow(runId: Long): ComplexPlanRunEntity =
|
||||
complexPlanRunRepository.findById(runId)
|
||||
.orElseThrow { CustomValidationException("Complex plan run $runId not found") }
|
||||
|
||||
private fun applyStatistics(entity: ComplexPlanRunEntity, statistics: ComplexPlanRunStatisticsDTO) {
|
||||
entity.calculatedModesCount = statistics.calculatedModesCount
|
||||
entity.bookedModesCount = statistics.bookedModesCount
|
||||
entity.complanModesCount = statistics.complanModesCount
|
||||
entity.mixedModesCount = statistics.mixedModesCount
|
||||
entity.affectedSatellitesCount = statistics.affectedSatellitesCount
|
||||
entity.processedCellsCount = statistics.processedCellsCount
|
||||
entity.bookedSlotLinksCount = statistics.bookedSlotLinksCount
|
||||
entity.totalRequestsArea = statistics.totalRequestsArea
|
||||
entity.coveredArea = statistics.coveredArea
|
||||
entity.coveredAreaPercent = statistics.coveredAreaPercent
|
||||
}
|
||||
|
||||
private fun calculateDurationMs(startedAt: LocalDateTime?, finishedAt: LocalDateTime?): Long? {
|
||||
if (startedAt == null || finishedAt == null) {
|
||||
return null
|
||||
}
|
||||
return Duration.between(startedAt, finishedAt).toMillis().coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun validateInterval(intervalStart: LocalDateTime, intervalEnd: LocalDateTime) {
|
||||
if (intervalEnd.isBefore(intervalStart)) {
|
||||
throw CustomValidationException("Параметр intervalEnd должен быть больше или равен intervalStart")
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import jakarta.persistence.criteria.JoinType
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.jpa.domain.Specification
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunFilterDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class ComplexPlanRunQueryService(
|
||||
private val complexPlanRunRepository: ComplexPlanRunRepository,
|
||||
private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository,
|
||||
private val complexPlanRunMapper: ComplexPlanRunMapper
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getRun(runId: Long): ComplexPlanRunResponseDTO {
|
||||
lateinit var response: ComplexPlanRunResponseDTO
|
||||
val durationMs = measureTimeMillis {
|
||||
val run = complexPlanRunRepository.findDetailedById(runId)
|
||||
?: throw CustomValidationException("Complex plan run $runId not found")
|
||||
run.snapshots = satelliteModeSnapshotRepository.findAllByComplexPlanRun_IdOrderByIdAsc(runId).toMutableList()
|
||||
response = complexPlanRunMapper.toResponse(run)
|
||||
}
|
||||
logger.info(
|
||||
"complexPlanRun response loaded: runId={}, status={}, snapshots={}, durationMs={}",
|
||||
runId,
|
||||
response.status,
|
||||
response.snapshotIds.size,
|
||||
durationMs
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findRuns(filter: ComplexPlanRunFilterDTO): List<ComplexPlanRunSummaryDTO> =
|
||||
complexPlanRunRepository.findAll(
|
||||
buildSpecification(filter),
|
||||
Sort.by(
|
||||
Sort.Order.desc("createdAt"),
|
||||
Sort.Order.desc("id")
|
||||
)
|
||||
).map(complexPlanRunMapper::toSummary)
|
||||
|
||||
private fun buildSpecification(filter: ComplexPlanRunFilterDTO): Specification<space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity> =
|
||||
Specification { root, query, criteriaBuilder ->
|
||||
val predicates = mutableListOf<jakarta.persistence.criteria.Predicate>()
|
||||
query.distinct(true)
|
||||
|
||||
filter.status?.let { status ->
|
||||
predicates += criteriaBuilder.equal(root.get<space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus>("status"), status)
|
||||
}
|
||||
filter.createdFrom?.let { createdFrom ->
|
||||
predicates += criteriaBuilder.greaterThanOrEqualTo(root.get("createdAt"), createdFrom)
|
||||
}
|
||||
filter.createdTo?.let { createdTo ->
|
||||
predicates += criteriaBuilder.lessThanOrEqualTo(root.get("createdAt"), createdTo)
|
||||
}
|
||||
filter.intervalStart?.let { intervalStart ->
|
||||
predicates += criteriaBuilder.greaterThanOrEqualTo(root.get("intervalStart"), intervalStart)
|
||||
}
|
||||
filter.intervalEnd?.let { intervalEnd ->
|
||||
predicates += criteriaBuilder.lessThanOrEqualTo(root.get("intervalEnd"), intervalEnd)
|
||||
}
|
||||
filter.satelliteId?.let { satelliteId ->
|
||||
val satellitesJoin = root.join<Any, Any>("satellites", JoinType.LEFT)
|
||||
predicates += criteriaBuilder.equal(satellitesJoin.get<Long>("satelliteId"), satelliteId)
|
||||
}
|
||||
|
||||
criteriaBuilder.and(*predicates.toTypedArray())
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataAccessException
|
||||
import org.springframework.context.event.ContextClosedEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class ComplexPlanRunShutdownHandler(
|
||||
private val complexPlanRunPersistenceService: ComplexPlanRunPersistenceService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@EventListener(ContextClosedEvent::class)
|
||||
fun onContextClosed() {
|
||||
try {
|
||||
val updatedRuns = complexPlanRunPersistenceService.markActiveRunsFailedOnShutdown()
|
||||
if (updatedRuns > 0) {
|
||||
logger.info("complexPlanRun shutdown handler completed: updatedRuns={}", updatedRuns)
|
||||
}
|
||||
} catch (ex: DataAccessException) {
|
||||
logger.warn("complexPlanRun shutdown handler skipped due to data access issue: {}", ex.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.dao.DataAccessException
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class ComplexPlanRunStartupRecovery(
|
||||
private val complexPlanRunPersistenceService: ComplexPlanRunPersistenceService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
try {
|
||||
val recoveredRuns = complexPlanRunPersistenceService.markRunningRunsFailedOnStartup()
|
||||
if (recoveredRuns > 0) {
|
||||
logger.info("complexPlanRun startup recovery completed: recoveredRuns={}", recoveredRuns)
|
||||
}
|
||||
} catch (ex: DataAccessException) {
|
||||
logger.warn("complexPlanRun startup recovery skipped due to data access issue: {}", ex.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClientRequestException
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.reactive.function.client.bodyToFlux
|
||||
import reactor.core.publisher.Mono
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SatelliteMissionBatchRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SatelliteMissionBatchResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchResponseDTO
|
||||
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.SurveySlotDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class CoverageBatchClient(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) : CoverageCalculationClient {
|
||||
|
||||
override val source: CoverageCalculationSource = CoverageCalculationSource.SLOTS
|
||||
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
private data class CoverageBatchCallResult(
|
||||
val response: List<SlotCoverageBatchResponseDTO>,
|
||||
val durationMs: Long,
|
||||
val coverageStrategy: SlotCoverageStrategy
|
||||
)
|
||||
|
||||
private class CoverageBatchRemoteException(
|
||||
val statusCode: Int,
|
||||
message: String
|
||||
) : RuntimeException(message)
|
||||
|
||||
@Value("\${settings.slots-service:slots-service}")
|
||||
val url = ""
|
||||
|
||||
private fun client() = webClientBuilder.baseUrl(url).build()
|
||||
|
||||
fun plannedModes(
|
||||
satelliteIds: List<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, List<SurveySlotDTO>> {
|
||||
if (satelliteIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val selectedSatelliteIds = satelliteIds.distinct()
|
||||
logger.info(
|
||||
"slots-service plannedModes batch start: satellites={}, interval=[{} - {}]",
|
||||
selectedSatelliteIds.size,
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
|
||||
var response = emptyList<SatelliteMissionBatchResponseDTO>()
|
||||
val durationMs = measureTimeMillis {
|
||||
response = client()
|
||||
.post()
|
||||
.uri("/api/slots/mission/satellites")
|
||||
.bodyValue(
|
||||
SatelliteMissionBatchRequestDTO(
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
timeStart = intervalStart,
|
||||
timeStop = intervalEnd
|
||||
)
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux<SatelliteMissionBatchResponseDTO>()
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"slots-service plannedModes batch finish: satellites={}, chains={}, durationMs={}",
|
||||
selectedSatelliteIds.size,
|
||||
response.sumOf { it.slots.size },
|
||||
durationMs
|
||||
)
|
||||
|
||||
return response.associate { it.satelliteId to it.slots }
|
||||
}
|
||||
|
||||
override fun coverageModes(
|
||||
targets: List<SlotCoverageTargetDTO>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
satelliteIds: List<Long>,
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>,
|
||||
sun: Double?,
|
||||
coverageStrategy: SlotCoverageStrategy?
|
||||
): Map<Long, List<SlotDTO>> {
|
||||
if (targets.isEmpty() || satelliteIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val selectedSatelliteIds = satelliteIds.distinct()
|
||||
logger.info(
|
||||
"slots-service coverage batch start: targets={}, satellites={}, occupiedIntervals={}, interval=[{} - {}], sun={}, coverageStrategy={}",
|
||||
targets.size,
|
||||
selectedSatelliteIds.size,
|
||||
occupiedIntervals.size,
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
sun,
|
||||
coverageStrategy
|
||||
)
|
||||
|
||||
val effectiveStrategy = coverageStrategy ?: SlotCoverageStrategy.GREEDY
|
||||
|
||||
val result = try {
|
||||
requestCoverageModes(
|
||||
targets = targets,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
selectedSatelliteIds = selectedSatelliteIds,
|
||||
occupiedIntervals = occupiedIntervals,
|
||||
sun = sun,
|
||||
coverageStrategy = effectiveStrategy
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
if (effectiveStrategy == SlotCoverageStrategy.CONTINUOUS && isContinuousFallbackCandidate(ex)) {
|
||||
logger.warn(
|
||||
"slots-service coverage batch CONTINUOUS failed, fallback to GREEDY: targets={}, satellites={}, occupiedIntervals={}, interval=[{} - {}], causeType={}, cause={}",
|
||||
targets.size,
|
||||
selectedSatelliteIds.size,
|
||||
occupiedIntervals.size,
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
ex::class.simpleName,
|
||||
ex.message
|
||||
)
|
||||
requestCoverageModes(
|
||||
targets = targets,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
selectedSatelliteIds = selectedSatelliteIds,
|
||||
occupiedIntervals = occupiedIntervals,
|
||||
sun = sun,
|
||||
coverageStrategy = SlotCoverageStrategy.GREEDY
|
||||
)
|
||||
} else {
|
||||
throw toCustomError(ex)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}, effectiveCoverageStrategy={}",
|
||||
targets.size,
|
||||
result.response.size,
|
||||
result.response.sumOf { it.slots.size },
|
||||
result.durationMs,
|
||||
result.coverageStrategy
|
||||
)
|
||||
|
||||
return result.response.associate { it.targetId to it.slots }
|
||||
}
|
||||
|
||||
private fun requestCoverageModes(
|
||||
targets: List<SlotCoverageTargetDTO>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
selectedSatelliteIds: List<Long>,
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>,
|
||||
sun: Double?,
|
||||
coverageStrategy: SlotCoverageStrategy
|
||||
): CoverageBatchCallResult {
|
||||
var response = emptyList<SlotCoverageBatchResponseDTO>()
|
||||
val durationMs = measureTimeMillis {
|
||||
response = client()
|
||||
.post()
|
||||
.uri("/api/slots/poly-cover/batch")
|
||||
.bodyValue(
|
||||
SlotCoverageBatchRequestDTO(
|
||||
items = targets,
|
||||
timeStart = intervalStart,
|
||||
timeStop = intervalEnd,
|
||||
cov = true,
|
||||
satellites = selectedSatelliteIds,
|
||||
occupiedIntervals = occupiedIntervals,
|
||||
sun = sun,
|
||||
coverageStrategy = coverageStrategy
|
||||
)
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
val statusCode = response.statusCode().value()
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body ->
|
||||
Mono.error(CoverageBatchRemoteException(statusCode, extractErrorMessage(body)))
|
||||
}
|
||||
}
|
||||
.bodyToFlux<SlotCoverageBatchResponseDTO>()
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
}
|
||||
return CoverageBatchCallResult(
|
||||
response = response,
|
||||
durationMs = durationMs,
|
||||
coverageStrategy = coverageStrategy
|
||||
)
|
||||
}
|
||||
|
||||
private fun isContinuousFallbackCandidate(ex: Exception): Boolean =
|
||||
when (ex) {
|
||||
is CoverageBatchRemoteException -> ex.statusCode >= 500
|
||||
is WebClientRequestException -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun toCustomError(ex: Exception): CustomErrorException =
|
||||
when (ex) {
|
||||
is CustomErrorException -> ex
|
||||
is CoverageBatchRemoteException -> CustomErrorException(ex.message ?: "error")
|
||||
else -> CustomErrorException(ex.message ?: "error")
|
||||
}
|
||||
|
||||
private fun extractErrorMessage(body: String): String {
|
||||
val text = body.trim()
|
||||
if (text.isEmpty()) return "error"
|
||||
|
||||
return runCatching {
|
||||
val node = ObjectMapper().readTree(text)
|
||||
when {
|
||||
node.hasNonNull("error") -> node["error"].asText()
|
||||
node.hasNonNull("message") -> node["message"].asText()
|
||||
else -> text
|
||||
}
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
|
||||
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 java.time.LocalDateTime
|
||||
|
||||
interface CoverageCalculationClient {
|
||||
val source: CoverageCalculationSource
|
||||
|
||||
fun coverageModes(
|
||||
targets: List<SlotCoverageTargetDTO>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
satelliteIds: List<Long>,
|
||||
occupiedIntervals: List<OccupiedIntervalDTO> = emptyList(),
|
||||
sun: Double? = null,
|
||||
coverageStrategy: SlotCoverageStrategy? = null
|
||||
): Map<Long, List<SlotDTO>>
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
|
||||
@Service
|
||||
class CoverageCalculationClientResolver(
|
||||
clients: List<CoverageCalculationClient>
|
||||
) {
|
||||
private val clientsBySource = clients.associateBy { it.source }
|
||||
|
||||
fun resolve(source: CoverageCalculationSource): CoverageCalculationClient =
|
||||
clientsBySource[source]
|
||||
?: throw CustomValidationException("Unsupported coverage calculation source: $source")
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
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.Mono
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
|
||||
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.SurveySlotDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) : CoverageCalculationClient {
|
||||
|
||||
override val source: CoverageCalculationSource = CoverageCalculationSource.COVERAGE_SCHEME
|
||||
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}")
|
||||
val url = ""
|
||||
|
||||
private fun client() = webClientBuilder.baseUrl(url).build()
|
||||
|
||||
override fun coverageModes(
|
||||
targets: List<SlotCoverageTargetDTO>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
satelliteIds: List<Long>,
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>,
|
||||
sun: Double?,
|
||||
coverageStrategy: SlotCoverageStrategy?
|
||||
): Map<Long, List<SlotDTO>> {
|
||||
if (targets.isEmpty() || satelliteIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val selectedSatelliteIds = satelliteIds.distinct()
|
||||
|
||||
logger.info(
|
||||
"coverage-scheme-service coverage start: targets={}, satellites={}, occupiedIntervals={}, interval=[{} - {}]",
|
||||
targets.size,
|
||||
selectedSatelliteIds.size,
|
||||
occupiedIntervals.size,
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
|
||||
val result = linkedMapOf<Long, List<SlotDTO>>()
|
||||
val durationMs = measureTimeMillis {
|
||||
targets.forEach { target ->
|
||||
lateinit var response: CoverageSchemeResponseDTO
|
||||
val targetDurationMs = measureTimeMillis {
|
||||
response = calculateTarget(
|
||||
target = target,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
sun = sun
|
||||
)
|
||||
}
|
||||
result[target.targetId] = response.coverageScheme.map { it.toSlotDTO() }
|
||||
logger.info(
|
||||
"coverage-scheme-service target calculated: targetId={}, candidateSlots={}, durationMs={}",
|
||||
target.targetId,
|
||||
response.coverageScheme.size,
|
||||
targetDurationMs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"coverage-scheme-service coverage finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}",
|
||||
targets.size,
|
||||
result.size,
|
||||
result.values.sumOf { it.size },
|
||||
durationMs
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun calculateTarget(
|
||||
target: SlotCoverageTargetDTO,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
satelliteIds: List<Long>,
|
||||
sun: Double?
|
||||
): CoverageSchemeResponseDTO =
|
||||
client()
|
||||
.post()
|
||||
.uri("/api/coverage-schemes/calculate")
|
||||
.bodyValue(
|
||||
CoverageSchemeCalculateRequestDTO(
|
||||
polygonWkt = target.wkt,
|
||||
satelliteIds = satelliteIds,
|
||||
timeStart = intervalStart,
|
||||
timeEnd = intervalEnd,
|
||||
sunAngle = sun
|
||||
)
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToMono(CoverageSchemeResponseDTO::class.java)
|
||||
.block()
|
||||
?: CoverageSchemeResponseDTO()
|
||||
|
||||
private fun SurveySlotDTO.toSlotDTO() =
|
||||
SlotDTO(
|
||||
satelliteId = satelliteId,
|
||||
tn = tn,
|
||||
tk = tk,
|
||||
roll = roll,
|
||||
contour = contour,
|
||||
revolution = revolution,
|
||||
revolutionSign = revolutionSign,
|
||||
latitude = latitude,
|
||||
longitude = longitude
|
||||
)
|
||||
|
||||
private fun extractErrorMessage(body: String): String {
|
||||
val text = body.trim()
|
||||
if (text.isEmpty()) return "error"
|
||||
|
||||
return runCatching {
|
||||
val node = ObjectMapper().readTree(text)
|
||||
when {
|
||||
node.hasNonNull("error") -> node["error"].asText()
|
||||
node.hasNonNull("message") -> node["message"].asText()
|
||||
else -> text
|
||||
}
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
|
||||
import java.util.UUID
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
|
||||
@Service
|
||||
/**
|
||||
* Request/grid reader backed by pcp-request-service /v1 cells API.
|
||||
*/
|
||||
class EarthGridService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Value("\${settings.request-service:\${settings.earth-grid-service:pcp-request-service}}")
|
||||
val url = ""
|
||||
|
||||
private fun client() = webClientBuilder.baseUrl(url).build()
|
||||
|
||||
fun cells(countLat: Int? = null, countLong: Int? = null): Iterable<EarthCellWithRequestsDTO> {
|
||||
var cells = emptyList<EarthCellWithRequestsDTO>()
|
||||
val durationMs = measureTimeMillis {
|
||||
cells = client()
|
||||
.get()
|
||||
.uri(cellsWithRequestsUri(countLat, countLong))
|
||||
.retrieve()
|
||||
.bodyToMono(CellsWithRequestsResponseDto::class.java)
|
||||
.block()
|
||||
?.items
|
||||
.orEmpty()
|
||||
.map { cell -> cell.toEarthCellWithRequestsDto() }
|
||||
}
|
||||
logger.info(
|
||||
"request-service v1 cells with requests loaded: cells={}, durationMs={}",
|
||||
cells.size,
|
||||
durationMs
|
||||
)
|
||||
return cells
|
||||
}
|
||||
|
||||
private fun cellsWithRequestsUri(countLat: Int?, countLong: Int?): String {
|
||||
val params = mutableListOf("minImportance=$REQUEST_GRID_MIN_IMPORTANCE")
|
||||
countLat?.let { params += "countLat=$it" }
|
||||
countLong?.let { params += "countLong=$it" }
|
||||
return "/v1/cells/with-requests?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
private fun CellWithRequestsResponseDto.toEarthCellWithRequestsDto(): EarthCellWithRequestsDTO =
|
||||
EarthCellWithRequestsDTO(
|
||||
id = cellNum,
|
||||
num = cellNum,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
importance = importance,
|
||||
contour = contour,
|
||||
requests = requests.map { request -> request.toCellRequestDto() },
|
||||
)
|
||||
|
||||
private fun CellRequestFragmentResponseDto.toCellRequestDto(): CellRequestDOT =
|
||||
CellRequestDOT(
|
||||
requestId = requestId.toString(),
|
||||
covPercent = coveragePercent,
|
||||
importance = importance,
|
||||
contour = contour,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CellsWithRequestsResponseDto(
|
||||
val items: List<CellWithRequestsResponseDto> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CellWithRequestsResponseDto(
|
||||
val cellNum: Long = 0,
|
||||
val latitude: Double = 0.0,
|
||||
val longitude: Double = 0.0,
|
||||
val importance: Double = 0.0,
|
||||
val contour: String = "",
|
||||
val requests: List<CellRequestFragmentResponseDto> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class CellRequestFragmentResponseDto(
|
||||
val requestId: UUID = UUID(0L, 0L),
|
||||
val contour: String = "",
|
||||
val coveragePercent: Double = 0.0,
|
||||
val importance: Double = 0.0,
|
||||
)
|
||||
|
||||
private const val REQUEST_GRID_MIN_IMPORTANCE = "0.001"
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
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.SatelliteSummaryDTO
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
interface SatelliteCatalogClient {
|
||||
fun allSatellites(): List<SatelliteDTO>
|
||||
}
|
||||
|
||||
@Service
|
||||
class SatelliteCatalogWebClient(
|
||||
webClientBuilderProvider: ObjectProvider<WebClient.Builder>
|
||||
) : SatelliteCatalogClient {
|
||||
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Value("\${settings.satellite-catalog-service:pcp-satellite-catalog-service}")
|
||||
private val satelliteCatalogServiceUrl = ""
|
||||
|
||||
override fun allSatellites(): List<SatelliteDTO> {
|
||||
var summaries = emptyList<SatelliteSummaryDTO>()
|
||||
val summariesMs = measureTimeMillis {
|
||||
summaries = webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$satelliteCatalogServiceUrl/api/satellites")
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteSummaryDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
}
|
||||
logger.info(
|
||||
"satellite-catalog-service summaries loaded: satellites={}, durationMs={}",
|
||||
summaries.size,
|
||||
summariesMs
|
||||
)
|
||||
if (summaries.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
var satellites = emptyList<SatelliteDTO>()
|
||||
val detailsMs = measureTimeMillis {
|
||||
satellites = webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$satelliteCatalogServiceUrl/api/satellites/batch")
|
||||
.bodyValue(SatelliteBatchRequestDTO(ids = summaries.map { it.id }))
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
}
|
||||
logger.info(
|
||||
"satellite-catalog-service details loaded: requested={}, returned={}, durationMs={}",
|
||||
summaries.size,
|
||||
satellites.size,
|
||||
detailsMs
|
||||
)
|
||||
return satellites
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package space.nstart.pcp.pcp_satellites_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-complex-mission-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(
|
||||
topics = ["\${app.kafka.topics.satellites:pcp.satellites}"],
|
||||
groupId = SATELLITE_DELETED_CONSUMER_GROUP
|
||||
)
|
||||
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 pcp-complex-mission-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 pcp-complex-mission-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 pcp-complex-mission-service", exception)
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List<Long> =
|
||||
listOfNotNull(satelliteId, noradId).distinct()
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunSatelliteRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
|
||||
@Service
|
||||
class SatelliteDeletedService(
|
||||
private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository,
|
||||
private val satelliteModeRepository: SatelliteModeRepository,
|
||||
private val complexPlanRunSatelliteRepository: ComplexPlanRunSatelliteRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Transactional
|
||||
fun deleteSatelliteData(satelliteId: Long): SatelliteComplexMissionDeletionSummary {
|
||||
val modesDeletedByCascade = satelliteModeRepository.countBySatelliteId(satelliteId)
|
||||
val snapshotsBeforeDelete = satelliteModeSnapshotRepository.countBySatelliteId(satelliteId)
|
||||
val runLinksBeforeDelete = complexPlanRunSatelliteRepository.countBySatelliteId(satelliteId)
|
||||
|
||||
val snapshotsDeleted = satelliteModeSnapshotRepository.deleteAllBySatelliteId(satelliteId)
|
||||
val runLinksDeleted = complexPlanRunSatelliteRepository.deleteAllBySatelliteId(satelliteId)
|
||||
val summary = SatelliteComplexMissionDeletionSummary(
|
||||
satelliteId = satelliteId,
|
||||
snapshotsDeleted = snapshotsDeleted,
|
||||
modesDeletedByCascade = modesDeletedByCascade,
|
||||
runLinksDeleted = runLinksDeleted
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Deleted complex mission data for satellite: satelliteId={}, snapshotsDeleted={}, snapshotsBeforeDelete={}, modesDeletedByCascade={}, runLinksDeleted={}, runLinksBeforeDelete={}",
|
||||
summary.satelliteId,
|
||||
summary.snapshotsDeleted,
|
||||
snapshotsBeforeDelete,
|
||||
summary.modesDeletedByCascade,
|
||||
summary.runLinksDeleted,
|
||||
runLinksBeforeDelete
|
||||
)
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
data class SatelliteComplexMissionDeletionSummary(
|
||||
val satelliteId: Long,
|
||||
val snapshotsDeleted: Long,
|
||||
val modesDeletedByCascade: Long,
|
||||
val runLinksDeleted: Long
|
||||
)
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeBookedSlotEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeCellEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeType
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
|
||||
|
||||
@Component
|
||||
class SatelliteModeMapper {
|
||||
|
||||
fun toEntity(
|
||||
satelliteId: Long,
|
||||
survey: SurveyMode,
|
||||
snapshot: SatelliteModeSnapshotEntity? = null,
|
||||
complexPlanRun: ComplexPlanRunEntity? = null
|
||||
): SatelliteModeEntity {
|
||||
val (latitude, longitude) = resolveCoordinates(survey)
|
||||
val entity = SatelliteModeEntity(
|
||||
satelliteId = satelliteId,
|
||||
source = survey.source,
|
||||
type = SatelliteModeType.SURVEY,
|
||||
startTime = survey.time,
|
||||
endTime = survey.timeStop,
|
||||
revolution = survey.revolution,
|
||||
lat = latitude,
|
||||
longitude = longitude,
|
||||
duration = survey.duration,
|
||||
contourWkt = survey.contourWKT,
|
||||
roll = survey.roll,
|
||||
cellNum = survey.primaryCellNum(),
|
||||
snapshot = snapshot,
|
||||
complexPlanRun = complexPlanRun
|
||||
)
|
||||
|
||||
entity.bookedSlots = survey.bookedSlotIds
|
||||
.filter { it > 0 }
|
||||
.distinct()
|
||||
.map { bookedSlotId ->
|
||||
SatelliteModeBookedSlotEntity(
|
||||
mode = entity,
|
||||
bookedSlotId = bookedSlotId
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
entity.cells = survey.cellNumsForPersistence()
|
||||
.map { cellNum ->
|
||||
SatelliteModeCellEntity(
|
||||
mode = entity,
|
||||
cellNum = cellNum
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
return entity
|
||||
}
|
||||
|
||||
fun toSurveyMode(
|
||||
entity: SatelliteModeEntity,
|
||||
bookedSlotIds: List<Long> = emptyList(),
|
||||
cellNums: List<Long> = emptyList()
|
||||
): SurveyMode =
|
||||
SurveyMode(
|
||||
revolution = entity.revolution,
|
||||
time = entity.startTime,
|
||||
timeStop = entity.endTime,
|
||||
roll = entity.roll,
|
||||
latitude = entity.lat,
|
||||
longitude = entity.longitude,
|
||||
duration = entity.duration,
|
||||
contourWKT = entity.contourWkt ?: "",
|
||||
cellNum = primaryCellNum(cellNums, entity.cellNum),
|
||||
source = entity.source,
|
||||
bookedSlotIds = bookedSlotIds,
|
||||
cellNums = normalizeCellNums(cellNums, entity.cellNum)
|
||||
)
|
||||
|
||||
fun toResponse(
|
||||
entity: SatelliteModeEntity,
|
||||
bookedSlotIds: List<Long> = emptyList(),
|
||||
cellNums: List<Long> = emptyList()
|
||||
): SatelliteModeResponseDTO =
|
||||
SatelliteModeResponseDTO(
|
||||
id = entity.id ?: 0,
|
||||
satelliteId = entity.satelliteId,
|
||||
source = entity.source.name,
|
||||
type = entity.type.name,
|
||||
startTime = entity.startTime,
|
||||
endTime = entity.endTime,
|
||||
revolution = entity.revolution,
|
||||
lat = entity.lat,
|
||||
longitude = entity.longitude,
|
||||
duration = entity.duration,
|
||||
contourWkt = entity.contourWkt,
|
||||
roll = entity.roll,
|
||||
cellNum = primaryCellNum(cellNums, entity.cellNum),
|
||||
cellNums = normalizeCellNums(cellNums, entity.cellNum),
|
||||
bookedSlotIds = bookedSlotIds,
|
||||
snapshotId = entity.snapshot?.id
|
||||
)
|
||||
|
||||
private fun SurveyMode.primaryCellNum(): Long =
|
||||
cellNumsForPersistence().singleOrNull() ?: -1
|
||||
|
||||
private fun SurveyMode.cellNumsForPersistence(): List<Long> =
|
||||
normalizeCellNums(cellNums, cellNum)
|
||||
|
||||
private fun normalizeCellNums(cellNums: List<Long>, fallbackCellNum: Long): List<Long> {
|
||||
val normalized = cellNums
|
||||
.ifEmpty { if (fallbackCellNum > 0) listOf(fallbackCellNum) else emptyList() }
|
||||
.filter { it > 0 }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun primaryCellNum(cellNums: List<Long>, fallbackCellNum: Long): Long =
|
||||
normalizeCellNums(cellNums, fallbackCellNum).singleOrNull() ?: -1
|
||||
|
||||
private fun resolveCoordinates(survey: SurveyMode): Pair<Double, Double> {
|
||||
if (survey.latitude != 0.0 || survey.longitude != 0.0) {
|
||||
return survey.latitude to survey.longitude
|
||||
}
|
||||
if (survey.contourWKT.isBlank()) {
|
||||
return survey.latitude to survey.longitude
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val centroid = WKTReader().read(survey.contourWKT).centroid
|
||||
centroid.y to centroid.x
|
||||
}.getOrDefault(survey.latitude to survey.longitude)
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import java.time.Clock
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class SatelliteModePersistenceService(
|
||||
private val satelliteModeRepository: SatelliteModeRepository,
|
||||
private val satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository,
|
||||
private val satelliteModeCellRepository: SatelliteModeCellRepository,
|
||||
private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository,
|
||||
private val satelliteModeMapper: SatelliteModeMapper,
|
||||
private val clock: Clock
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Transactional
|
||||
fun deleteModesForInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
) {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
|
||||
val selectedSatelliteIds = satelliteIds.distinct()
|
||||
if (selectedSatelliteIds.isEmpty()) {
|
||||
logger.info("Удаление режимов пропущено: пустой список спутников")
|
||||
return
|
||||
}
|
||||
|
||||
var modeIds = emptyList<Long>()
|
||||
var snapshotIds = emptyList<Long>()
|
||||
val lookupMs = measureTimeMillis {
|
||||
modeIds = satelliteModeRepository.findIntersectingIdsBySatelliteIdsAndInterval(
|
||||
selectedSatelliteIds,
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
snapshotIds = satelliteModeSnapshotRepository.findIntersectingIdsBySatelliteIdsAndInterval(
|
||||
selectedSatelliteIds,
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode delete stage lookup completed: satellites={}, snapshots={}, modes={}, durationMs={}",
|
||||
selectedSatelliteIds,
|
||||
snapshotIds.size,
|
||||
modeIds.size,
|
||||
lookupMs
|
||||
)
|
||||
|
||||
if (modeIds.isEmpty() && snapshotIds.isEmpty()) {
|
||||
logger.info(
|
||||
"Для удаления не найдено пересекающихся snapshot/result-set: satellites={}, interval=[{} - {}]",
|
||||
selectedSatelliteIds,
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Удаление legacy snapshot/result-set: satellites={}, interval=[{} - {}], snapshots={}, modes={}",
|
||||
selectedSatelliteIds,
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
snapshotIds.size,
|
||||
modeIds.size
|
||||
)
|
||||
val deleteMs = measureTimeMillis {
|
||||
if (modeIds.isNotEmpty()) {
|
||||
satelliteModeBookedSlotRepository.deleteAllByModeIds(modeIds)
|
||||
satelliteModeCellRepository.deleteAllByModeIds(modeIds)
|
||||
satelliteModeRepository.deleteIntersectingBySatelliteIdsAndInterval(
|
||||
selectedSatelliteIds,
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
}
|
||||
if (snapshotIds.isNotEmpty()) {
|
||||
satelliteModeSnapshotRepository.deleteAllByIdInBatch(snapshotIds)
|
||||
}
|
||||
}
|
||||
logger.info("Удаление legacy snapshot/result-set завершено: durationMs={}", deleteMs)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun saveModesForInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
surveysBySatelliteId: Map<Long, List<SurveyMode>>,
|
||||
complexPlanRun: ComplexPlanRunEntity? = null
|
||||
): SatelliteModeSaveResult {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
|
||||
val selectedSatelliteIds = satelliteIds.distinct()
|
||||
if (selectedSatelliteIds.isEmpty()) {
|
||||
logger.info("Сохранение режимов пропущено: пустой список спутников")
|
||||
return SatelliteModeSaveResult()
|
||||
}
|
||||
|
||||
return saveSnapshotModes(
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = surveysBySatelliteId,
|
||||
complexPlanRun = complexPlanRun,
|
||||
activateSnapshot = true
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun replaceModesForInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
surveysBySatelliteId: Map<Long, List<SurveyMode>>,
|
||||
complexPlanRun: ComplexPlanRunEntity? = null
|
||||
): SatelliteModeSaveResult {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
val selectedSatelliteIds = satelliteIds.distinct()
|
||||
if (selectedSatelliteIds.isEmpty()) {
|
||||
logger.info("Atomic replace режимов пропущен: пустой список спутников")
|
||||
return SatelliteModeSaveResult()
|
||||
}
|
||||
|
||||
return saveSnapshotModes(
|
||||
satelliteIds = selectedSatelliteIds,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = surveysBySatelliteId,
|
||||
complexPlanRun = complexPlanRun,
|
||||
activateSnapshot = true
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun clearAll() {
|
||||
logger.info("Полная очистка таблиц satellite_mode_snapshots и satellite_modes")
|
||||
val durationMs = measureTimeMillis {
|
||||
satelliteModeBookedSlotRepository.deleteAllInBatch()
|
||||
satelliteModeCellRepository.deleteAllInBatch()
|
||||
satelliteModeRepository.deleteAllInBatch()
|
||||
satelliteModeSnapshotRepository.deleteAllInBatch()
|
||||
}
|
||||
logger.info("Полная очистка таблиц satellite_mode_snapshots и satellite_modes завершена: durationMs={}", durationMs)
|
||||
}
|
||||
|
||||
private fun saveSnapshotModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
surveysBySatelliteId: Map<Long, List<SurveyMode>>,
|
||||
complexPlanRun: ComplexPlanRunEntity?,
|
||||
activateSnapshot: Boolean
|
||||
): SatelliteModeSaveResult {
|
||||
lateinit var snapshots: List<SatelliteModeSnapshotEntity>
|
||||
val createSnapshotsMs = measureTimeMillis {
|
||||
snapshots = satelliteIds.map { satelliteId ->
|
||||
SatelliteModeSnapshotEntity(
|
||||
satelliteId = satelliteId,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
createdAt = LocalDateTime.now(clock),
|
||||
isActive = false,
|
||||
complexPlanRun = complexPlanRun
|
||||
)
|
||||
}
|
||||
satelliteModeSnapshotRepository.saveAll(snapshots)
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode save stage snapshots-created: snapshots={}, durationMs={}",
|
||||
snapshots.size,
|
||||
createSnapshotsMs
|
||||
)
|
||||
|
||||
lateinit var entities: List<SatelliteModeEntity>
|
||||
val mapEntitiesMs = measureTimeMillis {
|
||||
entities = snapshots.flatMap { snapshot ->
|
||||
surveysBySatelliteId[snapshot.satelliteId]
|
||||
.orEmpty()
|
||||
.filter { intersects(it.time, it.timeStop, intervalStart, intervalEnd) }
|
||||
.map { survey ->
|
||||
satelliteModeMapper.toEntity(
|
||||
satelliteId = snapshot.satelliteId,
|
||||
survey = survey,
|
||||
snapshot = snapshot,
|
||||
complexPlanRun = complexPlanRun
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode save stage entities-mapped: snapshots={}, modes={}, durationMs={}",
|
||||
snapshots.size,
|
||||
entities.size,
|
||||
mapEntitiesMs
|
||||
)
|
||||
|
||||
if (entities.isNotEmpty()) {
|
||||
logger.info(
|
||||
"Сохранение stage 4 snapshot: satellites={}, interval=[{} - {}], snapshots={}, modes={}",
|
||||
satelliteIds,
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
snapshots.size,
|
||||
entities.size
|
||||
)
|
||||
val saveModesMs = measureTimeMillis {
|
||||
satelliteModeRepository.saveAll(entities)
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode save stage modes-saved: modes={}, durationMs={}",
|
||||
entities.size,
|
||||
saveModesMs
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
"Сохранение stage 4 snapshot без режимов: satellites={}, interval=[{} - {}], snapshots={}",
|
||||
satelliteIds,
|
||||
intervalStart,
|
||||
intervalEnd,
|
||||
snapshots.size
|
||||
)
|
||||
}
|
||||
|
||||
val activeSnapshotIds = if (activateSnapshot) {
|
||||
val activateMs = measureTimeMillis {
|
||||
snapshots.forEach { snapshot ->
|
||||
satelliteModeSnapshotRepository.deactivateActiveSnapshots(snapshot.satelliteId)
|
||||
snapshot.isActive = true
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode save stage snapshots-activated: snapshots={}, durationMs={}",
|
||||
snapshots.size,
|
||||
activateMs
|
||||
)
|
||||
snapshots.mapNotNull { it.id }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Сохранение stage 4 snapshot завершено: snapshots={}, activeSnapshots={}",
|
||||
snapshots.mapNotNull { it.id },
|
||||
activeSnapshotIds
|
||||
)
|
||||
return SatelliteModeSaveResult.from(
|
||||
entities = entities,
|
||||
snapshotIds = snapshots.mapNotNull { it.id },
|
||||
activeSnapshotIds = activeSnapshotIds
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateInterval(intervalStart: LocalDateTime, intervalEnd: LocalDateTime) {
|
||||
if (intervalEnd.isBefore(intervalStart)) {
|
||||
throw CustomValidationException("Параметр intervalEnd должен быть больше или равен intervalStart")
|
||||
}
|
||||
}
|
||||
|
||||
private fun intersects(
|
||||
start1: LocalDateTime,
|
||||
end1: LocalDateTime,
|
||||
start2: LocalDateTime,
|
||||
end2: LocalDateTime
|
||||
): Boolean = start1 < end2 && start2 < end1
|
||||
}
|
||||
|
||||
data class SatelliteModeSaveResult(
|
||||
val calculatedModesCount: Int = 0,
|
||||
val bookedModesCount: Int = 0,
|
||||
val complanModesCount: Int = 0,
|
||||
val mixedModesCount: Int = 0,
|
||||
val affectedSatellitesCount: Int = 0,
|
||||
val processedCellsCount: Int = 0,
|
||||
val bookedSlotLinksCount: Int = 0,
|
||||
val modeIds: List<Long> = emptyList(),
|
||||
val snapshotIds: List<Long> = emptyList(),
|
||||
val activeSnapshotIds: List<Long> = emptyList()
|
||||
) {
|
||||
companion object {
|
||||
fun from(
|
||||
entities: List<SatelliteModeEntity>,
|
||||
snapshotIds: List<Long>,
|
||||
activeSnapshotIds: List<Long>
|
||||
): SatelliteModeSaveResult =
|
||||
SatelliteModeSaveResult(
|
||||
calculatedModesCount = entities.size,
|
||||
bookedModesCount = entities.count { it.source == SatelliteModeSource.SLOTS },
|
||||
complanModesCount = entities.count { it.source == SatelliteModeSource.COMPLAN },
|
||||
mixedModesCount = entities.count { it.source == SatelliteModeSource.MIXED },
|
||||
affectedSatellitesCount = entities.map { it.satelliteId }.distinct().size,
|
||||
processedCellsCount = entities.flatMap { entity -> entity.cells.map { it.cellNum } }
|
||||
.filter { it > 0 }
|
||||
.distinct()
|
||||
.size,
|
||||
bookedSlotLinksCount = entities.sumOf { it.bookedSlots.size },
|
||||
modeIds = entities.mapNotNull { it.id },
|
||||
snapshotIds = snapshotIds,
|
||||
activeSnapshotIds = activeSnapshotIds
|
||||
)
|
||||
}
|
||||
}
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.jpa.domain.Specification
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DailyDurationDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionStatisticsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.RevolutionDurationDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class SatelliteModeQueryService(
|
||||
private val satelliteModeRepository: SatelliteModeRepository,
|
||||
private val satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository,
|
||||
private val satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository,
|
||||
private val satelliteModeCellRepository: SatelliteModeCellRepository,
|
||||
private val satelliteModeMapper: SatelliteModeMapper,
|
||||
private val satelliteModeSnapshotMapper: SatelliteModeSnapshotMapper
|
||||
) {
|
||||
|
||||
private data class ModeKey(
|
||||
val id: Long?,
|
||||
val satelliteId: Long,
|
||||
val startTime: LocalDateTime,
|
||||
val endTime: LocalDateTime
|
||||
)
|
||||
|
||||
private val wktReader = WKTReader()
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun findMissionModes(satelliteId: Long): List<SurveyModeInfoDTO> =
|
||||
satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId)
|
||||
.let { modes ->
|
||||
val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id })
|
||||
modes.map { entity ->
|
||||
satelliteModeMapper.toSurveyMode(
|
||||
entity = entity,
|
||||
cellNums = cellNumsByModeId[entity.id].orEmpty()
|
||||
).toDTO()
|
||||
}
|
||||
}
|
||||
|
||||
fun missionStatistics(satelliteId: Long): MissionStatisticsDTO {
|
||||
val surveys = satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId)
|
||||
.let { modes ->
|
||||
val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id })
|
||||
modes.map { entity ->
|
||||
satelliteModeMapper.toSurveyMode(
|
||||
entity = entity,
|
||||
cellNums = cellNumsByModeId[entity.id].orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return MissionStatisticsDTO(
|
||||
modesCount = surveys.size,
|
||||
totalContourArea = surveys.sumOf { survey -> contourArea(survey.contourWKT) },
|
||||
revolutionDurations = surveys
|
||||
.groupBy { it.revolution }
|
||||
.toSortedMap()
|
||||
.map { (revolution, items) ->
|
||||
RevolutionDurationDTO(
|
||||
revolution = revolution,
|
||||
totalDuration = items.sumOf { it.duration }
|
||||
)
|
||||
},
|
||||
dailyDurations = surveys
|
||||
.flatMap { survey -> splitSurveyDurationByDay(survey) }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.toSortedMap()
|
||||
.map { (date, durations) ->
|
||||
DailyDurationDTO(
|
||||
date = date,
|
||||
totalDuration = durations.sum()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun buildPaintGeometry(satelliteId: Long): String {
|
||||
val contours = satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueOrderByStartTimeAsc(satelliteId)
|
||||
.mapNotNull { it.contourWkt?.takeIf(String::isNotBlank) }
|
||||
|
||||
if (contours.isEmpty()) {
|
||||
return "GEOMETRYCOLLECTION EMPTY"
|
||||
}
|
||||
|
||||
return contours.joinToString(
|
||||
prefix = "GEOMETRYCOLLECTION(",
|
||||
postfix = ")"
|
||||
)
|
||||
}
|
||||
|
||||
fun buildCellPaintGeometry(cellNum: Long): String {
|
||||
val contours = satelliteModeRepository.findAllByRelatedCellNumOrderByStartTimeAsc(cellNum)
|
||||
.mapNotNull { it.contourWkt?.takeIf(String::isNotBlank) }
|
||||
|
||||
if (contours.isEmpty()) {
|
||||
return "GEOMETRYCOLLECTION EMPTY"
|
||||
}
|
||||
|
||||
return contours.joinToString(
|
||||
prefix = "GEOMETRYCOLLECTION(",
|
||||
postfix = ")"
|
||||
)
|
||||
}
|
||||
|
||||
fun findModesByInterval(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
runId: Long? = null
|
||||
): List<SatelliteModeResponseDTO> {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
if (satelliteIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val distinctSatelliteIds = satelliteIds.distinct()
|
||||
val snapshots = if (runId == null) {
|
||||
satelliteModeSnapshotRepository
|
||||
.findAllBySatelliteIdInAndIsActiveTrueOrderBySatelliteIdAscCreatedAtDesc(distinctSatelliteIds)
|
||||
} else {
|
||||
satelliteModeSnapshotRepository
|
||||
.findAllByComplexPlanRun_IdAndSatelliteIdInOrderBySatelliteIdAscCreatedAtDesc(
|
||||
runId,
|
||||
distinctSatelliteIds
|
||||
)
|
||||
}
|
||||
val snapshotIds = snapshots.distinctBy { it.satelliteId }.mapNotNull { it.id }
|
||||
|
||||
return findModesBySnapshotIds(snapshotIds)
|
||||
.filter { mode -> mode.startTime < intervalEnd && intervalStart < mode.endTime }
|
||||
}
|
||||
|
||||
fun findModesByRun(runId: Long): List<SatelliteModeResponseDTO> {
|
||||
val modes = satelliteModeRepository.findAllByComplexPlanRun_IdOrderByStartTimeAsc(runId)
|
||||
val bookedSlotsByModeId = bookedSlotsByModeId(modes.mapNotNull { it.id })
|
||||
val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id })
|
||||
|
||||
return modes.map { mode ->
|
||||
satelliteModeMapper.toResponse(
|
||||
entity = mode,
|
||||
bookedSlotIds = bookedSlotsByModeId[mode.id].orEmpty(),
|
||||
cellNums = cellNumsByModeId[mode.id].orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun findSnapshot(snapshotId: Long): SatelliteModeSnapshotResponseDTO =
|
||||
satelliteModeSnapshotMapper.toResponse(
|
||||
satelliteModeSnapshotRepository.findById(snapshotId)
|
||||
.orElseThrow { CustomValidationException("Satellite mode snapshot $snapshotId not found") }
|
||||
)
|
||||
|
||||
fun findSnapshots(
|
||||
satelliteId: Long?,
|
||||
intervalStart: LocalDateTime?,
|
||||
intervalEnd: LocalDateTime?,
|
||||
activeOnly: Boolean
|
||||
): List<SatelliteModeSnapshotResponseDTO> =
|
||||
satelliteModeSnapshotRepository.findAll(
|
||||
buildSnapshotSpecification(
|
||||
satelliteId = satelliteId,
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
activeOnly = activeOnly
|
||||
),
|
||||
Sort.by(
|
||||
Sort.Order.asc("satelliteId"),
|
||||
Sort.Order.desc("intervalStart"),
|
||||
Sort.Order.desc("createdAt"),
|
||||
Sort.Order.desc("id")
|
||||
)
|
||||
).map(satelliteModeSnapshotMapper::toResponse)
|
||||
|
||||
fun findActiveSnapshot(
|
||||
satelliteId: Long,
|
||||
intervalStart: LocalDateTime? = null,
|
||||
intervalEnd: LocalDateTime? = null
|
||||
): SatelliteModeSnapshotResponseDTO? {
|
||||
if ((intervalStart == null) != (intervalEnd == null)) {
|
||||
throw CustomValidationException("Параметры intervalStart и intervalEnd должны передаваться вместе")
|
||||
}
|
||||
if (intervalStart != null && intervalEnd != null) {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
}
|
||||
return satelliteModeSnapshotRepository
|
||||
.findFirstBySatelliteIdAndIsActiveTrueOrderByCreatedAtDescIdDesc(satelliteId)
|
||||
?.let(satelliteModeSnapshotMapper::toResponse)
|
||||
}
|
||||
|
||||
fun findModesBySnapshot(
|
||||
snapshotId: Long,
|
||||
intervalStart: LocalDateTime? = null,
|
||||
intervalEnd: LocalDateTime? = null
|
||||
): List<SatelliteModeResponseDTO> {
|
||||
if ((intervalStart == null) != (intervalEnd == null)) {
|
||||
throw CustomValidationException("Параметры intervalStart и intervalEnd должны передаваться вместе")
|
||||
}
|
||||
if (intervalStart != null && intervalEnd != null) {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
}
|
||||
return findModesBySnapshotIds(listOf(snapshotId))
|
||||
.filter { mode ->
|
||||
if (intervalStart == null || intervalEnd == null) {
|
||||
true
|
||||
} else {
|
||||
mode.startTime < intervalEnd && intervalStart < mode.endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadWorkingModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> =
|
||||
loadConstraintModes(satelliteIds, intervalStart, intervalEnd)
|
||||
|
||||
fun loadConstraintModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
if (satelliteIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val distinctSatelliteIds = satelliteIds.distinct()
|
||||
lateinit var dayModes: List<SatelliteModeEntity>
|
||||
val dayModesMs = measureTimeMillis {
|
||||
dayModes = loadDayConstraintModeEntities(distinctSatelliteIds, intervalStart, intervalEnd)
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode query stage day-constraints loaded: satellites={}, modes={}, durationMs={}",
|
||||
distinctSatelliteIds,
|
||||
dayModes.size,
|
||||
dayModesMs
|
||||
)
|
||||
|
||||
lateinit var revolutionModes: List<SatelliteModeEntity>
|
||||
val revolutionModesMs = measureTimeMillis {
|
||||
revolutionModes = loadRevolutionConstraintModeEntities(dayModes)
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode query stage revolution-constraints loaded: satellites={}, modes={}, durationMs={}",
|
||||
distinctSatelliteIds,
|
||||
revolutionModes.size,
|
||||
revolutionModesMs
|
||||
)
|
||||
|
||||
val modes = (dayModes + revolutionModes)
|
||||
.distinctBy { entity -> ModeKey(entity.id, entity.satelliteId, entity.startTime, entity.endTime) }
|
||||
.sortedBy { it.startTime }
|
||||
|
||||
lateinit var bookedSlotsByModeId: Map<Long, List<Long>>
|
||||
lateinit var cellNumsByModeId: Map<Long, List<Long>>
|
||||
val linksMs = measureTimeMillis {
|
||||
val modeIds = modes.mapNotNull { it.id }
|
||||
bookedSlotsByModeId = bookedSlotsByModeId(modeIds)
|
||||
cellNumsByModeId = cellNumsByModeId(modeIds)
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode query stage mode-links loaded: modes={}, bookedSlotLinks={}, cellLinks={}, durationMs={}",
|
||||
modes.size,
|
||||
bookedSlotsByModeId.values.sumOf { it.size },
|
||||
cellNumsByModeId.values.sumOf { it.size },
|
||||
linksMs
|
||||
)
|
||||
|
||||
lateinit var result: Map<Long, MutableList<SurveyMode>>
|
||||
val mapMs = measureTimeMillis {
|
||||
result = modes
|
||||
.groupBy { it.satelliteId }
|
||||
.mapValues { (_, entities) ->
|
||||
entities.map { entity ->
|
||||
satelliteModeMapper.toSurveyMode(
|
||||
entity = entity,
|
||||
bookedSlotIds = bookedSlotsByModeId[entity.id].orEmpty(),
|
||||
cellNums = cellNumsByModeId[entity.id].orEmpty()
|
||||
)
|
||||
}.toMutableList()
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode query stage constraints mapped: satellites={}, modes={}, durationMs={}",
|
||||
result.keys,
|
||||
result.values.sumOf { it.size },
|
||||
mapMs
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
fun loadDayConstraintModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> {
|
||||
lateinit var result: Map<Long, MutableList<SurveyMode>>
|
||||
val durationMs = measureTimeMillis {
|
||||
result = mapConstraintEntitiesToSurveys(loadDayConstraintModeEntities(satelliteIds, intervalStart, intervalEnd))
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode query day-constraint modes loaded: satellites={}, modes={}, durationMs={}",
|
||||
satelliteIds.distinct(),
|
||||
result.values.sumOf { it.size },
|
||||
durationMs
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
fun loadRevolutionConstraintModes(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Map<Long, MutableList<SurveyMode>> {
|
||||
lateinit var result: Map<Long, MutableList<SurveyMode>>
|
||||
val durationMs = measureTimeMillis {
|
||||
val dayModes = loadDayConstraintModeEntities(satelliteIds, intervalStart, intervalEnd)
|
||||
result = mapConstraintEntitiesToSurveys(loadRevolutionConstraintModeEntities(dayModes))
|
||||
}
|
||||
logger.info(
|
||||
"Satellite mode query revolution-constraint modes loaded: satellites={}, modes={}, durationMs={}",
|
||||
satelliteIds.distinct(),
|
||||
result.values.sumOf { it.size },
|
||||
durationMs
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun loadDayConstraintModeEntities(
|
||||
satelliteIds: Collection<Long>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): List<SatelliteModeEntity> {
|
||||
validateInterval(intervalStart, intervalEnd)
|
||||
if (satelliteIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val dayStart = intervalStart.toLocalDate().atStartOfDay()
|
||||
val lastTouchedDate = touchedDateRange(intervalStart, intervalEnd).last()
|
||||
val dayEndExclusive = lastTouchedDate.plusDays(1).atStartOfDay()
|
||||
return satelliteModeRepository
|
||||
.findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
satelliteIds.distinct(),
|
||||
dayStart,
|
||||
dayEndExclusive
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadRevolutionConstraintModeEntities(dayModes: List<SatelliteModeEntity>): List<SatelliteModeEntity> =
|
||||
dayModes
|
||||
.groupBy { it.satelliteId }
|
||||
.flatMap { (satelliteId, entities) ->
|
||||
val revolutions = entities.map { it.revolution }.toSet()
|
||||
if (revolutions.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
satelliteModeRepository.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc(
|
||||
satelliteId,
|
||||
revolutions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapConstraintEntitiesToSurveys(modes: List<SatelliteModeEntity>): Map<Long, MutableList<SurveyMode>> {
|
||||
if (modes.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val uniqueModes = modes
|
||||
.distinctBy { entity -> ModeKey(entity.id, entity.satelliteId, entity.startTime, entity.endTime) }
|
||||
.sortedBy { it.startTime }
|
||||
val bookedSlotsByModeId = bookedSlotsByModeId(uniqueModes.mapNotNull { it.id })
|
||||
val cellNumsByModeId = cellNumsByModeId(uniqueModes.mapNotNull { it.id })
|
||||
|
||||
return uniqueModes
|
||||
.groupBy { it.satelliteId }
|
||||
.mapValues { (_, entities) ->
|
||||
entities.map { entity ->
|
||||
satelliteModeMapper.toSurveyMode(
|
||||
entity = entity,
|
||||
bookedSlotIds = bookedSlotsByModeId[entity.id].orEmpty(),
|
||||
cellNums = cellNumsByModeId[entity.id].orEmpty()
|
||||
)
|
||||
}.toMutableList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findModesBySnapshotIds(snapshotIds: Collection<Long>): List<SatelliteModeResponseDTO> {
|
||||
if (snapshotIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val modes = satelliteModeRepository.findAllBySnapshot_IdInOrderByStartTimeAsc(snapshotIds.distinct())
|
||||
val bookedSlotsByModeId = bookedSlotsByModeId(modes.mapNotNull { it.id })
|
||||
val cellNumsByModeId = cellNumsByModeId(modes.mapNotNull { it.id })
|
||||
return modes.map { mode ->
|
||||
satelliteModeMapper.toResponse(
|
||||
entity = mode,
|
||||
bookedSlotIds = bookedSlotsByModeId[mode.id].orEmpty(),
|
||||
cellNums = cellNumsByModeId[mode.id].orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bookedSlotsByModeId(modeIds: Collection<Long>): Map<Long, List<Long>> {
|
||||
if (modeIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
return satelliteModeBookedSlotRepository.findAllByMode_IdIn(modeIds)
|
||||
.groupBy(
|
||||
keySelector = { it.mode?.id ?: -1L },
|
||||
valueTransform = { it.bookedSlotId }
|
||||
)
|
||||
.mapValues { (_, bookedSlotIds) ->
|
||||
bookedSlotIds.filter { it > 0 }.distinct()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cellNumsByModeId(modeIds: Collection<Long>): Map<Long, List<Long>> {
|
||||
if (modeIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
return satelliteModeCellRepository.findAllByMode_IdIn(modeIds)
|
||||
.groupBy(
|
||||
keySelector = { it.mode?.id ?: -1L },
|
||||
valueTransform = { it.cellNum }
|
||||
)
|
||||
.mapValues { (_, cellNums) ->
|
||||
cellNums.filter { it > 0 }.distinct().sorted()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSnapshotSpecification(
|
||||
satelliteId: Long?,
|
||||
intervalStart: LocalDateTime?,
|
||||
intervalEnd: LocalDateTime?,
|
||||
activeOnly: Boolean
|
||||
): Specification<space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity> =
|
||||
Specification { root, _, criteriaBuilder ->
|
||||
val predicates = mutableListOf<jakarta.persistence.criteria.Predicate>()
|
||||
satelliteId?.let { predicates += criteriaBuilder.equal(root.get<Long>("satelliteId"), it) }
|
||||
intervalStart?.let { predicates += criteriaBuilder.equal(root.get<LocalDateTime>("intervalStart"), it) }
|
||||
intervalEnd?.let { predicates += criteriaBuilder.equal(root.get<LocalDateTime>("intervalEnd"), it) }
|
||||
if (activeOnly) {
|
||||
predicates += criteriaBuilder.isTrue(root.get("isActive"))
|
||||
}
|
||||
criteriaBuilder.and(*predicates.toTypedArray())
|
||||
}
|
||||
|
||||
private fun validateInterval(intervalStart: LocalDateTime, intervalEnd: LocalDateTime) {
|
||||
if (intervalEnd.isBefore(intervalStart)) {
|
||||
throw CustomValidationException("Параметр intervalEnd должен быть больше или равен intervalStart")
|
||||
}
|
||||
}
|
||||
|
||||
private fun touchedDateRange(intervalStart: LocalDateTime, intervalEnd: LocalDateTime): List<LocalDate> {
|
||||
val lastTouchedDate = when {
|
||||
intervalEnd.isEqual(intervalStart) -> intervalStart.toLocalDate()
|
||||
intervalEnd.toLocalTime() == LocalTime.MIDNIGHT -> intervalEnd.minusNanos(1).toLocalDate()
|
||||
else -> intervalEnd.minusNanos(1).toLocalDate()
|
||||
}
|
||||
return generateSequence(intervalStart.toLocalDate()) { current ->
|
||||
current.takeIf { it < lastTouchedDate }?.plusDays(1)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun contourArea(contourWkt: String): Double =
|
||||
if (contourWkt.isBlank()) 0.0
|
||||
else runCatching { wktReader.read(contourWkt).area }.getOrDefault(0.0)
|
||||
|
||||
private fun splitSurveyDurationByDay(survey: SurveyMode): List<Pair<LocalDate, Double>> {
|
||||
if (!survey.timeStop.isAfter(survey.time)) {
|
||||
return listOf(survey.time.toLocalDate() to survey.duration)
|
||||
}
|
||||
|
||||
val durations = mutableListOf<Pair<LocalDate, Double>>()
|
||||
var current = survey.time
|
||||
|
||||
while (current.toLocalDate() < survey.timeStop.toLocalDate()) {
|
||||
val nextDayStart = current.toLocalDate().plusDays(1).atStartOfDay()
|
||||
durations += current.toLocalDate() to Duration.between(current, nextDayStart).toSeconds().toDouble()
|
||||
current = nextDayStart
|
||||
}
|
||||
|
||||
durations += current.toLocalDate() to Duration.between(current, survey.timeStop).toSeconds().toDouble()
|
||||
return durations
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSnapshotEntity
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO
|
||||
|
||||
@Component
|
||||
class SatelliteModeSnapshotMapper {
|
||||
|
||||
fun toResponse(entity: SatelliteModeSnapshotEntity): SatelliteModeSnapshotResponseDTO =
|
||||
SatelliteModeSnapshotResponseDTO(
|
||||
id = entity.id ?: 0,
|
||||
satelliteId = entity.satelliteId,
|
||||
intervalStart = entity.intervalStart,
|
||||
intervalEnd = entity.intervalEnd,
|
||||
createdAt = entity.createdAt,
|
||||
isActive = entity.isActive,
|
||||
complexPlanRunId = entity.complexPlanRun?.id
|
||||
)
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_satellites_service.model.BLS
|
||||
import space.nstart.pcp.pcp_satellites_service.model.LongTermMission
|
||||
import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DailyDurationDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionStatisticsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.RevolutionDurationDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class SatelliteService(
|
||||
private val satelliteCatalogClient: SatelliteCatalogClient
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
private val wktReader = WKTReader()
|
||||
private val satellitesById = linkedMapOf<Long, SatelliteModel>()
|
||||
|
||||
val satellites: List<SatelliteModel>
|
||||
get() = ensureLoaded().values.toList()
|
||||
|
||||
fun clear() {
|
||||
satellites.forEach { satellite -> satellite.longMission.surveys.clear() }
|
||||
}
|
||||
|
||||
fun all() = satellites.map { sat -> sat.toDTO() }
|
||||
|
||||
fun byId(id: Long) = ensureLoaded()[id]?.toDTO()
|
||||
|
||||
fun complexMission(id: Long) =
|
||||
ensureLoaded()[id]
|
||||
?.longMission?.surveys?.sortedBy { survey -> survey.time }?.map { survey -> survey.toDTO() }
|
||||
?: throw CustomErrorException("Нет КА с заданным идентификаторм $id")
|
||||
|
||||
fun missionStatistics(id: Long): MissionStatisticsDTO {
|
||||
val mission = ensureLoaded()[id]
|
||||
?.longMission
|
||||
?: throw CustomErrorException("Нет КА с заданным идентификаторм $id")
|
||||
|
||||
return MissionStatisticsDTO(
|
||||
modesCount = mission.surveys.size,
|
||||
totalContourArea = mission.surveys.sumOf { survey -> contourArea(survey.contourWKT) },
|
||||
revolutionDurations = mission.surveys
|
||||
.groupBy { it.revolution }
|
||||
.toSortedMap()
|
||||
.map { (revolution, surveys) ->
|
||||
RevolutionDurationDTO(
|
||||
revolution = revolution,
|
||||
totalDuration = surveys.sumOf { survey -> survey.duration }
|
||||
)
|
||||
},
|
||||
dailyDurations = mission.surveys
|
||||
.flatMap { survey -> splitSurveyDurationByDay(survey) }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.toSortedMap()
|
||||
.map { (date, durations) ->
|
||||
DailyDurationDTO(
|
||||
date = date,
|
||||
totalDuration = durations.sum()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun complexMissionForPaint(id: Long): String {
|
||||
val plan = ensureLoaded()[id]
|
||||
?.longMission?.surveys
|
||||
?: throw CustomErrorException("Нет КА с заданным идентификаторм $id")
|
||||
|
||||
var geom = "GEOMETRYCOLLECTION("
|
||||
plan.forEach { survey ->
|
||||
geom += "${survey.contourWKT},"
|
||||
}
|
||||
geom = geom.dropLast(1)
|
||||
geom += ")"
|
||||
|
||||
return geom
|
||||
}
|
||||
|
||||
fun hasResources(
|
||||
tStart: LocalDateTime,
|
||||
tStop: LocalDateTime,
|
||||
available: List<Long>,
|
||||
sats: MutableList<Long>
|
||||
): Boolean {
|
||||
sats.clear()
|
||||
var currentDay = tStart.toLocalDate()
|
||||
val lastDay = tStop.toLocalDate().minusDays(1)
|
||||
var hasResource = false
|
||||
|
||||
while (currentDay <= lastDay) {
|
||||
satellites.forEach { satellite ->
|
||||
if (available.contains(satellite.satelliteId) && satellite.hasResources(currentDay)) {
|
||||
hasResource = true
|
||||
if (!sats.contains(satellite.satelliteId)) {
|
||||
sats.add(satellite.satelliteId)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentDay = currentDay.plusDays(1)
|
||||
}
|
||||
return hasResource
|
||||
}
|
||||
|
||||
private fun ensureLoaded(): Map<Long, SatelliteModel> {
|
||||
if (satellitesById.isNotEmpty()) {
|
||||
logger.info("Satellite cache hit: satellites={}", satellitesById.size)
|
||||
return satellitesById
|
||||
}
|
||||
|
||||
synchronized(satellitesById) {
|
||||
if (satellitesById.isEmpty()) {
|
||||
val durationMs = measureTimeMillis {
|
||||
satelliteCatalogClient.allSatellites()
|
||||
.sortedBy { it.id }
|
||||
.forEach { satellite ->
|
||||
satellitesById[satellite.id] = satellite.toSatelliteModel()
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
"Satellite cache loaded: satellites={}, durationMs={}",
|
||||
satellitesById.size,
|
||||
durationMs
|
||||
)
|
||||
}
|
||||
}
|
||||
return satellitesById
|
||||
}
|
||||
|
||||
private fun SatelliteDTO.toSatelliteModel() = SatelliteModel(
|
||||
satelliteId = id,
|
||||
name = name,
|
||||
red = visualization.red,
|
||||
green = visualization.green,
|
||||
blue = visualization.blue,
|
||||
longMission = LongTermMission(),
|
||||
bls = observationProfile?.let {
|
||||
BLS(
|
||||
captureAngle = it.captureAngle,
|
||||
sunAngleMin = it.sunAngleMin,
|
||||
durationMin = it.durationMinSeconds.toDouble(),
|
||||
durationMax = it.durationMaxSeconds.toDouble(),
|
||||
mmi = it.mmiSeconds.toDouble(),
|
||||
dailyMaxDuration = it.dailyMaxDurationSeconds.toDouble(),
|
||||
revolutionMaxDuration = it.revolutionMaxDurationSeconds.toDouble()
|
||||
)
|
||||
} ?: BLS(),
|
||||
scanTLE = scanTle
|
||||
)
|
||||
|
||||
private fun contourArea(contourWkt: String): Double =
|
||||
if (contourWkt.isBlank()) {
|
||||
0.0
|
||||
} else {
|
||||
runCatching { wktReader.read(contourWkt).area }.getOrDefault(0.0)
|
||||
}
|
||||
|
||||
private fun splitSurveyDurationByDay(survey: SurveyMode): List<Pair<java.time.LocalDate, Double>> {
|
||||
if (!survey.timeStop.isAfter(survey.time)) {
|
||||
return listOf(survey.time.toLocalDate() to survey.duration)
|
||||
}
|
||||
|
||||
val durations = mutableListOf<Pair<java.time.LocalDate, Double>>()
|
||||
var current = survey.time
|
||||
|
||||
while (current.toLocalDate() < survey.timeStop.toLocalDate()) {
|
||||
val nextDayStart = current.toLocalDate().plusDays(1).atStartOfDay()
|
||||
durations += current.toLocalDate() to Duration.between(current, nextDayStart).toSeconds().toDouble()
|
||||
current = nextDayStart
|
||||
}
|
||||
|
||||
durations += current.toLocalDate() to Duration.between(current, survey.timeStop).toSeconds().toDouble()
|
||||
return durations
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
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 org.springframework.web.reactive.function.client.bodyToFlux
|
||||
import org.springframework.web.util.UriComponentsBuilder
|
||||
import reactor.core.publisher.Mono
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
|
||||
@Value("\${settings.slots-service:slots-service}")
|
||||
val url = ""
|
||||
|
||||
private fun client() = webClientBuilder.baseUrl(url).build()
|
||||
|
||||
fun slots(wkt : String, tn : LocalDateTime, tk : LocalDateTime, sign : RevolutionSign?, cov : Boolean?, sats : List<Long>) =
|
||||
(
|
||||
if (sign == null) {
|
||||
client()
|
||||
.get()
|
||||
.uri(
|
||||
UriComponentsBuilder
|
||||
.fromUriString("/api/slots/poly-cover")
|
||||
.queryParam("wkt", wkt)
|
||||
.queryParam("timeStart", tn)
|
||||
.queryParam("timeStop", tk)
|
||||
.queryParam("cov", cov)
|
||||
.apply {
|
||||
sats.forEach { queryParam("satellites", it) }
|
||||
}
|
||||
.build()
|
||||
.toUriString()
|
||||
)
|
||||
} else {
|
||||
client()
|
||||
.get()
|
||||
.uri(
|
||||
"/api/slots/poly-cover?wkt={wkt}&timeStart={timeStart}&timeStop={timeStop}&revSign={revSign}&cov={cov}",
|
||||
wkt,
|
||||
tn,
|
||||
tk,
|
||||
sign,
|
||||
cov
|
||||
)
|
||||
}
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux<SlotDTO>()
|
||||
|
||||
|
||||
fun plan(id : Long, tn : LocalDateTime, tk : LocalDateTime) =
|
||||
client()
|
||||
.get()
|
||||
.uri(
|
||||
"/api/slots/mission/satellite?satellite_id={satelliteId}&timeStart={timeStart}&timeStop={timeStop}",
|
||||
id,
|
||||
tn,
|
||||
tk
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux< SurveySlotDTO>()
|
||||
|
||||
|
||||
fun bookReq(req: BookingRequestDTO): List<BookedSlotDTO> =
|
||||
client()
|
||||
.post()
|
||||
.uri("/api/slots/booking/request")
|
||||
.bodyValue(req)
|
||||
.retrieve()
|
||||
.bodyToFlux(BookedSlotDTO::class.java)
|
||||
.collectList()
|
||||
.block() ?: emptyList()
|
||||
|
||||
fun cancelReq(requestId: String): Int =
|
||||
client()
|
||||
.delete()
|
||||
.uri("/api/slots/booking/request?requestId={requestId}", requestId)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToMono(Int::class.java)
|
||||
.block() ?: 0
|
||||
|
||||
private fun extractErrorMessage(body: String): String {
|
||||
val text = body.trim()
|
||||
if (text.isEmpty()) return "error"
|
||||
|
||||
return runCatching {
|
||||
val node = ObjectMapper().readTree(text)
|
||||
when {
|
||||
node.hasNonNull("error") -> node["error"].asText()
|
||||
node.hasNonNull("message") -> node["message"].asText()
|
||||
else -> text
|
||||
}
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
}
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.locationtech.jts.geom.Geometry
|
||||
import org.locationtech.jts.geom.Polygon
|
||||
import org.locationtech.jts.io.WKTReader
|
||||
import org.locationtech.jts.io.WKTWriter
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.BLS
|
||||
import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
@Service
|
||||
class SurveyPlacementService {
|
||||
|
||||
data class PlacementDecision(
|
||||
val accepted: Boolean,
|
||||
val appliedSurvey: SurveyMode? = null,
|
||||
val adjustedCandidate: SurveyMode? = null,
|
||||
val absorbedSurveys: List<SurveyMode> = emptyList(),
|
||||
val trimmed: Boolean = false,
|
||||
val rejectionReason: String? = null
|
||||
)
|
||||
|
||||
private data class PlacementCandidate(
|
||||
val adjustedCandidate: SurveyMode,
|
||||
val absorbedSurveys: List<SurveyMode>,
|
||||
val mergedSurvey: SurveyMode,
|
||||
val resultingSurveys: List<SurveyMode>
|
||||
)
|
||||
|
||||
private data class PlacementValidation(
|
||||
val accepted: Boolean,
|
||||
val reason: String? = null
|
||||
)
|
||||
|
||||
private data class GeometryMergeAssessment(
|
||||
val mergeable: Boolean,
|
||||
val intersectionArea: Double? = null,
|
||||
val touchesOrIntersects: Boolean = false,
|
||||
val distance: Double? = null,
|
||||
val geometryChecked: Boolean = true
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val ROLL_EPSILON = 0.01
|
||||
private const val GEOMETRY_INTERSECTION_AREA_EPSILON = 1e-8
|
||||
private const val GEOMETRY_DISTANCE_EPSILON = 1e-6
|
||||
private const val MERGE_MAX_TIME_GAP_SECONDS = 1L
|
||||
}
|
||||
|
||||
private val logger: Logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun tryPlaceSurvey(
|
||||
existingSurveys: MutableList<SurveyMode>,
|
||||
candidateSurvey: SurveyMode,
|
||||
satellite: SatelliteModel
|
||||
): PlacementDecision {
|
||||
val constraints = satellite.bls
|
||||
val initialPlacement = buildPlacementCandidate(existingSurveys, candidateSurvey, satellite.satelliteId)
|
||||
val initialValidation = validatePlacement(initialPlacement, constraints)
|
||||
if (initialValidation.accepted) {
|
||||
applyPlacement(existingSurveys, initialPlacement)
|
||||
return PlacementDecision(
|
||||
accepted = true,
|
||||
appliedSurvey = initialPlacement.mergedSurvey,
|
||||
adjustedCandidate = initialPlacement.adjustedCandidate,
|
||||
absorbedSurveys = initialPlacement.absorbedSurveys
|
||||
)
|
||||
}
|
||||
|
||||
val adjustedPlacement = adjustPlacementToConstraints(existingSurveys, candidateSurvey, constraints, satellite.satelliteId)
|
||||
?: return PlacementDecision(
|
||||
accepted = false,
|
||||
rejectionReason = initialValidation.reason ?: "placement_rejected"
|
||||
)
|
||||
val adjustedValidation = validatePlacement(adjustedPlacement, constraints)
|
||||
if (!adjustedValidation.accepted) {
|
||||
return PlacementDecision(
|
||||
accepted = false,
|
||||
rejectionReason = adjustedValidation.reason ?: "placement_rejected"
|
||||
)
|
||||
}
|
||||
|
||||
applyPlacement(existingSurveys, adjustedPlacement)
|
||||
return PlacementDecision(
|
||||
accepted = true,
|
||||
appliedSurvey = adjustedPlacement.mergedSurvey,
|
||||
adjustedCandidate = adjustedPlacement.adjustedCandidate,
|
||||
absorbedSurveys = adjustedPlacement.absorbedSurveys,
|
||||
trimmed = adjustedPlacement.adjustedCandidate.timeStop != candidateSurvey.timeStop
|
||||
)
|
||||
}
|
||||
|
||||
fun mergeSurvey(
|
||||
existingSurveys: MutableList<SurveyMode>,
|
||||
candidateSurvey: SurveyMode,
|
||||
satellite: SatelliteModel
|
||||
): SurveyMode {
|
||||
val placement = buildPlacementCandidate(existingSurveys, candidateSurvey, satellite.satelliteId)
|
||||
applyPlacement(existingSurveys, placement)
|
||||
return placement.mergedSurvey
|
||||
}
|
||||
|
||||
private fun buildPlacementCandidate(
|
||||
existingSurveys: List<SurveyMode>,
|
||||
candidateSurvey: SurveyMode,
|
||||
satelliteId: Long
|
||||
): PlacementCandidate {
|
||||
val mergeChain = buildMergeChain(existingSurveys, candidateSurvey, satelliteId)
|
||||
val resultingSurveys = existingSurveys
|
||||
.filterNot { mergeChain.absorbedSurveys.contains(it) }
|
||||
.plus(mergeChain.mergedSurvey)
|
||||
.sortedBy { it.time }
|
||||
|
||||
return PlacementCandidate(
|
||||
adjustedCandidate = candidateSurvey,
|
||||
absorbedSurveys = mergeChain.absorbedSurveys,
|
||||
mergedSurvey = mergeChain.mergedSurvey,
|
||||
resultingSurveys = resultingSurveys
|
||||
)
|
||||
}
|
||||
|
||||
private fun adjustPlacementToConstraints(
|
||||
existingSurveys: List<SurveyMode>,
|
||||
candidateSurvey: SurveyMode,
|
||||
constraints: BLS,
|
||||
satelliteId: Long
|
||||
): PlacementCandidate? {
|
||||
val originalDurationSeconds = Duration.between(candidateSurvey.time, candidateSurvey.timeStop).seconds
|
||||
if (originalDurationSeconds <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
val minDurationSeconds = ceil(constraints.durationMin).toLong().coerceAtLeast(1L)
|
||||
var low = minDurationSeconds
|
||||
var high = originalDurationSeconds
|
||||
var bestPlacement: PlacementCandidate? = null
|
||||
|
||||
while (low <= high) {
|
||||
val mid = (low + high) / 2
|
||||
val adjustedCandidate = trimCandidate(candidateSurvey, mid)
|
||||
val placement = buildPlacementCandidate(existingSurveys, adjustedCandidate, satelliteId)
|
||||
val validation = validatePlacement(placement, constraints)
|
||||
if (validation.accepted) {
|
||||
bestPlacement = placement
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return bestPlacement
|
||||
}
|
||||
|
||||
private fun validatePlacement(
|
||||
placement: PlacementCandidate,
|
||||
constraints: BLS
|
||||
): PlacementValidation {
|
||||
val candidateDurationSeconds = Duration.between(
|
||||
placement.adjustedCandidate.time,
|
||||
placement.adjustedCandidate.timeStop
|
||||
).seconds.toDouble()
|
||||
if (candidateDurationSeconds < constraints.durationMin) {
|
||||
return PlacementValidation(false, "duration_min")
|
||||
}
|
||||
|
||||
if (placement.mergedSurvey.duration > constraints.durationMax) {
|
||||
return PlacementValidation(false, "duration_max")
|
||||
}
|
||||
|
||||
if (hasDifferentRollConflict(placement, constraints)) {
|
||||
return PlacementValidation(false, "different_roll_conflict")
|
||||
}
|
||||
|
||||
if (exceedsDailyMaxDuration(placement.resultingSurveys, constraints)) {
|
||||
return PlacementValidation(false, "daily_max_duration")
|
||||
}
|
||||
|
||||
if (exceedsRevolutionMaxDuration(placement.resultingSurveys, constraints)) {
|
||||
return PlacementValidation(false, "revolution_max_duration")
|
||||
}
|
||||
|
||||
return PlacementValidation(true)
|
||||
}
|
||||
|
||||
private fun applyPlacement(
|
||||
existingSurveys: MutableList<SurveyMode>,
|
||||
placement: PlacementCandidate
|
||||
) {
|
||||
existingSurveys.removeAll(placement.absorbedSurveys.toSet())
|
||||
existingSurveys.add(placement.mergedSurvey)
|
||||
existingSurveys.sortBy { it.time }
|
||||
}
|
||||
|
||||
private data class MergeChain(
|
||||
val absorbedSurveys: List<SurveyMode>,
|
||||
val mergedSurvey: SurveyMode
|
||||
)
|
||||
|
||||
private fun buildMergeChain(
|
||||
existingSurveys: List<SurveyMode>,
|
||||
candidateSurvey: SurveyMode,
|
||||
satelliteId: Long
|
||||
): MergeChain {
|
||||
val absorbed = linkedSetOf<SurveyMode>()
|
||||
var mergedSurvey = candidateSurvey
|
||||
var expanded = true
|
||||
|
||||
while (expanded) {
|
||||
expanded = false
|
||||
existingSurveys.forEach { existing ->
|
||||
if (absorbed.contains(existing)) {
|
||||
return@forEach
|
||||
}
|
||||
if (!sameRoll(existing.roll, candidateSurvey.roll)) {
|
||||
return@forEach
|
||||
}
|
||||
if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) {
|
||||
return@forEach
|
||||
}
|
||||
val overlapsInTime = intersectsInTime(
|
||||
existing.time,
|
||||
existing.timeStop,
|
||||
mergedSurvey.time,
|
||||
mergedSurvey.timeStop
|
||||
)
|
||||
val geometryMergeAssessment = assessMergeableGeometry(
|
||||
leftContour = existing.contourWKT,
|
||||
rightContour = mergedSurvey.contourWKT,
|
||||
satelliteId = satelliteId,
|
||||
allowContinuityWithoutArea = !overlapsInTime
|
||||
)
|
||||
if (!geometryMergeAssessment.mergeable) {
|
||||
if (geometryMergeAssessment.geometryChecked) {
|
||||
logMergeSkippedWithoutSufficientGeometry(
|
||||
satelliteId = satelliteId,
|
||||
candidateSurvey = mergedSurvey,
|
||||
existingSurvey = existing,
|
||||
intersectionArea = geometryMergeAssessment.intersectionArea ?: 0.0,
|
||||
distance = geometryMergeAssessment.distance,
|
||||
overlapsInTime = overlapsInTime
|
||||
)
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
val mergedContour = unionContoursIfPolygon(
|
||||
baseContour = mergedSurvey.contourWKT,
|
||||
contourToAdd = existing.contourWKT,
|
||||
satelliteId = satelliteId,
|
||||
candidateSurvey = mergedSurvey,
|
||||
existingSurvey = existing
|
||||
) ?: return@forEach
|
||||
absorbed += existing
|
||||
mergedSurvey = mergeSurveys(mergedSurvey, existing, mergedContour)
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
|
||||
return MergeChain(absorbed.toList(), mergedSurvey)
|
||||
}
|
||||
|
||||
private fun mergeSurveys(
|
||||
baseSurvey: SurveyMode,
|
||||
existingSurvey: SurveyMode,
|
||||
mergedContour: String
|
||||
): SurveyMode {
|
||||
val mergedStart = minOf(baseSurvey.time, existingSurvey.time)
|
||||
val mergedStop = maxOf(baseSurvey.timeStop, existingSurvey.timeStop)
|
||||
val mergedRevolution = if (existingSurvey.time < baseSurvey.time) {
|
||||
existingSurvey.revolution
|
||||
} else {
|
||||
baseSurvey.revolution
|
||||
}
|
||||
val mergedBookedSlotIds = (baseSurvey.bookedSlotIds + existingSurvey.bookedSlotIds).distinct()
|
||||
val mergedCellNums = normalizeCellNums(baseSurvey).apply {
|
||||
addAll(normalizeCellNums(existingSurvey))
|
||||
}
|
||||
return SurveyMode(
|
||||
revolution = mergedRevolution,
|
||||
time = mergedStart,
|
||||
timeStop = mergedStop,
|
||||
roll = baseSurvey.roll,
|
||||
latitude = baseSurvey.latitude,
|
||||
longitude = baseSurvey.longitude,
|
||||
duration = Duration.between(mergedStart, mergedStop).seconds.toDouble(),
|
||||
contourWKT = mergedContour,
|
||||
cellNum = mergedCellNums.singleOrNull() ?: -1,
|
||||
source = mergeSources(baseSurvey.source, existingSurvey.source),
|
||||
bookedSlotIds = mergedBookedSlotIds,
|
||||
cellNums = mergedCellNums.toList().sorted()
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasDifferentRollConflict(
|
||||
placement: PlacementCandidate,
|
||||
constraints: BLS
|
||||
): Boolean =
|
||||
placement.resultingSurveys.any { existing ->
|
||||
existing !== placement.mergedSurvey &&
|
||||
!sameRoll(existing.roll, placement.mergedSurvey.roll) &&
|
||||
intersectsWithMmi(
|
||||
existing.time,
|
||||
existing.timeStop,
|
||||
placement.mergedSurvey.time,
|
||||
placement.mergedSurvey.timeStop,
|
||||
constraints.mmi
|
||||
)
|
||||
}
|
||||
|
||||
private fun exceedsDailyMaxDuration(
|
||||
surveys: List<SurveyMode>,
|
||||
constraints: BLS
|
||||
): Boolean =
|
||||
surveys
|
||||
.flatMap { splitSurveyDurationByDay(it) }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.values
|
||||
.any { durations -> durations.sum() > constraints.dailyMaxDuration }
|
||||
|
||||
private fun exceedsRevolutionMaxDuration(
|
||||
surveys: List<SurveyMode>,
|
||||
constraints: BLS
|
||||
): Boolean =
|
||||
surveys
|
||||
.groupBy { it.revolution }
|
||||
.values
|
||||
.any { grouped -> grouped.sumOf { it.duration } > constraints.revolutionMaxDuration }
|
||||
|
||||
private fun splitSurveyDurationByDay(survey: SurveyMode): List<Pair<LocalDate, Double>> {
|
||||
if (!survey.timeStop.isAfter(survey.time)) {
|
||||
return listOf(survey.time.toLocalDate() to survey.duration)
|
||||
}
|
||||
|
||||
val durations = mutableListOf<Pair<LocalDate, Double>>()
|
||||
var current = survey.time
|
||||
|
||||
while (current.toLocalDate() < survey.timeStop.toLocalDate()) {
|
||||
val nextDayStart = current.toLocalDate().plusDays(1).atStartOfDay()
|
||||
durations += current.toLocalDate() to Duration.between(current, nextDayStart).seconds.toDouble()
|
||||
current = nextDayStart
|
||||
}
|
||||
|
||||
durations += current.toLocalDate() to Duration.between(current, survey.timeStop).seconds.toDouble()
|
||||
return durations
|
||||
}
|
||||
|
||||
private fun trimCandidate(candidateSurvey: SurveyMode, allowedDurationSeconds: Long): SurveyMode {
|
||||
val trimmedEnd = candidateSurvey.time.plusSeconds(allowedDurationSeconds.coerceAtLeast(0))
|
||||
return candidateSurvey.copyWith(
|
||||
timeStop = trimmedEnd,
|
||||
duration = Duration.between(candidateSurvey.time, trimmedEnd).seconds.toDouble()
|
||||
)
|
||||
}
|
||||
|
||||
private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON
|
||||
|
||||
private fun canMergeInTime(
|
||||
start1: LocalDateTime,
|
||||
stop1: LocalDateTime,
|
||||
start2: LocalDateTime,
|
||||
stop2: LocalDateTime
|
||||
): Boolean {
|
||||
val firstStop = minOf(stop1, stop2)
|
||||
val secondStart = maxOf(start1, start2)
|
||||
if (!secondStart.isAfter(firstStop)) {
|
||||
return true
|
||||
}
|
||||
val gapSeconds = Duration.between(firstStop, secondStart).seconds
|
||||
return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS
|
||||
}
|
||||
|
||||
private fun intersectsInTime(
|
||||
start1: LocalDateTime,
|
||||
stop1: LocalDateTime,
|
||||
start2: LocalDateTime,
|
||||
stop2: LocalDateTime
|
||||
): Boolean = start1 < stop2 && start2 < stop1
|
||||
|
||||
private fun intersectsWithMmi(
|
||||
start1: LocalDateTime,
|
||||
stop1: LocalDateTime,
|
||||
start2: LocalDateTime,
|
||||
stop2: LocalDateTime,
|
||||
mmiSeconds: Double
|
||||
): Boolean {
|
||||
val mmi = ceil(mmiSeconds).toLong()
|
||||
return start1 <= stop2.plusSeconds(mmi) && start2 <= stop1.plusSeconds(mmi)
|
||||
}
|
||||
|
||||
private fun mergeSources(left: SatelliteModeSource, right: SatelliteModeSource): SatelliteModeSource = when {
|
||||
left == right -> left
|
||||
left == SatelliteModeSource.MIXED || right == SatelliteModeSource.MIXED -> SatelliteModeSource.MIXED
|
||||
else -> SatelliteModeSource.MIXED
|
||||
}
|
||||
|
||||
private fun normalizeCellNums(survey: SurveyMode): MutableSet<Long> =
|
||||
survey.cellNums
|
||||
.ifEmpty { if (survey.cellNum > 0) listOf(survey.cellNum) else emptyList() }
|
||||
.filter { it > 0 }
|
||||
.toMutableSet()
|
||||
|
||||
private fun assessMergeableGeometry(
|
||||
leftContour: String,
|
||||
rightContour: String,
|
||||
satelliteId: Long,
|
||||
allowContinuityWithoutArea: Boolean
|
||||
): GeometryMergeAssessment {
|
||||
return try {
|
||||
val reader = WKTReader()
|
||||
val leftGeometry = reader.read(leftContour)
|
||||
val rightGeometry = reader.read(rightContour)
|
||||
logInvalidGeometryIfNeeded(satelliteId, "left", leftGeometry)
|
||||
logInvalidGeometryIfNeeded(satelliteId, "right", rightGeometry)
|
||||
val intersectionArea = leftGeometry.intersection(rightGeometry).area
|
||||
val touchesOrIntersects = leftGeometry.intersects(rightGeometry) || leftGeometry.touches(rightGeometry)
|
||||
val distance = if (touchesOrIntersects) 0.0 else leftGeometry.distance(rightGeometry)
|
||||
GeometryMergeAssessment(
|
||||
mergeable = intersectionArea > GEOMETRY_INTERSECTION_AREA_EPSILON ||
|
||||
(allowContinuityWithoutArea && (touchesOrIntersects || distance <= GEOMETRY_DISTANCE_EPSILON)),
|
||||
intersectionArea = intersectionArea,
|
||||
touchesOrIntersects = touchesOrIntersects,
|
||||
distance = distance
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Не удалось проверить непрерывность геометрий маршрутов при placement: {}", e.message)
|
||||
GeometryMergeAssessment(mergeable = false, geometryChecked = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unionContoursIfPolygon(
|
||||
baseContour: String,
|
||||
contourToAdd: String,
|
||||
satelliteId: Long,
|
||||
candidateSurvey: SurveyMode,
|
||||
existingSurvey: SurveyMode
|
||||
): String? {
|
||||
return try {
|
||||
val reader = WKTReader()
|
||||
val unionedGeometry = reader.read(baseContour).union(reader.read(contourToAdd))
|
||||
if (unionedGeometry !is Polygon) {
|
||||
logger.warn(
|
||||
"Merge skipped because union produced non-polygon geometry: satelliteId={}, geometryType={}, candidateStart={}, candidateEnd={}, existingStart={}, existingEnd={}, candidateRoll={}, existingRoll={}, candidateSource={}, existingSource={}",
|
||||
satelliteId,
|
||||
unionedGeometry.geometryType,
|
||||
candidateSurvey.time,
|
||||
candidateSurvey.timeStop,
|
||||
existingSurvey.time,
|
||||
existingSurvey.timeStop,
|
||||
candidateSurvey.roll,
|
||||
existingSurvey.roll,
|
||||
candidateSurvey.source,
|
||||
existingSurvey.source
|
||||
)
|
||||
return null
|
||||
}
|
||||
WKTWriter().write(unionedGeometry)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Не удалось объединить контуры маршрутов при placement: {}", e.message)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun logInvalidGeometryIfNeeded(
|
||||
satelliteId: Long,
|
||||
geometryRole: String,
|
||||
geometry: Geometry
|
||||
) {
|
||||
if (geometry.isValid) {
|
||||
return
|
||||
}
|
||||
logger.warn(
|
||||
"Merge geometry is invalid during placement: satelliteId={}, geometryRole={}, geometryType={}",
|
||||
satelliteId,
|
||||
geometryRole,
|
||||
geometry.geometryType
|
||||
)
|
||||
}
|
||||
|
||||
private fun logMergeSkippedWithoutSufficientGeometry(
|
||||
satelliteId: Long,
|
||||
candidateSurvey: SurveyMode,
|
||||
existingSurvey: SurveyMode,
|
||||
intersectionArea: Double,
|
||||
distance: Double?,
|
||||
overlapsInTime: Boolean
|
||||
) {
|
||||
if (overlapsInTime) {
|
||||
logger.warn(
|
||||
"Merge skipped because contours lack sufficient areal intersection despite temporal overlap: satelliteId={}, intersectionArea={}, epsilon={}, candidateStart={}, candidateEnd={}, existingStart={}, existingEnd={}, candidateRoll={}, existingRoll={}, candidateSource={}, existingSource={}, candidateCellNums={}, existingCellNums={}, candidateBookedSlotIds={}, existingBookedSlotIds={}",
|
||||
satelliteId,
|
||||
intersectionArea,
|
||||
GEOMETRY_INTERSECTION_AREA_EPSILON,
|
||||
candidateSurvey.time,
|
||||
candidateSurvey.timeStop,
|
||||
existingSurvey.time,
|
||||
existingSurvey.timeStop,
|
||||
candidateSurvey.roll,
|
||||
existingSurvey.roll,
|
||||
candidateSurvey.source,
|
||||
existingSurvey.source,
|
||||
normalizeCellNums(candidateSurvey).toList().sorted(),
|
||||
normalizeCellNums(existingSurvey).toList().sorted(),
|
||||
candidateSurvey.bookedSlotIds,
|
||||
existingSurvey.bookedSlotIds
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
"Merge skipped because contours are not continuous enough despite mergeable time window: satelliteId={}, intersectionArea={}, areaEpsilon={}, distance={}, distanceEpsilon={}, maxTimeGapSeconds={}, candidateStart={}, candidateEnd={}, existingStart={}, existingEnd={}, candidateRoll={}, existingRoll={}, candidateSource={}, existingSource={}, candidateCellNums={}, existingCellNums={}, candidateBookedSlotIds={}, existingBookedSlotIds={}",
|
||||
satelliteId,
|
||||
intersectionArea,
|
||||
GEOMETRY_INTERSECTION_AREA_EPSILON,
|
||||
distance,
|
||||
GEOMETRY_DISTANCE_EPSILON,
|
||||
MERGE_MAX_TIME_GAP_SECONDS,
|
||||
candidateSurvey.time,
|
||||
candidateSurvey.timeStop,
|
||||
existingSurvey.time,
|
||||
existingSurvey.timeStop,
|
||||
candidateSurvey.roll,
|
||||
existingSurvey.roll,
|
||||
candidateSurvey.source,
|
||||
existingSurvey.source,
|
||||
normalizeCellNums(candidateSurvey).toList().sorted(),
|
||||
normalizeCellNums(existingSurvey).toList().sorted(),
|
||||
candidateSurvey.bookedSlotIds,
|
||||
existingSurvey.bookedSlotIds
|
||||
)
|
||||
}
|
||||
|
||||
private fun SurveyMode.copyWith(
|
||||
time: LocalDateTime = this.time,
|
||||
timeStop: LocalDateTime = this.timeStop,
|
||||
duration: Double = this.duration,
|
||||
revolution: Long = this.revolution,
|
||||
contourWKT: String = this.contourWKT,
|
||||
cellNum: Long = this.cellNum,
|
||||
source: SatelliteModeSource = this.source,
|
||||
bookedSlotIds: List<Long> = this.bookedSlotIds,
|
||||
cellNums: List<Long> = this.cellNums
|
||||
) = SurveyMode(
|
||||
revolution = revolution,
|
||||
time = time,
|
||||
timeStop = timeStop,
|
||||
roll = roll,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
duration = duration,
|
||||
contourWKT = contourWKT,
|
||||
cellNum = cellNum,
|
||||
source = source,
|
||||
bookedSlotIds = bookedSlotIds,
|
||||
cellNums = cellNums
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
spring:
|
||||
application:
|
||||
name: pcp-complex-mission-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}
|
||||
|
||||
---
|
||||
# Fallback for direct bootRun when the external config server does not provide service datasource settings.
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: local
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/pcp_satellites}
|
||||
username: ${PCP_COMPLEX_MISSION_DATASOURCE_USERNAME:postgres}
|
||||
password: ${PCP_COMPLEX_MISSION_DATASOURCE_PASSWORD:password}
|
||||
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
|
||||
|
||||
---
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: ${PCP_COMPLEX_MISSION_DATASOURCE_URL:jdbc:postgresql://192.168.100.160:35400/pcp_satellites}
|
||||
username: ${PCP_COMPLEX_MISSION_DATASOURCE_USERNAME:postgres}
|
||||
password: ${PCP_COMPLEX_MISSION_DATASOURCE_PASSWORD:password}
|
||||
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
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE IF NOT EXISTS satellite_modes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
satellite_id BIGINT NOT NULL,
|
||||
source VARCHAR(20) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP NOT NULL,
|
||||
revolution BIGINT NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
longitude DOUBLE PRECISION NOT NULL,
|
||||
duration DOUBLE PRECISION NOT NULL,
|
||||
contour_wkt TEXT,
|
||||
roll DOUBLE PRECISION NOT NULL,
|
||||
cell_num BIGINT NOT NULL DEFAULT -1,
|
||||
CONSTRAINT chk_satellite_modes_source CHECK (source IN ('SLOTS', 'COMPLAN', 'MIXED')),
|
||||
CONSTRAINT chk_satellite_modes_type CHECK (type IN ('SURVEY'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_modes_satellite_id
|
||||
ON satellite_modes(satellite_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_modes_start_time
|
||||
ON satellite_modes(start_time);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_modes_end_time
|
||||
ON satellite_modes(end_time);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_modes_source
|
||||
ON satellite_modes(source);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_modes_satellite_interval
|
||||
ON satellite_modes(satellite_id, start_time, end_time);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS satellite_mode_booked_slot (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
mode_id BIGINT NOT NULL REFERENCES satellite_modes(id) ON DELETE CASCADE,
|
||||
booked_slot_id BIGINT NOT NULL,
|
||||
CONSTRAINT uq_satellite_mode_booked_slot_mode_booked_slot UNIQUE(mode_id, booked_slot_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_booked_slot_mode_id
|
||||
ON satellite_mode_booked_slot(mode_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_booked_slot_booked_slot_id
|
||||
ON satellite_mode_booked_slot(booked_slot_id);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE satellite_modes
|
||||
DROP CONSTRAINT IF EXISTS chk_satellite_modes_source;
|
||||
|
||||
ALTER TABLE satellite_modes
|
||||
ADD CONSTRAINT chk_satellite_modes_source
|
||||
CHECK (source IN ('SLOTS', 'COMPLAN', 'MIXED'));
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS satellite_mode_cell (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
mode_id BIGINT NOT NULL REFERENCES satellite_modes(id) ON DELETE CASCADE,
|
||||
cell_num BIGINT NOT NULL,
|
||||
CONSTRAINT uq_satellite_mode_cell_mode_cell UNIQUE(mode_id, cell_num)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_cell_mode_id
|
||||
ON satellite_mode_cell(mode_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_cell_cell_num
|
||||
ON satellite_mode_cell(cell_num);
|
||||
|
||||
INSERT INTO satellite_mode_cell (mode_id, cell_num)
|
||||
SELECT id, cell_num
|
||||
FROM satellite_modes
|
||||
WHERE cell_num > 0
|
||||
ON CONFLICT DO NOTHING;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
CREATE TABLE IF NOT EXISTS complex_plan_run (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP,
|
||||
duration_ms BIGINT,
|
||||
interval_start TIMESTAMP NOT NULL,
|
||||
interval_end TIMESTAMP NOT NULL,
|
||||
requested_by VARCHAR(255),
|
||||
error_message TEXT,
|
||||
calculated_modes_count INTEGER NOT NULL DEFAULT 0,
|
||||
booked_modes_count INTEGER NOT NULL DEFAULT 0,
|
||||
complan_modes_count INTEGER NOT NULL DEFAULT 0,
|
||||
mixed_modes_count INTEGER NOT NULL DEFAULT 0,
|
||||
affected_satellites_count INTEGER NOT NULL DEFAULT 0,
|
||||
processed_cells_count INTEGER NOT NULL DEFAULT 0,
|
||||
booked_slot_links_count INTEGER NOT NULL DEFAULT 0,
|
||||
request_payload TEXT,
|
||||
CONSTRAINT chk_complex_plan_run_status CHECK (status IN ('CREATED', 'RUNNING', 'COMPLETED', 'FAILED'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_complex_plan_run_status
|
||||
ON complex_plan_run(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_complex_plan_run_created_at
|
||||
ON complex_plan_run(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_complex_plan_run_interval_start
|
||||
ON complex_plan_run(interval_start);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_complex_plan_run_interval_end
|
||||
ON complex_plan_run(interval_end);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS complex_plan_run_satellite (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL REFERENCES complex_plan_run(id) ON DELETE CASCADE,
|
||||
satellite_id BIGINT NOT NULL,
|
||||
CONSTRAINT uq_complex_plan_run_satellite_run_satellite UNIQUE (run_id, satellite_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_complex_plan_run_satellite_run_id
|
||||
ON complex_plan_run_satellite(run_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_complex_plan_run_satellite_satellite_id
|
||||
ON complex_plan_run_satellite(satellite_id);
|
||||
|
||||
ALTER TABLE satellite_modes
|
||||
ADD COLUMN IF NOT EXISTS complex_plan_run_id BIGINT REFERENCES complex_plan_run(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_modes_complex_plan_run_id
|
||||
ON satellite_modes(complex_plan_run_id);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE satellite_modes
|
||||
DROP CONSTRAINT IF EXISTS chk_satellite_modes_source;
|
||||
|
||||
ALTER TABLE satellite_modes
|
||||
ADD CONSTRAINT chk_satellite_modes_source
|
||||
CHECK (source IN ('SLOTS', 'COMPLAN', 'MIXED', 'MANUAL'));
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
CREATE TABLE IF NOT EXISTS satellite_mode_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
satellite_id BIGINT NOT NULL,
|
||||
interval_start TIMESTAMP NOT NULL,
|
||||
interval_end TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
complex_plan_run_id BIGINT REFERENCES complex_plan_run(id) ON DELETE SET NULL,
|
||||
comment TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_satellite_id
|
||||
ON satellite_mode_snapshots(satellite_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_interval_start
|
||||
ON satellite_mode_snapshots(interval_start);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_interval_end
|
||||
ON satellite_mode_snapshots(interval_end);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_is_active
|
||||
ON satellite_mode_snapshots(is_active);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_mode_snapshots_run_id
|
||||
ON satellite_mode_snapshots(complex_plan_run_id);
|
||||
|
||||
ALTER TABLE satellite_modes
|
||||
ADD COLUMN IF NOT EXISTS snapshot_id BIGINT REFERENCES satellite_mode_snapshots(id) ON DELETE CASCADE;
|
||||
|
||||
INSERT INTO satellite_mode_snapshots (
|
||||
satellite_id,
|
||||
interval_start,
|
||||
interval_end,
|
||||
created_at,
|
||||
is_active,
|
||||
complex_plan_run_id,
|
||||
comment
|
||||
)
|
||||
SELECT
|
||||
sm.satellite_id,
|
||||
COALESCE(cpr.interval_start, MIN(sm.start_time)),
|
||||
COALESCE(cpr.interval_end, MAX(sm.end_time)),
|
||||
COALESCE(cpr.created_at, CURRENT_TIMESTAMP),
|
||||
TRUE,
|
||||
sm.complex_plan_run_id,
|
||||
CASE
|
||||
WHEN sm.complex_plan_run_id IS NULL THEN 'legacy-backfill-no-run'
|
||||
ELSE 'legacy-backfill-from-run'
|
||||
END
|
||||
FROM satellite_modes sm
|
||||
LEFT JOIN complex_plan_run cpr ON cpr.id = sm.complex_plan_run_id
|
||||
WHERE sm.snapshot_id IS NULL
|
||||
GROUP BY
|
||||
sm.satellite_id,
|
||||
sm.complex_plan_run_id,
|
||||
cpr.interval_start,
|
||||
cpr.interval_end,
|
||||
cpr.created_at;
|
||||
|
||||
UPDATE satellite_modes sm
|
||||
SET snapshot_id = snapshots.id
|
||||
FROM satellite_mode_snapshots snapshots
|
||||
WHERE sm.snapshot_id IS NULL
|
||||
AND snapshots.satellite_id = sm.satellite_id
|
||||
AND (
|
||||
(sm.complex_plan_run_id IS NOT NULL AND snapshots.complex_plan_run_id = sm.complex_plan_run_id)
|
||||
OR
|
||||
(sm.complex_plan_run_id IS NULL AND snapshots.complex_plan_run_id IS NULL)
|
||||
);
|
||||
|
||||
WITH ranked_snapshots AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY satellite_id, interval_start, interval_end
|
||||
ORDER BY created_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM satellite_mode_snapshots
|
||||
)
|
||||
UPDATE satellite_mode_snapshots snapshots
|
||||
SET is_active = CASE WHEN ranked_snapshots.rn = 1 THEN TRUE ELSE FALSE END
|
||||
FROM ranked_snapshots
|
||||
WHERE ranked_snapshots.id = snapshots.id;
|
||||
|
||||
ALTER TABLE satellite_modes
|
||||
ALTER COLUMN snapshot_id SET NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_satellite_modes_snapshot_id
|
||||
ON satellite_modes(snapshot_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_satellite_mode_snapshots_active_per_interval
|
||||
ON satellite_mode_snapshots(satellite_id, interval_start, interval_end)
|
||||
WHERE is_active = TRUE;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE complex_plan_run
|
||||
ADD COLUMN IF NOT EXISTS total_requests_area DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS covered_area DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS covered_area_percent DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp.pcp_satellites_service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
|
||||
@SpringBootTest
|
||||
class PcpBallisticsServiceApplicationTests {
|
||||
|
||||
@Test
|
||||
fun contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.verifyNoInteractions
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class ComplexPlanControllerTest {
|
||||
|
||||
@LocalServerPort
|
||||
private var port: Int = 0
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var complexMissionService: ComplexMissionService
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var complexPlanRunOrchestrationService: ComplexPlanRunOrchestrationService
|
||||
|
||||
@Test
|
||||
fun `process endpoint uses POST and logs response body as json`(output: CapturedOutput) {
|
||||
val request = ComplexPlanProcessRequestDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L)
|
||||
)
|
||||
doReturn(
|
||||
ComplexPlanRunResponseDTO(
|
||||
runId = 1L,
|
||||
status = ComplexPlanRunStatus.COMPLETED,
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC),
|
||||
startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC),
|
||||
finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC),
|
||||
durationMs = 60000,
|
||||
errorMessage = null,
|
||||
calculatedModesCount = 1,
|
||||
bookedModesCount = 1,
|
||||
complanModesCount = 0,
|
||||
mixedModesCount = 0,
|
||||
affectedSatellitesCount = 1,
|
||||
processedCellsCount = 1,
|
||||
bookedSlotLinksCount = 1,
|
||||
snapshotIds = listOf(1001L),
|
||||
requestPayload = "{}"
|
||||
)
|
||||
).`when`(complexPlanRunOrchestrationService).process(request, "tester")
|
||||
|
||||
WebClient.create("http://localhost:$port")
|
||||
.post()
|
||||
.uri("/api/com-plan/process")
|
||||
.header("X-Requested-By", "tester")
|
||||
.bodyValue(
|
||||
mapOf(
|
||||
"intervalStart" to "2026-03-26T10:00:00",
|
||||
"intervalEnd" to "2026-03-26T12:00:00",
|
||||
"satelliteIds" to listOf(22)
|
||||
)
|
||||
)
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.block()
|
||||
|
||||
verify(complexPlanRunOrchestrationService).process(
|
||||
request,
|
||||
"tester"
|
||||
)
|
||||
assertContains(output.out, "Complex plan process completed: runId=1, status=COMPLETED")
|
||||
assertContains(output.out, "responseBody={")
|
||||
assertContains(output.out, """"runId":1""")
|
||||
assertContains(output.out, """"status":"COMPLETED"""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process endpoint rejects sun outside allowed range`() {
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.post()
|
||||
.uri("/api/com-plan/process")
|
||||
.bodyValue(
|
||||
mapOf(
|
||||
"intervalStart" to "2026-03-26T10:00:00",
|
||||
"intervalEnd" to "2026-03-26T12:00:00",
|
||||
"satelliteIds" to listOf(22),
|
||||
"sun" to 90.1
|
||||
)
|
||||
)
|
||||
.exchangeToMono { clientResponse ->
|
||||
clientResponse.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("")
|
||||
.map { body -> clientResponse.statusCode().value() to body }
|
||||
}
|
||||
.block()!!
|
||||
|
||||
assertEquals(400, response.first)
|
||||
assertTrue(response.second.contains("sun"))
|
||||
verifyNoInteractions(complexPlanRunOrchestrationService)
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.controller
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistenceService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class ComplexPlanRunApiTest {
|
||||
|
||||
@LocalServerPort
|
||||
private var port: Int = 0
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunRepository: ComplexPlanRunRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeRepository: SatelliteModeRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModePersistenceService: SatelliteModePersistenceService
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
satelliteModeBookedSlotRepository.deleteAll()
|
||||
satelliteModeCellRepository.deleteAll()
|
||||
satelliteModeRepository.deleteAll()
|
||||
satelliteModeSnapshotRepository.deleteAll()
|
||||
complexPlanRunRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `run details endpoint returns persisted run`() {
|
||||
val runId = createCompletedRun(listOf(22L), "api-user")
|
||||
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/com-plan/runs/$runId")
|
||||
.retrieve()
|
||||
.bodyToMono(ComplexPlanRunResponseDTO::class.java)
|
||||
.block()!!
|
||||
|
||||
assertEquals(runId, response.runId)
|
||||
assertEquals(ComplexPlanRunStatus.COMPLETED, response.status)
|
||||
assertEquals(listOf(22L), response.satelliteIds)
|
||||
assertEquals("api-user", response.requestedBy)
|
||||
assertEquals(3, response.calculatedModesCount)
|
||||
assertEquals(10.0, response.totalRequestsArea)
|
||||
assertEquals(4.0, response.coveredArea)
|
||||
assertEquals(40.0, response.coveredAreaPercent)
|
||||
assertEquals(ZoneOffset.UTC, response.createdAt.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runs endpoint supports status and satellite filter`() {
|
||||
val completedRunId = createCompletedRun(listOf(22L), "one")
|
||||
createFailedRun(listOf(55L))
|
||||
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/com-plan/runs?status=COMPLETED&satelliteId=22")
|
||||
.retrieve()
|
||||
.bodyToFlux(ComplexPlanRunSummaryDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
assertEquals(1, response.size)
|
||||
assertEquals(completedRunId, response.single().runId)
|
||||
assertEquals(ComplexPlanRunStatus.COMPLETED, response.single().status)
|
||||
assertEquals(listOf(22L), response.single().satelliteIds)
|
||||
assertEquals(40.0, response.single().coveredAreaPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete run endpoint removes persisted run`() {
|
||||
val runId = createCompletedRun(listOf(22L), "api-user")
|
||||
|
||||
WebClient.create("http://localhost:$port")
|
||||
.delete()
|
||||
.uri("/api/com-plan/runs/$runId")
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
|
||||
assertFalse(complexPlanRunRepository.existsById(runId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `run modes endpoint returns modes sorted by time`() {
|
||||
val runId = createCompletedRun(listOf(22L), "api-user")
|
||||
val run = complexPlanRunRepository.findById(runId).orElseThrow()
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 2,
|
||||
time = intervalStart.plusMinutes(20),
|
||||
timeStop = intervalStart.plusMinutes(30),
|
||||
roll = 5.0,
|
||||
contourWKT = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))",
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
),
|
||||
SurveyMode(
|
||||
revolution = 1,
|
||||
time = intervalStart.plusMinutes(5),
|
||||
timeStop = intervalStart.plusMinutes(10),
|
||||
roll = 4.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
cellNums = listOf(42L)
|
||||
)
|
||||
)
|
||||
),
|
||||
complexPlanRun = run
|
||||
)
|
||||
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/com-plan/runs/$runId/modes")
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteModeResponseDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
assertEquals(listOf(1L, 2L), response.map { it.revolution })
|
||||
assertEquals(listOf(42L), response.first().cellNums)
|
||||
}
|
||||
|
||||
private fun createCompletedRun(satelliteIds: List<Long>, requestedBy: String?): Long {
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = satelliteIds,
|
||||
requestedBy = requestedBy,
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(run.id!!)
|
||||
complexPlanRunPersistenceService.markCompleted(
|
||||
run.id!!,
|
||||
ComplexPlanRunStatisticsDTO(
|
||||
calculatedModesCount = 3,
|
||||
bookedModesCount = 1,
|
||||
complanModesCount = 1,
|
||||
mixedModesCount = 1,
|
||||
affectedSatellitesCount = satelliteIds.size,
|
||||
processedCellsCount = 2,
|
||||
bookedSlotLinksCount = 4,
|
||||
totalRequestsArea = 10.0,
|
||||
coveredArea = 4.0,
|
||||
coveredAreaPercent = 40.0
|
||||
)
|
||||
)
|
||||
return run.id!!
|
||||
}
|
||||
|
||||
private fun createFailedRun(satelliteIds: List<Long>): Long {
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 15, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 16, 0),
|
||||
satelliteIds = satelliteIds,
|
||||
requestedBy = null,
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(run.id!!)
|
||||
complexPlanRunPersistenceService.markFailed(run.id!!, "boom")
|
||||
return run.id!!
|
||||
}
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.controller
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistenceService
|
||||
import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class SatelliteModeApiTest {
|
||||
|
||||
@LocalServerPort
|
||||
private var port: Int = 0
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModePersistenceService: SatelliteModePersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeRepository: SatelliteModeRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunRepository: ComplexPlanRunRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
satelliteModeBookedSlotRepository.deleteAll()
|
||||
satelliteModeCellRepository.deleteAll()
|
||||
satelliteModeRepository.deleteAll()
|
||||
satelliteModeSnapshotRepository.deleteAll()
|
||||
complexPlanRunRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modes endpoint returns interval-filtered modes from active snapshot regardless of snapshot interval`() {
|
||||
val snapshotStart = LocalDateTime.of(2026, 3, 26, 8, 0)
|
||||
val snapshotEnd = LocalDateTime.of(2026, 3, 26, 14, 0)
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = snapshotStart,
|
||||
intervalEnd = snapshotEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 100,
|
||||
time = intervalStart.plusMinutes(1),
|
||||
timeStop = intervalStart.plusMinutes(10),
|
||||
roll = 5.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
bookedSlotIds = listOf(777L),
|
||||
cellNums = listOf(42L, 43L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/satellites/modes?intervalStart=2026-03-26T10:00:00&intervalEnd=2026-03-26T12:00:00&satelliteId=22")
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteModeResponseDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
assertEquals(1, response.size)
|
||||
assertEquals(22L, response.single().satelliteId)
|
||||
assertEquals("COMPLAN", response.single().source)
|
||||
assertEquals(listOf(777L), response.single().bookedSlotIds)
|
||||
assertEquals(listOf(42L, 43L), response.single().cellNums)
|
||||
assertNotNull(response.single().snapshotId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite scoped modes endpoint returns interval-filtered modes from active snapshot`() {
|
||||
val snapshotStart = LocalDateTime.of(2026, 3, 27, 8, 0)
|
||||
val snapshotEnd = LocalDateTime.of(2026, 3, 27, 14, 0)
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 27, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0)
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = snapshotStart,
|
||||
intervalEnd = snapshotEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 101,
|
||||
time = intervalStart.plusMinutes(2),
|
||||
timeStop = intervalStart.plusMinutes(8),
|
||||
roll = 6.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.MANUAL,
|
||||
bookedSlotIds = listOf(778L),
|
||||
cellNums = listOf(44L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/satellites/22/modes?intervalStart=2026-03-27T10:00:00&intervalEnd=2026-03-27T12:00:00")
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteModeResponseDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
assertEquals(1, response.size)
|
||||
assertEquals("MANUAL", response.single().source)
|
||||
assertEquals(listOf(778L), response.single().bookedSlotIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite scoped modes endpoint returns modes for requested runId`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 27, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0)
|
||||
val firstRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
val secondRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 201,
|
||||
time = intervalStart.plusMinutes(2),
|
||||
timeStop = intervalStart.plusMinutes(8),
|
||||
roll = 6.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
)
|
||||
)
|
||||
),
|
||||
complexPlanRun = firstRun
|
||||
)
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 202,
|
||||
time = intervalStart.plusMinutes(3),
|
||||
timeStop = intervalStart.plusMinutes(9),
|
||||
roll = 7.0,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.MANUAL
|
||||
)
|
||||
)
|
||||
),
|
||||
complexPlanRun = secondRun
|
||||
)
|
||||
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri(
|
||||
"/api/satellites/22/modes" +
|
||||
"?intervalStart=2026-03-27T10:00:00" +
|
||||
"&intervalEnd=2026-03-27T12:00:00" +
|
||||
"&runId=${firstRun.id}"
|
||||
)
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteModeResponseDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
assertEquals(1, response.size)
|
||||
assertEquals(201L, response.single().revolution)
|
||||
assertEquals("COMPLAN", response.single().source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active snapshot endpoint ignores interval and resolves by satellite only`() {
|
||||
val snapshotStart = LocalDateTime.of(2026, 3, 30, 8, 0)
|
||||
val snapshotEnd = LocalDateTime.of(2026, 3, 30, 14, 0)
|
||||
val queryStart = LocalDateTime.of(2026, 3, 30, 10, 0)
|
||||
val queryEnd = LocalDateTime.of(2026, 3, 30, 11, 0)
|
||||
val snapshotId = satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = snapshotStart,
|
||||
intervalEnd = snapshotEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 301,
|
||||
time = queryStart.plusMinutes(5),
|
||||
timeStop = queryStart.plusMinutes(15),
|
||||
roll = 5.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
)
|
||||
)
|
||||
)
|
||||
).snapshotIds.single()
|
||||
|
||||
val response = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/satellites/22/snapshots/active?intervalStart=$queryStart&intervalEnd=$queryEnd")
|
||||
.retrieve()
|
||||
.bodyToMono(SatelliteModeSnapshotResponseDTO::class.java)
|
||||
.block()!!
|
||||
|
||||
assertEquals(snapshotId, response.id)
|
||||
assertEquals(22L, response.satelliteId)
|
||||
assertEquals(snapshotStart, response.intervalStart)
|
||||
assertEquals(snapshotEnd, response.intervalEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot endpoints return metadata and modes for explicit snapshot`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 28, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 28, 12, 0)
|
||||
val snapshotId = satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 102,
|
||||
time = intervalStart.plusMinutes(3),
|
||||
timeStop = intervalStart.plusMinutes(9),
|
||||
roll = 6.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
)
|
||||
)
|
||||
)
|
||||
).snapshotIds.single()
|
||||
|
||||
val snapshot = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/satellites/snapshots/$snapshotId")
|
||||
.retrieve()
|
||||
.bodyToMono(SatelliteModeSnapshotResponseDTO::class.java)
|
||||
.block()!!
|
||||
val modes = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/satellites/snapshots/$snapshotId/modes")
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteModeResponseDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
assertEquals(snapshotId, snapshot.id)
|
||||
assertEquals(22L, snapshot.satelliteId)
|
||||
assertEquals(intervalStart, snapshot.intervalStart)
|
||||
assertEquals(intervalEnd, snapshot.intervalEnd)
|
||||
assertEquals(true, snapshot.isActive)
|
||||
assertEquals(listOf(snapshotId), modes.mapNotNull { it.snapshotId }.distinct())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot modes endpoint filters by requested interval`() {
|
||||
val snapshotStart = LocalDateTime.of(2026, 3, 29, 8, 0)
|
||||
val snapshotEnd = LocalDateTime.of(2026, 3, 29, 14, 0)
|
||||
val requestedStart = LocalDateTime.of(2026, 3, 29, 10, 0)
|
||||
val requestedEnd = LocalDateTime.of(2026, 3, 29, 11, 0)
|
||||
val snapshotId = satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = snapshotStart,
|
||||
intervalEnd = snapshotEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 201,
|
||||
time = snapshotStart.plusMinutes(10),
|
||||
timeStop = snapshotStart.plusMinutes(20),
|
||||
roll = 5.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
),
|
||||
SurveyMode(
|
||||
revolution = 202,
|
||||
time = requestedStart.plusMinutes(5),
|
||||
timeStop = requestedStart.plusMinutes(15),
|
||||
roll = 5.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.MANUAL
|
||||
)
|
||||
)
|
||||
)
|
||||
).snapshotIds.single()
|
||||
|
||||
val modes = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri(
|
||||
"/api/satellites/snapshots/$snapshotId/modes?intervalStart=$requestedStart&intervalEnd=$requestedEnd"
|
||||
)
|
||||
.retrieve()
|
||||
.bodyToFlux(SatelliteModeResponseDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
|
||||
assertEquals(1, modes.size)
|
||||
assertEquals(202L, modes.single().revolution)
|
||||
assertEquals(snapshotId, modes.single().snapshotId)
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.dto
|
||||
|
||||
import jakarta.validation.Validation
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ComplexPlanProcessRequestDTOValidationTest {
|
||||
|
||||
private val validator = Validation.buildDefaultValidatorFactory().validator
|
||||
private val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
private val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
@Test
|
||||
fun `sun null is valid`() {
|
||||
assertTrue(validate(null).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sun minus 90 is valid`() {
|
||||
assertTrue(validate(-90.0).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sun 90 is valid`() {
|
||||
assertTrue(validate(90.0).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sun below minus 90 is invalid`() {
|
||||
val violations = validate(-90.1)
|
||||
|
||||
assertEquals(1, violations.size)
|
||||
assertEquals("sun", violations.single().propertyPath.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sun above 90 is invalid`() {
|
||||
val violations = validate(90.1)
|
||||
|
||||
assertEquals(1, violations.size)
|
||||
assertEquals("sun", violations.single().propertyPath.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aggregation counts null are valid`() {
|
||||
assertTrue(validate(sun = null, countLat = null, countLong = null).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aggregation counts one are valid`() {
|
||||
assertTrue(validate(sun = null, countLat = 1, countLong = 1).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `countLat below one is invalid`() {
|
||||
val violations = validate(sun = null, countLat = 0, countLong = 1)
|
||||
|
||||
assertEquals(1, violations.size)
|
||||
assertEquals("countLat", violations.single().propertyPath.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `countLong below one is invalid`() {
|
||||
val violations = validate(sun = null, countLat = 1, countLong = 0)
|
||||
|
||||
assertEquals(1, violations.size)
|
||||
assertEquals("countLong", violations.single().propertyPath.toString())
|
||||
}
|
||||
|
||||
private fun validate(
|
||||
sun: Double?,
|
||||
countLat: Int? = null,
|
||||
countLong: Int? = null
|
||||
) =
|
||||
validator.validate(
|
||||
ComplexPlanProcessRequestDTO(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
sun = sun,
|
||||
countLat = countLat,
|
||||
countLong = countLong
|
||||
)
|
||||
)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.model.mode
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class SurveyModeTest {
|
||||
|
||||
@Test
|
||||
fun `toDTO preserves slot ids and coordinates`() {
|
||||
val timeStart = LocalDateTime.of(2026, 3, 31, 12, 0)
|
||||
val timeStop = timeStart.plusMinutes(10)
|
||||
val surveyMode = SurveyMode(
|
||||
revolution = 42,
|
||||
time = timeStart,
|
||||
timeStop = timeStop,
|
||||
roll = 3.5,
|
||||
latitude = 55.75,
|
||||
longitude = 37.62,
|
||||
duration = 600.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 0))",
|
||||
bookedSlotIds = listOf(10L, 20L)
|
||||
)
|
||||
|
||||
val dto = surveyMode.toDTO()
|
||||
|
||||
assertEquals(timeStart, dto.time)
|
||||
assertEquals(timeStop, dto.timStop)
|
||||
assertEquals(listOf(10L, 20L), dto.slotIds)
|
||||
assertEquals(55.75, dto.latitude)
|
||||
assertEquals(37.62, dto.longitude)
|
||||
}
|
||||
}
|
||||
+776
@@ -0,0 +1,776 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.ArgumentMatchers.anyList
|
||||
import org.mockito.ArgumentMatchers.anyMap
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.ArgumentMatchers.isNull
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.inOrder
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.stubbing.Answer
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.ComplexPlanProperties
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.BLS
|
||||
import space.nstart.pcp.pcp_satellites_service.model.LongTermMission
|
||||
import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
|
||||
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.SurveySlotDTO
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ComplexPlanCalculationServiceTest {
|
||||
|
||||
private fun <T> anyListValue(): List<T> = anyList<T>() ?: emptyList()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun anySurveyMap(): Map<Long, List<SurveyMode>> =
|
||||
anyMap<Long, List<SurveyMode>>() as Map<Long, List<SurveyMode>>? ?: emptyMap()
|
||||
private fun <T> eqValue(value: T): T = eq(value) ?: value
|
||||
private fun captureMap(captor: ArgumentCaptor<Map<Long, List<SurveyMode>>>): Map<Long, List<SurveyMode>> =
|
||||
captor.capture() ?: emptyMap()
|
||||
private fun anyLocalDateTime(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun anyMutableLongList(): MutableList<Long> =
|
||||
(any(MutableList::class.java) as MutableList<Long>?) ?: mutableListOf()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun anyOccupiedIntervals(): List<OccupiedIntervalDTO> =
|
||||
(any(List::class.java) as List<OccupiedIntervalDTO>?) ?: emptyList()
|
||||
private fun captureOccupiedIntervals(captor: ArgumentCaptor<List<OccupiedIntervalDTO>>): List<OccupiedIntervalDTO> =
|
||||
captor.capture() ?: emptyList()
|
||||
private fun props(chunkSize: Int = 100) = ComplexPlanProperties().apply { batchChunkSize = chunkSize }
|
||||
private fun coverageBatchClient(): CoverageBatchClient =
|
||||
mock(CoverageBatchClient::class.java).also {
|
||||
`when`(it.source).thenReturn(CoverageCalculationSource.SLOTS)
|
||||
}
|
||||
private fun coverageResolver(vararg clients: CoverageCalculationClient) =
|
||||
CoverageCalculationClientResolver(clients.toList())
|
||||
|
||||
@Test
|
||||
fun `process calculates first and then atomically replaces persisted result`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = SatelliteService(testSatelliteCatalogClient())
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 11, 0)
|
||||
val cell = EarthCellWithRequestsDTO(
|
||||
id = 100,
|
||||
num = 42,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
requests = emptyList()
|
||||
)
|
||||
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd))
|
||||
.thenReturn(emptyMap())
|
||||
`when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(cell))
|
||||
`when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
22L to listOf(
|
||||
SurveySlotDTO(
|
||||
satelliteId = 22,
|
||||
tn = intervalStart.plusMinutes(5),
|
||||
tk = intervalStart.plusMinutes(15),
|
||||
roll = 10.0,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
revolution = 501,
|
||||
slotIds = listOf(9001)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
doReturn(
|
||||
mapOf(
|
||||
100L to listOf(
|
||||
SlotDTO(
|
||||
cycle = 0,
|
||||
satelliteId = 22,
|
||||
tn = intervalStart.plusMinutes(10),
|
||||
tk = intervalStart.plusMinutes(20),
|
||||
roll = 10.0,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))",
|
||||
revolution = 501,
|
||||
latitude = 10.0,
|
||||
longitude = 20.0
|
||||
)
|
||||
)
|
||||
)
|
||||
).`when`(coverageBatchClient).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
doReturn(SatelliteModeSaveResult(calculatedModesCount = 1, bookedModesCount = 1, affectedSatellitesCount = 1))
|
||||
.`when`(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
service.process(intervalStart, intervalEnd, listOf(22L))
|
||||
|
||||
val inOrder = inOrder(complexPlanPersistenceService, complexPlanQueryService, coverageBatchClient)
|
||||
inOrder.verify(complexPlanQueryService).loadConstraintModes(listOf(22L), intervalStart, intervalEnd)
|
||||
inOrder.verify(coverageBatchClient).plannedModes(listOf(22L), intervalStart, intervalEnd)
|
||||
inOrder.verify(coverageBatchClient).coverageModes(
|
||||
anyListValue(),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
eqValue(listOf(22L)),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
inOrder.verify(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
val surveysCaptor = ArgumentCaptor.forClass(Map::class.java) as ArgumentCaptor<Map<Long, List<SurveyMode>>>
|
||||
verify(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
captureMap(surveysCaptor),
|
||||
isNull()
|
||||
)
|
||||
|
||||
val savedSurvey = surveysCaptor.value.getValue(22L).single()
|
||||
assertEquals(SatelliteModeSource.SLOTS, savedSurvey.source)
|
||||
assertEquals(listOf(9001L), savedSurvey.bookedSlotIds)
|
||||
assertTrue(savedSurvey.time <= intervalStart.plusMinutes(5))
|
||||
assertTrue(savedSurvey.timeStop >= intervalStart.plusMinutes(15))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process returns covered area percent by survey area union`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = SatelliteService(testSatelliteCatalogClient())
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 11, 0)
|
||||
val requestContour = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))"
|
||||
val coveredContour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
val cell = EarthCellWithRequestsDTO(
|
||||
id = 100,
|
||||
num = 42,
|
||||
contour = requestContour,
|
||||
requests = listOf(
|
||||
CellRequestDOT(
|
||||
requestId = "request-1",
|
||||
contour = requestContour
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd))
|
||||
.thenReturn(emptyMap())
|
||||
`when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(cell))
|
||||
`when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
doReturn(
|
||||
mapOf(
|
||||
100L to listOf(
|
||||
SlotDTO(
|
||||
cycle = 0,
|
||||
satelliteId = 22,
|
||||
tn = intervalStart.plusMinutes(10),
|
||||
tk = intervalStart.plusMinutes(20),
|
||||
roll = 10.0,
|
||||
contour = coveredContour,
|
||||
revolution = 501,
|
||||
latitude = 10.0,
|
||||
longitude = 20.0
|
||||
)
|
||||
)
|
||||
)
|
||||
).`when`(coverageBatchClient).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
doReturn(SatelliteModeSaveResult(calculatedModesCount = 1, complanModesCount = 1, affectedSatellitesCount = 1))
|
||||
.`when`(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
val result = service.process(intervalStart, intervalEnd, listOf(22L))
|
||||
|
||||
assertEquals(2.0, result.totalRequestsArea, 0.0001)
|
||||
assertEquals(1.0, result.coveredArea, 0.0001)
|
||||
assertEquals(50.0, result.coveredAreaPercent, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process calculates total requests area from all request cell fragments`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = SatelliteService(testSatelliteCatalogClient())
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 11, 0)
|
||||
val firstRequestFragment = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
val secondRequestFragment = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
val cells = listOf(
|
||||
EarthCellWithRequestsDTO(
|
||||
id = 100,
|
||||
num = 42,
|
||||
contour = firstRequestFragment,
|
||||
requests = listOf(
|
||||
CellRequestDOT(
|
||||
id = 1001,
|
||||
requestId = "request-1",
|
||||
contour = firstRequestFragment
|
||||
)
|
||||
)
|
||||
),
|
||||
EarthCellWithRequestsDTO(
|
||||
id = 101,
|
||||
num = 43,
|
||||
contour = secondRequestFragment,
|
||||
requests = listOf(
|
||||
CellRequestDOT(
|
||||
id = 1002,
|
||||
requestId = "request-1",
|
||||
contour = secondRequestFragment
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd))
|
||||
.thenReturn(emptyMap())
|
||||
`when`(earthGridService.cells(isNull(), isNull())).thenReturn(cells)
|
||||
`when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
doReturn(emptyMap<Long, List<SlotDTO>>())
|
||||
.`when`(coverageBatchClient).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
doReturn(SatelliteModeSaveResult())
|
||||
.`when`(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
val result = service.process(intervalStart, intervalEnd, listOf(22L))
|
||||
|
||||
assertEquals(2.0, result.totalRequestsArea, 0.0001)
|
||||
assertEquals(0.0, result.coveredArea, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process requests coverage by chunks and stops before next chunk when resource is exhausted`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(chunkSize = 2),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 10, 0)
|
||||
val cells = listOf(
|
||||
EarthCellWithRequestsDTO(id = 1, num = 1, importance = 10.0, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", requests = emptyList()),
|
||||
EarthCellWithRequestsDTO(id = 2, num = 2, importance = 9.0, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))", requests = emptyList()),
|
||||
EarthCellWithRequestsDTO(id = 3, num = 3, importance = 8.0, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))", requests = emptyList())
|
||||
)
|
||||
|
||||
`when`(satelliteService.satellites).thenReturn(
|
||||
listOf(
|
||||
SatelliteModel(
|
||||
satelliteId = 22L,
|
||||
name = "T",
|
||||
bls = BLS(durationMax = 900.0, dailyMaxDuration = 605.0, revolutionMaxDuration = 2_000.0)
|
||||
)
|
||||
)
|
||||
)
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd))
|
||||
.thenReturn(emptyMap())
|
||||
`when`(earthGridService.cells(isNull(), isNull())).thenReturn(cells)
|
||||
`when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
doReturn(
|
||||
mapOf(
|
||||
1L to listOf(
|
||||
SlotDTO(
|
||||
cycle = 0,
|
||||
satelliteId = 22,
|
||||
tn = intervalStart.plusMinutes(5),
|
||||
tk = intervalStart.plusMinutes(15),
|
||||
roll = 8.0,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
revolution = 501,
|
||||
latitude = 10.0,
|
||||
longitude = 20.0
|
||||
)
|
||||
)
|
||||
)
|
||||
).`when`(coverageBatchClient).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
service.process(intervalStart, intervalEnd, listOf(22L))
|
||||
|
||||
verify(coverageBatchClient, times(1))
|
||||
.coverageModes(
|
||||
anyListValue(),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
eqValue(listOf(22L)),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process passes occupied intervals snapshot and refreshes it between chunks`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(chunkSize = 1),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 10, 0)
|
||||
val satellite = SatelliteModel(
|
||||
satelliteId = 22L,
|
||||
name = "T",
|
||||
longMission = LongTermMission(),
|
||||
bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0)
|
||||
)
|
||||
val firstCell = EarthCellWithRequestsDTO(
|
||||
id = 1,
|
||||
num = 101,
|
||||
importance = 10.0,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
requests = emptyList()
|
||||
)
|
||||
val secondCell = EarthCellWithRequestsDTO(
|
||||
id = 2,
|
||||
num = 102,
|
||||
importance = 9.0,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))",
|
||||
requests = emptyList()
|
||||
)
|
||||
|
||||
`when`(satelliteService.satellites).thenReturn(listOf(satellite))
|
||||
`when`(satelliteService.hasResources(eqValue(intervalStart), eqValue(intervalEnd), eqValue(listOf(22L)), anyMutableLongList()))
|
||||
.thenAnswer(Answer {
|
||||
val available = it.arguments[3] as MutableList<Long>
|
||||
available.clear()
|
||||
available += 22L
|
||||
true
|
||||
})
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd))
|
||||
.thenReturn(
|
||||
mapOf(
|
||||
22L to mutableListOf(
|
||||
SurveyMode(
|
||||
revolution = 500,
|
||||
time = intervalStart.minusMinutes(20),
|
||||
timeStop = intervalStart.minusMinutes(10),
|
||||
roll = 12.5,
|
||||
duration = 600.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.SLOTS
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(firstCell, secondCell))
|
||||
`when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
doReturn(
|
||||
mapOf(
|
||||
1L to listOf(
|
||||
SlotDTO(
|
||||
cycle = 0,
|
||||
satelliteId = 22,
|
||||
tn = intervalStart.plusMinutes(5),
|
||||
tk = intervalStart.plusMinutes(15),
|
||||
roll = 8.0,
|
||||
contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))",
|
||||
revolution = 501,
|
||||
latitude = 10.0,
|
||||
longitude = 20.0
|
||||
)
|
||||
)
|
||||
),
|
||||
emptyMap<Long, List<SlotDTO>>()
|
||||
).`when`(coverageBatchClient).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
service.process(intervalStart, intervalEnd, listOf(22L))
|
||||
|
||||
val occupiedCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<OccupiedIntervalDTO>>
|
||||
verify(coverageBatchClient, times(2)).coverageModes(
|
||||
anyListValue(),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
eqValue(listOf(22L)),
|
||||
captureOccupiedIntervals(occupiedCaptor),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
val firstSnapshot = occupiedCaptor.allValues[0]
|
||||
val secondSnapshot = occupiedCaptor.allValues[1]
|
||||
|
||||
assertEquals(1, firstSnapshot.size)
|
||||
assertEquals(2, secondSnapshot.size)
|
||||
assertTrue(secondSnapshot.any { it.roll == 8.0 && it.startTime == intervalStart.plusMinutes(5) && it.endTime == intervalStart.plusMinutes(15) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process failure before replace does not touch persisted result`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0)
|
||||
|
||||
`when`(satelliteService.satellites).thenReturn(
|
||||
listOf(
|
||||
SatelliteModel(
|
||||
satelliteId = 22L,
|
||||
name = "T",
|
||||
bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0)
|
||||
)
|
||||
)
|
||||
)
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd))
|
||||
.thenReturn(emptyMap())
|
||||
org.mockito.Mockito.doThrow(RuntimeException("cells boom"))
|
||||
.`when`(earthGridService).cells(isNull(), isNull())
|
||||
|
||||
kotlin.test.assertFailsWith<RuntimeException> {
|
||||
service.process(intervalStart, intervalEnd, listOf(22L))
|
||||
}
|
||||
|
||||
verify(complexPlanPersistenceService, never()).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process passes sun filter to coverage batch client`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0)
|
||||
val requestedSun = 12.5
|
||||
val cell = EarthCellWithRequestsDTO(
|
||||
id = 100,
|
||||
num = 42,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
requests = emptyList()
|
||||
)
|
||||
var capturedSun: Double? = null
|
||||
|
||||
`when`(satelliteService.satellites).thenReturn(
|
||||
listOf(
|
||||
SatelliteModel(
|
||||
satelliteId = 22L,
|
||||
name = "T",
|
||||
bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0)
|
||||
)
|
||||
)
|
||||
)
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
`when`(earthGridService.cells(eqValue(2), eqValue(3))).thenReturn(listOf(cell))
|
||||
`when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
org.mockito.Mockito.doAnswer { invocation ->
|
||||
capturedSun = invocation.arguments[5] as Double?
|
||||
emptyMap<Long, List<SlotDTO>>()
|
||||
}.`when`(coverageBatchClient).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
org.mockito.ArgumentMatchers.nullable(Double::class.java),
|
||||
isNull()
|
||||
)
|
||||
doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
service.process(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
sun = requestedSun,
|
||||
countLat = 2,
|
||||
countLong = 3
|
||||
)
|
||||
|
||||
verify(earthGridService).cells(2, 3)
|
||||
verify(coverageBatchClient, org.mockito.Mockito.atLeastOnce()).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
org.mockito.ArgumentMatchers.nullable(Double::class.java),
|
||||
isNull()
|
||||
)
|
||||
assertEquals(requestedSun, capturedSun)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process uses coverage scheme client when requested`() {
|
||||
val coverageBatchClient = coverageBatchClient()
|
||||
val coverageSchemeClient = mock(CoverageCalculationClient::class.java)
|
||||
val earthGridService = mock(EarthGridService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val complexPlanQueryService = mock(ComplexPlanQueryService::class.java)
|
||||
val complexPlanPersistenceService = mock(ComplexPlanPersistenceService::class.java)
|
||||
val surveyPlacementService = SurveyPlacementService()
|
||||
|
||||
`when`(coverageSchemeClient.source).thenReturn(CoverageCalculationSource.COVERAGE_SCHEME)
|
||||
|
||||
val service = ComplexPlanCalculationService(
|
||||
coverageBatchClient = coverageBatchClient,
|
||||
coverageCalculationClientResolver = coverageResolver(coverageBatchClient, coverageSchemeClient),
|
||||
earthGridService = earthGridService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanQueryService = complexPlanQueryService,
|
||||
complexPlanPersistenceService = complexPlanPersistenceService,
|
||||
complexPlanProperties = props(),
|
||||
surveyPlacementService = surveyPlacementService
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0)
|
||||
val cell = EarthCellWithRequestsDTO(
|
||||
id = 100,
|
||||
num = 42,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
requests = emptyList()
|
||||
)
|
||||
|
||||
`when`(satelliteService.satellites).thenReturn(
|
||||
listOf(
|
||||
SatelliteModel(
|
||||
satelliteId = 22L,
|
||||
name = "T",
|
||||
bls = BLS(durationMax = 900.0, dailyMaxDuration = 2_000.0, revolutionMaxDuration = 2_000.0)
|
||||
)
|
||||
)
|
||||
)
|
||||
`when`(complexPlanQueryService.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
`when`(earthGridService.cells(isNull(), isNull())).thenReturn(listOf(cell))
|
||||
`when`(coverageBatchClient.plannedModes(listOf(22L), intervalStart, intervalEnd)).thenReturn(emptyMap())
|
||||
doReturn(emptyMap<Long, List<SlotDTO>>()).`when`(coverageSchemeClient).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
doReturn(SatelliteModeSaveResult()).`when`(complexPlanPersistenceService).replaceModesForInterval(
|
||||
eqValue(listOf(22L)),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
anySurveyMap(),
|
||||
isNull()
|
||||
)
|
||||
|
||||
service.process(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
coverageSource = CoverageCalculationSource.COVERAGE_SCHEME
|
||||
)
|
||||
|
||||
verify(coverageBatchClient).plannedModes(listOf(22L), intervalStart, intervalEnd)
|
||||
verify(coverageSchemeClient).coverageModes(
|
||||
anyListValue(),
|
||||
eqValue(intervalStart),
|
||||
eqValue(intervalEnd),
|
||||
eqValue(listOf(22L)),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
verify(coverageBatchClient, never()).coverageModes(
|
||||
anyListValue(),
|
||||
anyLocalDateTime(),
|
||||
anyLocalDateTime(),
|
||||
anyListValue(),
|
||||
anyOccupiedIntervals(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
@SpringBootTest
|
||||
class ComplexPlanExecutionLockServiceTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanExecutionLockService: ComplexPlanExecutionLockService
|
||||
|
||||
@Test
|
||||
fun `same satellite cannot acquire execution lock twice before release`() {
|
||||
val firstLock = complexPlanExecutionLockService.tryAcquire(listOf(22L))
|
||||
assertNotNull(firstLock)
|
||||
|
||||
try {
|
||||
val secondLock = complexPlanExecutionLockService.tryAcquire(listOf(22L))
|
||||
assertNull(secondLock)
|
||||
} finally {
|
||||
firstLock.close()
|
||||
}
|
||||
|
||||
val thirdLock = complexPlanExecutionLockService.tryAcquire(listOf(22L))
|
||||
assertNotNull(thirdLock)
|
||||
thirdLock.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different satellites can acquire startup locks independently`() {
|
||||
val firstLock = complexPlanExecutionLockService.tryAcquire(listOf(22L))
|
||||
val secondLock = complexPlanExecutionLockService.tryAcquire(listOf(23L))
|
||||
|
||||
assertNotNull(firstLock)
|
||||
assertNotNull(secondLock)
|
||||
|
||||
firstLock.close()
|
||||
secondLock.close()
|
||||
}
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.inOrder
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class ComplexPlanRunOrchestrationServiceTest {
|
||||
|
||||
@Test
|
||||
fun `orchestration marks run failed when lock is unavailable`() {
|
||||
val persistenceService = mock(ComplexPlanRunPersistenceService::class.java)
|
||||
val queryService = mock(ComplexPlanRunQueryService::class.java)
|
||||
val calculationService = mock(ComplexPlanCalculationService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val lockService = mock(ComplexPlanExecutionLockService::class.java)
|
||||
|
||||
val orchestrationService = ComplexPlanRunOrchestrationService(
|
||||
complexPlanRunPersistenceService = persistenceService,
|
||||
complexPlanRunQueryService = queryService,
|
||||
complexPlanCalculationService = calculationService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanExecutionLockService = lockService
|
||||
)
|
||||
|
||||
val request = ComplexPlanProcessRequestDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteId = 22L
|
||||
)
|
||||
val createdRun = ComplexPlanRunEntity(
|
||||
id = 10L,
|
||||
status = ComplexPlanRunStatus.CREATED,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd
|
||||
)
|
||||
|
||||
doReturn(createdRun)
|
||||
.`when`(persistenceService)
|
||||
.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":null,\"countLat\":null,\"countLong\":null,\"coverageStrategy\":\"GREEDY\"}"
|
||||
)
|
||||
)
|
||||
doReturn(null).`when`(lockService).tryAcquire(listOf(22L))
|
||||
|
||||
assertFailsWith<CustomValidationException> {
|
||||
orchestrationService.process(request, requestedBy = "tester")
|
||||
}
|
||||
|
||||
verify(persistenceService).markFailed(
|
||||
10L,
|
||||
"Complex plan recalculation is already running for at least one requested satellite"
|
||||
)
|
||||
verify(calculationService, never()).process(
|
||||
10L,
|
||||
request.intervalStart,
|
||||
request.intervalEnd,
|
||||
listOf(22L),
|
||||
null,
|
||||
CoverageCalculationSource.SLOTS,
|
||||
null,
|
||||
null,
|
||||
SlotCoverageStrategy.GREEDY
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orchestration marks run failed when overlapping RUNNING run exists`() {
|
||||
val persistenceService = mock(ComplexPlanRunPersistenceService::class.java)
|
||||
val queryService = mock(ComplexPlanRunQueryService::class.java)
|
||||
val calculationService = mock(ComplexPlanCalculationService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val lockService = mock(ComplexPlanExecutionLockService::class.java)
|
||||
val lockHandle = ComplexPlanExecutionLockService.ExecutionLockHandle {}
|
||||
|
||||
val orchestrationService = ComplexPlanRunOrchestrationService(
|
||||
complexPlanRunPersistenceService = persistenceService,
|
||||
complexPlanRunQueryService = queryService,
|
||||
complexPlanCalculationService = calculationService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanExecutionLockService = lockService
|
||||
)
|
||||
|
||||
val request = ComplexPlanProcessRequestDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteId = 22L
|
||||
)
|
||||
val createdRun = ComplexPlanRunEntity(
|
||||
id = 10L,
|
||||
status = ComplexPlanRunStatus.CREATED,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd
|
||||
)
|
||||
|
||||
doReturn(createdRun)
|
||||
.`when`(persistenceService)
|
||||
.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":null,\"countLat\":null,\"countLong\":null,\"coverageStrategy\":\"GREEDY\"}"
|
||||
)
|
||||
)
|
||||
doReturn(lockHandle).`when`(lockService).tryAcquire(listOf(22L))
|
||||
doReturn(listOf(99L))
|
||||
.`when`(persistenceService)
|
||||
.findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L)
|
||||
|
||||
assertFailsWith<CustomValidationException> {
|
||||
orchestrationService.process(request, requestedBy = "tester")
|
||||
}
|
||||
|
||||
verify(persistenceService).markFailed(
|
||||
10L,
|
||||
"Complex plan recalculation is already running for overlapping interval, runIds=[99]"
|
||||
)
|
||||
verify(calculationService, never()).process(
|
||||
10L,
|
||||
request.intervalStart,
|
||||
request.intervalEnd,
|
||||
listOf(22L),
|
||||
null,
|
||||
CoverageCalculationSource.SLOTS,
|
||||
null,
|
||||
null,
|
||||
SlotCoverageStrategy.GREEDY
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orchestration marks run running before releasing startup lock`() {
|
||||
val persistenceService = mock(ComplexPlanRunPersistenceService::class.java)
|
||||
val queryService = mock(ComplexPlanRunQueryService::class.java)
|
||||
val calculationService = mock(ComplexPlanCalculationService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val lockService = mock(ComplexPlanExecutionLockService::class.java)
|
||||
val lockHandle = mock(ComplexPlanExecutionLockService.ExecutionLockHandle::class.java)
|
||||
|
||||
val orchestrationService = ComplexPlanRunOrchestrationService(
|
||||
complexPlanRunPersistenceService = persistenceService,
|
||||
complexPlanRunQueryService = queryService,
|
||||
complexPlanCalculationService = calculationService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanExecutionLockService = lockService
|
||||
)
|
||||
|
||||
val request = ComplexPlanProcessRequestDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteId = 22L
|
||||
)
|
||||
val createdRun = ComplexPlanRunEntity(
|
||||
id = 10L,
|
||||
status = ComplexPlanRunStatus.CREATED,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd
|
||||
)
|
||||
val runningRun = ComplexPlanRunEntity(
|
||||
id = 10L,
|
||||
status = ComplexPlanRunStatus.RUNNING,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd
|
||||
)
|
||||
val response = ComplexPlanRunResponseDTO(
|
||||
runId = 10L,
|
||||
status = ComplexPlanRunStatus.COMPLETED,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC),
|
||||
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC),
|
||||
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC),
|
||||
durationMs = 60_000,
|
||||
errorMessage = null,
|
||||
calculatedModesCount = 1,
|
||||
bookedModesCount = 0,
|
||||
complanModesCount = 1,
|
||||
mixedModesCount = 0,
|
||||
affectedSatellitesCount = 1,
|
||||
processedCellsCount = 1,
|
||||
bookedSlotLinksCount = 0,
|
||||
snapshotIds = listOf(501L),
|
||||
requestPayload = "{}"
|
||||
)
|
||||
|
||||
doReturn(createdRun)
|
||||
.`when`(persistenceService)
|
||||
.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":null,\"countLat\":null,\"countLong\":null,\"coverageStrategy\":\"GREEDY\"}"
|
||||
)
|
||||
)
|
||||
doReturn(lockHandle).`when`(lockService).tryAcquire(listOf(22L))
|
||||
doReturn(emptyList<Long>())
|
||||
.`when`(persistenceService)
|
||||
.findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L)
|
||||
doReturn(runningRun).`when`(persistenceService).markRunning(10L)
|
||||
doReturn(ComplexPlanCalculationResult(calculatedModesCount = 1))
|
||||
.`when`(calculationService)
|
||||
.process(
|
||||
10L,
|
||||
request.intervalStart,
|
||||
request.intervalEnd,
|
||||
listOf(22L),
|
||||
null,
|
||||
CoverageCalculationSource.SLOTS,
|
||||
null,
|
||||
null,
|
||||
SlotCoverageStrategy.GREEDY
|
||||
)
|
||||
doReturn(createdRun)
|
||||
.`when`(persistenceService)
|
||||
.markCompleted(10L, ComplexPlanRunStatisticsDTO(calculatedModesCount = 1))
|
||||
doReturn(response).`when`(queryService).getRun(10L)
|
||||
|
||||
val result = orchestrationService.process(request, requestedBy = "tester")
|
||||
|
||||
val ordered = inOrder(persistenceService, lockHandle, calculationService)
|
||||
ordered.verify(persistenceService)
|
||||
.findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L)
|
||||
ordered.verify(persistenceService).markRunning(10L)
|
||||
ordered.verify(lockHandle).close()
|
||||
ordered.verify(calculationService).process(
|
||||
10L,
|
||||
request.intervalStart,
|
||||
request.intervalEnd,
|
||||
listOf(22L),
|
||||
null,
|
||||
CoverageCalculationSource.SLOTS,
|
||||
null,
|
||||
null,
|
||||
SlotCoverageStrategy.GREEDY
|
||||
)
|
||||
assertEquals(10L, result.runId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orchestration passes requested coverage source to calculation`() {
|
||||
val persistenceService = mock(ComplexPlanRunPersistenceService::class.java)
|
||||
val queryService = mock(ComplexPlanRunQueryService::class.java)
|
||||
val calculationService = mock(ComplexPlanCalculationService::class.java)
|
||||
val satelliteService = mock(SatelliteService::class.java)
|
||||
val lockService = mock(ComplexPlanExecutionLockService::class.java)
|
||||
val lockHandle = mock(ComplexPlanExecutionLockService.ExecutionLockHandle::class.java)
|
||||
|
||||
val orchestrationService = ComplexPlanRunOrchestrationService(
|
||||
complexPlanRunPersistenceService = persistenceService,
|
||||
complexPlanRunQueryService = queryService,
|
||||
complexPlanCalculationService = calculationService,
|
||||
satelliteService = satelliteService,
|
||||
complexPlanExecutionLockService = lockService
|
||||
)
|
||||
|
||||
val request = ComplexPlanProcessRequestDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteId = 22L,
|
||||
coverageSource = CoverageCalculationSource.COVERAGE_SCHEME,
|
||||
countLat = 2,
|
||||
countLong = 3,
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
val createdRun = ComplexPlanRunEntity(
|
||||
id = 10L,
|
||||
status = ComplexPlanRunStatus.CREATED,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd
|
||||
)
|
||||
val response = ComplexPlanRunResponseDTO(
|
||||
runId = 10L,
|
||||
status = ComplexPlanRunStatus.COMPLETED,
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC),
|
||||
startedAt = request.intervalStart.atOffset(ZoneOffset.UTC),
|
||||
finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC),
|
||||
durationMs = 60_000,
|
||||
errorMessage = null,
|
||||
calculatedModesCount = 0,
|
||||
bookedModesCount = 0,
|
||||
complanModesCount = 0,
|
||||
mixedModesCount = 0,
|
||||
affectedSatellitesCount = 0,
|
||||
processedCellsCount = 0,
|
||||
bookedSlotLinksCount = 0,
|
||||
snapshotIds = emptyList(),
|
||||
requestPayload = "{}"
|
||||
)
|
||||
|
||||
doReturn(createdRun).`when`(persistenceService).createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = request.intervalStart,
|
||||
intervalEnd = request.intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{\"intervalStart\":\"2026-03-26T10:00:00\",\"intervalEnd\":\"2026-03-26T12:00:00\",\"satelliteId\":22,\"satelliteIds\":[],\"sun\":null,\"coverageSource\":\"COVERAGE_SCHEME\",\"countLat\":2,\"countLong\":3,\"coverageStrategy\":\"CONTINUOUS\"}"
|
||||
)
|
||||
)
|
||||
doReturn(lockHandle).`when`(lockService).tryAcquire(listOf(22L))
|
||||
doReturn(emptyList<Long>())
|
||||
.`when`(persistenceService)
|
||||
.findOverlappingRunningRunIds(listOf(22L), request.intervalStart, request.intervalEnd, 10L)
|
||||
doReturn(createdRun).`when`(persistenceService).markRunning(10L)
|
||||
doReturn(ComplexPlanCalculationResult()).`when`(calculationService).process(
|
||||
10L,
|
||||
request.intervalStart,
|
||||
request.intervalEnd,
|
||||
listOf(22L),
|
||||
null,
|
||||
CoverageCalculationSource.COVERAGE_SCHEME,
|
||||
2,
|
||||
3,
|
||||
SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
doReturn(createdRun).`when`(persistenceService).markCompleted(10L, ComplexPlanRunStatisticsDTO())
|
||||
doReturn(response).`when`(queryService).getRun(10L)
|
||||
|
||||
orchestrationService.process(request, requestedBy = "tester")
|
||||
|
||||
verify(calculationService).process(
|
||||
10L,
|
||||
request.intervalStart,
|
||||
request.intervalEnd,
|
||||
listOf(22L),
|
||||
null,
|
||||
CoverageCalculationSource.COVERAGE_SCHEME,
|
||||
2,
|
||||
3,
|
||||
SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
}
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunStatisticsDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@SpringBootTest
|
||||
class ComplexPlanRunPersistenceServiceTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunRepository: ComplexPlanRunRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeRepository: SatelliteModeRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanPersistenceService: ComplexPlanPersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunQueryService: ComplexPlanRunQueryService
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
satelliteModeBookedSlotRepository.deleteAll()
|
||||
satelliteModeCellRepository.deleteAll()
|
||||
satelliteModeRepository.deleteAll()
|
||||
satelliteModeSnapshotRepository.deleteAll()
|
||||
complexPlanRunRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create run stores CREATED status and then moves to RUNNING`() {
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L, 23L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = """{"k":"v"}"""
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(ComplexPlanRunStatus.CREATED, run.status)
|
||||
assertEquals(listOf(22L, 23L), run.satellites.map { it.satelliteId }.sorted())
|
||||
|
||||
val running = complexPlanRunPersistenceService.markRunning(run.id!!)
|
||||
assertEquals(ComplexPlanRunStatus.RUNNING, running.status)
|
||||
assertNotNull(running.startedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed run stores duration and statistics`() {
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(run.id!!)
|
||||
Thread.sleep(5)
|
||||
|
||||
val completed = complexPlanRunPersistenceService.markCompleted(
|
||||
runId = run.id!!,
|
||||
statistics = ComplexPlanRunStatisticsDTO(
|
||||
calculatedModesCount = 3,
|
||||
bookedModesCount = 1,
|
||||
complanModesCount = 1,
|
||||
mixedModesCount = 1,
|
||||
affectedSatellitesCount = 1,
|
||||
processedCellsCount = 2,
|
||||
bookedSlotLinksCount = 4,
|
||||
totalRequestsArea = 10.0,
|
||||
coveredArea = 4.0,
|
||||
coveredAreaPercent = 40.0
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(ComplexPlanRunStatus.COMPLETED, completed.status)
|
||||
assertNotNull(completed.finishedAt)
|
||||
assertNotNull(completed.durationMs)
|
||||
assertTrue(completed.durationMs!! >= 0)
|
||||
assertEquals(3, completed.calculatedModesCount)
|
||||
assertEquals(1, completed.bookedModesCount)
|
||||
assertEquals(1, completed.complanModesCount)
|
||||
assertEquals(1, completed.mixedModesCount)
|
||||
assertEquals(2, completed.processedCellsCount)
|
||||
assertEquals(4, completed.bookedSlotLinksCount)
|
||||
assertEquals(10.0, completed.totalRequestsArea)
|
||||
assertEquals(4.0, completed.coveredArea)
|
||||
assertEquals(40.0, completed.coveredAreaPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getRun returns linked snapshot ids and active snapshot ids`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
|
||||
complexPlanPersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 123,
|
||||
time = intervalStart.plusMinutes(5),
|
||||
timeStop = intervalStart.plusMinutes(15),
|
||||
roll = 3.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
)
|
||||
)
|
||||
),
|
||||
complexPlanRunId = run.id
|
||||
)
|
||||
|
||||
val response = complexPlanRunQueryService.getRun(run.id!!)
|
||||
|
||||
assertEquals(1, response.snapshotIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed run stores error and keeps audit trail`() {
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = null,
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(run.id!!)
|
||||
|
||||
val failed = complexPlanRunPersistenceService.markFailed(run.id!!, "boom")
|
||||
|
||||
assertEquals(ComplexPlanRunStatus.FAILED, failed.status)
|
||||
assertEquals("boom", failed.errorMessage)
|
||||
assertNotNull(failed.finishedAt)
|
||||
assertNotNull(failed.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shutdown marks CREATED and RUNNING runs as FAILED and leaves completed untouched`() {
|
||||
val createdRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
val runningRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 13, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 14, 0),
|
||||
satelliteIds = listOf(23L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(runningRun.id!!)
|
||||
val completedRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 15, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 16, 0),
|
||||
satelliteIds = listOf(24L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(completedRun.id!!)
|
||||
complexPlanRunPersistenceService.markCompleted(
|
||||
runId = completedRun.id!!,
|
||||
statistics = ComplexPlanRunStatisticsDTO(calculatedModesCount = 1)
|
||||
)
|
||||
|
||||
val affectedRuns = complexPlanRunPersistenceService.markActiveRunsFailedOnShutdown()
|
||||
|
||||
assertEquals(2, affectedRuns)
|
||||
|
||||
val reloadedCreated = complexPlanRunRepository.findById(createdRun.id!!).orElseThrow()
|
||||
val reloadedRunning = complexPlanRunRepository.findById(runningRun.id!!).orElseThrow()
|
||||
val reloadedCompleted = complexPlanRunRepository.findById(completedRun.id!!).orElseThrow()
|
||||
|
||||
assertEquals(ComplexPlanRunStatus.FAILED, reloadedCreated.status)
|
||||
assertEquals(ComplexPlanRunStatus.FAILED, reloadedRunning.status)
|
||||
assertEquals(ComplexPlanRunStatus.COMPLETED, reloadedCompleted.status)
|
||||
assertEquals(
|
||||
ComplexPlanRunPersistenceService.APPLICATION_SHUTDOWN_ERROR_MESSAGE,
|
||||
reloadedCreated.errorMessage
|
||||
)
|
||||
assertEquals(
|
||||
ComplexPlanRunPersistenceService.APPLICATION_SHUTDOWN_ERROR_MESSAGE,
|
||||
reloadedRunning.errorMessage
|
||||
)
|
||||
assertNotNull(reloadedCreated.finishedAt)
|
||||
assertNotNull(reloadedRunning.finishedAt)
|
||||
assertNotNull(reloadedRunning.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findOverlappingRunningRunIds is interval aware`() {
|
||||
val runningRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 0),
|
||||
satelliteIds = listOf(10L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(runningRun.id!!)
|
||||
|
||||
val nonOverlapping = complexPlanRunPersistenceService.findOverlappingRunningRunIds(
|
||||
satelliteIds = listOf(10L),
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 13, 0),
|
||||
excludedRunId = -1L
|
||||
)
|
||||
val overlapping = complexPlanRunPersistenceService.findOverlappingRunningRunIds(
|
||||
satelliteIds = listOf(10L),
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 30),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 30),
|
||||
excludedRunId = -1L
|
||||
)
|
||||
val otherSatellite = complexPlanRunPersistenceService.findOverlappingRunningRunIds(
|
||||
satelliteIds = listOf(11L),
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 30),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 30),
|
||||
excludedRunId = -1L
|
||||
)
|
||||
|
||||
assertTrue(nonOverlapping.isEmpty())
|
||||
assertEquals(listOf(runningRun.id!!), overlapping)
|
||||
assertTrue(otherSatellite.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startup recovery marks stale RUNNING runs as FAILED`() {
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 0),
|
||||
satelliteIds = listOf(10L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
complexPlanRunPersistenceService.markRunning(run.id!!)
|
||||
|
||||
val affectedRuns = complexPlanRunPersistenceService.markRunningRunsFailedOnStartup()
|
||||
|
||||
val reloaded = complexPlanRunRepository.findById(run.id!!).orElseThrow()
|
||||
assertEquals(1, affectedRuns)
|
||||
assertEquals(ComplexPlanRunStatus.FAILED, reloaded.status)
|
||||
assertEquals(ComplexPlanRunPersistenceService.STARTUP_RECOVERY_ERROR_MESSAGE, reloaded.errorMessage)
|
||||
assertNotNull(reloaded.finishedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startup recovery is no-op when there are no RUNNING runs`() {
|
||||
val affectedRuns = complexPlanRunPersistenceService.markRunningRunsFailedOnStartup()
|
||||
assertEquals(0, affectedRuns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `run keeps link to created snapshots`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 27, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 12, 0)
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
|
||||
complexPlanPersistenceService.replaceModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = emptyMap(),
|
||||
complexPlanRunId = run.id
|
||||
)
|
||||
|
||||
val reloaded = complexPlanRunPersistenceService.getRun(run.id!!)
|
||||
assertEquals(1, reloaded.snapshots.size)
|
||||
assertEquals(22L, reloaded.snapshots.single().satelliteId)
|
||||
assertTrue(reloaded.snapshots.single().isActive)
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
|
||||
class ComplexPlanRunShutdownHandlerTest {
|
||||
|
||||
@Test
|
||||
fun `context close handler marks active runs as failed`() {
|
||||
val persistenceService = mock(ComplexPlanRunPersistenceService::class.java)
|
||||
val handler = ComplexPlanRunShutdownHandler(persistenceService)
|
||||
|
||||
handler.onContextClosed()
|
||||
|
||||
verify(persistenceService).markActiveRunsFailedOnShutdown()
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
|
||||
class ComplexPlanRunStartupRecoveryTest {
|
||||
|
||||
@Test
|
||||
fun `startup recovery invokes persistence reconciliation`() {
|
||||
val persistenceService = mock(ComplexPlanRunPersistenceService::class.java)
|
||||
doReturn(2).`when`(persistenceService).markRunningRunsFailedOnStartup()
|
||||
|
||||
val recovery = ComplexPlanRunStartupRecovery(persistenceService)
|
||||
recovery.onApplicationReady()
|
||||
|
||||
verify(persistenceService).markRunningRunsFailedOnStartup()
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchResponseDTO
|
||||
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 tools.jackson.databind.ObjectMapper
|
||||
import java.net.InetSocketAddress
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CoverageBatchClientTest {
|
||||
|
||||
private var server: HttpServer? = null
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
server?.stop(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coverageModes sends sun and coverage strategy in batch request body`() {
|
||||
var requestBody = ""
|
||||
server = HttpServer.create(InetSocketAddress(0), 0).apply {
|
||||
createContext("/api/slots/poly-cover/batch") { exchange ->
|
||||
requestBody = exchange.requestBody.bufferedReader().readText()
|
||||
val responseBody = "[]"
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong())
|
||||
exchange.responseBody.use { it.write(responseBody.toByteArray()) }
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider<WebClient.Builder>
|
||||
`when`(builderProvider.ifAvailable).thenReturn(WebClient.builder())
|
||||
|
||||
val client = CoverageBatchClient(builderProvider)
|
||||
ReflectionTestUtils.setField(client, "url", "http://localhost:${server!!.address.port}")
|
||||
|
||||
client.coverageModes(
|
||||
targets = listOf(SlotCoverageTargetDTO(targetId = 101L, wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L),
|
||||
occupiedIntervals = emptyList(),
|
||||
sun = 12.5,
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
|
||||
assertTrue(requestBody.contains("\"sun\":12.5"))
|
||||
assertTrue(requestBody.contains("\"coverageStrategy\":\"CONTINUOUS\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coverageModes falls back to GREEDY when CONTINUOUS batch request returns server error`() {
|
||||
val requestBodies = mutableListOf<String>()
|
||||
val requestCounter = AtomicInteger()
|
||||
server = HttpServer.create(InetSocketAddress(0), 0).apply {
|
||||
createContext("/api/slots/poly-cover/batch") { exchange ->
|
||||
val requestBody = exchange.requestBody.bufferedReader().readText()
|
||||
requestBodies += requestBody
|
||||
val attempt = requestCounter.incrementAndGet()
|
||||
if (attempt == 1) {
|
||||
val responseBody = """{"error":"solver failed"}"""
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(500, responseBody.toByteArray().size.toLong())
|
||||
exchange.responseBody.use { it.write(responseBody.toByteArray()) }
|
||||
} else {
|
||||
val responseBody = ObjectMapper().writeValueAsString(
|
||||
listOf(
|
||||
SlotCoverageBatchResponseDTO(
|
||||
targetId = 101L,
|
||||
slots = listOf(
|
||||
SlotDTO(
|
||||
cycle = 0,
|
||||
satelliteId = 22,
|
||||
tn = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
tk = LocalDateTime.of(2026, 3, 26, 10, 10),
|
||||
roll = 10.0,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
revolution = 501,
|
||||
latitude = 10.0,
|
||||
longitude = 20.0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong())
|
||||
exchange.responseBody.use { it.write(responseBody.toByteArray()) }
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider<WebClient.Builder>
|
||||
`when`(builderProvider.ifAvailable).thenReturn(WebClient.builder())
|
||||
|
||||
val client = CoverageBatchClient(builderProvider)
|
||||
ReflectionTestUtils.setField(client, "url", "http://localhost:${server!!.address.port}")
|
||||
|
||||
val response = client.coverageModes(
|
||||
targets = listOf(SlotCoverageTargetDTO(targetId = 101L, wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L),
|
||||
occupiedIntervals = emptyList(),
|
||||
sun = null,
|
||||
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
|
||||
)
|
||||
|
||||
assertEquals(2, requestBodies.size)
|
||||
assertTrue(requestBodies[0].contains("\"coverageStrategy\":\"CONTINUOUS\""))
|
||||
assertTrue(requestBodies[1].contains("\"coverageStrategy\":\"GREEDY\""))
|
||||
assertEquals(1, response.size)
|
||||
assertEquals(1, response.getValue(101L).size)
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
|
||||
import java.net.InetSocketAddress
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CoverageSchemeCalculationClientTest {
|
||||
|
||||
private var server: HttpServer? = null
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
server?.stop(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coverageModes calls coverage scheme service and maps response to slot model`() {
|
||||
var requestBody = ""
|
||||
server = HttpServer.create(InetSocketAddress(0), 0).apply {
|
||||
createContext("/api/coverage-schemes/calculate") { exchange ->
|
||||
requestBody = exchange.requestBody.bufferedReader().readText()
|
||||
val responseBody = """
|
||||
{
|
||||
"targetPolygonWkt": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
"observations": [],
|
||||
"coverageScheme": [
|
||||
{
|
||||
"satelliteId": 22,
|
||||
"tn": "2026-03-26T10:05:00",
|
||||
"tk": "2026-03-26T10:15:00",
|
||||
"roll": 8.5,
|
||||
"contour": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
"revolution": 501,
|
||||
"revolutionSign": "ASC",
|
||||
"slotIds": [],
|
||||
"latitude": 10.0,
|
||||
"longitude": 20.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong())
|
||||
exchange.responseBody.use { it.write(responseBody.toByteArray()) }
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider<WebClient.Builder>
|
||||
`when`(builderProvider.ifAvailable).thenReturn(WebClient.builder())
|
||||
|
||||
val client = CoverageSchemeCalculationClient(builderProvider)
|
||||
ReflectionTestUtils.setField(client, "url", "http://localhost:${server!!.address.port}")
|
||||
|
||||
val result = client.coverageModes(
|
||||
targets = listOf(SlotCoverageTargetDTO(targetId = 101L, wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
|
||||
intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
satelliteIds = listOf(22L),
|
||||
occupiedIntervals = emptyList(),
|
||||
sun = null
|
||||
)
|
||||
|
||||
val slot = result.getValue(101L).single()
|
||||
assertEquals(CoverageCalculationSource.COVERAGE_SCHEME, client.source)
|
||||
assertTrue(requestBody.contains("\"polygonWkt\":\"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))\""))
|
||||
assertTrue(requestBody.contains("\"satelliteIds\":[22]"))
|
||||
assertEquals(22L, slot.satelliteId)
|
||||
assertEquals(8.5, slot.roll)
|
||||
assertEquals(501L, slot.revolution)
|
||||
assertEquals(10.0, slot.latitude)
|
||||
assertEquals(20.0, slot.longitude)
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.util.unit.DataSize
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_satellites_service.configuration.WebClientConfig
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class EarthGridServiceTest {
|
||||
|
||||
private var server: HttpServer? = null
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
server?.stop(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cells calls v1 cells with requests and maps wrapper items`() {
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
server = serverWithCellsResponse(requestedUris)
|
||||
|
||||
val cells = earthGridService().cells().toList()
|
||||
val cell = cells.single()
|
||||
val request = cell.requests.single()
|
||||
|
||||
assertEquals("/v1/cells/with-requests", requestedUris.single().path)
|
||||
assertEquals("minImportance=0.001", requestedUris.single().query)
|
||||
assertFalse(requestedUris.any { uri -> uri.path == "/api/earth-grid/with-requests" })
|
||||
assertEquals(1L, cell.id)
|
||||
assertEquals(1L, cell.num)
|
||||
assertEquals(55.5, cell.latitude)
|
||||
assertEquals(37.5, cell.longitude)
|
||||
assertEquals(10.0, cell.importance)
|
||||
assertEquals("POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))", cell.contour)
|
||||
assertEquals("00000000-0000-0000-0000-000000000001", request.requestId)
|
||||
assertEquals(42.5, request.covPercent)
|
||||
assertEquals(9.0, request.importance)
|
||||
assertEquals("POLYGON ((30.1 50.1, 30.2 50.1, 30.2 50.2, 30.1 50.2, 30.1 50.1))", request.contour)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cells forwards countLat and countLong to v1 cells with requests`() {
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
server = serverWithCellsResponse(requestedUris)
|
||||
|
||||
earthGridService().cells(countLat = 12, countLong = 24).toList()
|
||||
|
||||
assertEquals("/v1/cells/with-requests", requestedUris.single().path)
|
||||
assertEquals("minImportance=0.001&countLat=12&countLong=24", requestedUris.single().query)
|
||||
assertFalse(requestedUris.any { uri -> uri.path == "/api/earth-grid/with-requests" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cells supports response larger than default webclient buffer`() {
|
||||
val itemCount = 4_000
|
||||
val contour = "POLYGON ((${(1..80).joinToString(",") { index -> "$index $index" }}))"
|
||||
val responseBody = buildString {
|
||||
append("""{"items":[""")
|
||||
repeat(itemCount) { index ->
|
||||
if (index > 0) {
|
||||
append(",")
|
||||
}
|
||||
append(
|
||||
"""
|
||||
{
|
||||
"cellNum": $index,
|
||||
"latitude": 55.5,
|
||||
"longitude": 37.5,
|
||||
"importance": 10.0,
|
||||
"contour": "$contour",
|
||||
"requests": []
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
append("]}")
|
||||
}
|
||||
val requestedUris = mutableListOf<URI>()
|
||||
server = serverWithCellsResponse(requestedUris, responseBody)
|
||||
|
||||
val cells = earthGridService {
|
||||
WebClientConfig(DataSize.ofMegabytes(20)).webClientBuilder()
|
||||
}.cells().toList()
|
||||
|
||||
assertEquals(itemCount, cells.size)
|
||||
assertEquals("/v1/cells/with-requests", requestedUris.single().path)
|
||||
assertEquals("minImportance=0.001", requestedUris.single().query)
|
||||
}
|
||||
|
||||
private fun serverWithCellsResponse(requestedUris: MutableList<URI>): HttpServer =
|
||||
serverWithCellsResponse(
|
||||
requestedUris,
|
||||
"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"cellNum": 1,
|
||||
"latitude": 55.5,
|
||||
"longitude": 37.5,
|
||||
"importance": 10.0,
|
||||
"contour": "POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))",
|
||||
"requests": [
|
||||
{
|
||||
"requestId": "00000000-0000-0000-0000-000000000001",
|
||||
"contour": "POLYGON ((30.1 50.1, 30.2 50.1, 30.2 50.2, 30.1 50.2, 30.1 50.1))",
|
||||
"coveragePercent": 42.5,
|
||||
"importance": 9.0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
private fun serverWithCellsResponse(
|
||||
requestedUris: MutableList<URI>,
|
||||
responseBody: String
|
||||
): HttpServer =
|
||||
HttpServer.create(InetSocketAddress(0), 0).apply {
|
||||
createContext("/v1/cells/with-requests") { exchange ->
|
||||
requestedUris += exchange.requestURI
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong())
|
||||
exchange.responseBody.use { it.write(responseBody.toByteArray()) }
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
private fun earthGridService(
|
||||
builderFactory: () -> WebClient.Builder = { WebClient.builder() }
|
||||
): EarthGridService {
|
||||
val builderProvider = mock(ObjectProvider::class.java) as ObjectProvider<WebClient.Builder>
|
||||
`when`(builderProvider.ifAvailable).thenReturn(builderFactory())
|
||||
|
||||
val service = EarthGridService(builderProvider)
|
||||
ReflectionTestUtils.setField(service, "url", "http://localhost:${server!!.address.port}")
|
||||
return service
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package space.nstart.pcp.pcp_satellites_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.pcp_satellites_service.repository.ComplexPlanRunSatelliteRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
|
||||
class SatelliteDeletedServiceTest {
|
||||
|
||||
@Test
|
||||
fun `deleteSatelliteData removes complex mission satellite data`() {
|
||||
val snapshotRepository = mock(SatelliteModeSnapshotRepository::class.java)
|
||||
val modeRepository = mock(SatelliteModeRepository::class.java)
|
||||
val runSatelliteRepository = mock(ComplexPlanRunSatelliteRepository::class.java)
|
||||
val service = SatelliteDeletedService(snapshotRepository, modeRepository, runSatelliteRepository)
|
||||
|
||||
`when`(modeRepository.countBySatelliteId(56756L)).thenReturn(12L)
|
||||
`when`(snapshotRepository.countBySatelliteId(56756L)).thenReturn(3L)
|
||||
`when`(runSatelliteRepository.countBySatelliteId(56756L)).thenReturn(2L)
|
||||
`when`(snapshotRepository.deleteAllBySatelliteId(56756L)).thenReturn(3L)
|
||||
`when`(runSatelliteRepository.deleteAllBySatelliteId(56756L)).thenReturn(2L)
|
||||
|
||||
val summary = service.deleteSatelliteData(56756L)
|
||||
|
||||
assertEquals(56756L, summary.satelliteId)
|
||||
assertEquals(3L, summary.snapshotsDeleted)
|
||||
assertEquals(12L, summary.modesDeletedByCascade)
|
||||
assertEquals(2L, summary.runLinksDeleted)
|
||||
verify(snapshotRepository).deleteAllBySatelliteId(56756L)
|
||||
verify(runSatelliteRepository).deleteAllBySatelliteId(56756L)
|
||||
}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean
|
||||
import org.mockito.Mockito.doThrow
|
||||
import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunCreateDTO
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.ComplexPlanRunRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@SpringBootTest
|
||||
class SatelliteModePersistenceServiceTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModePersistenceService: SatelliteModePersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeQueryService: SatelliteModeQueryService
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeRepository: SatelliteModeRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeBookedSlotRepository: SatelliteModeBookedSlotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeCellRepository: SatelliteModeCellRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteModeSnapshotRepository: SatelliteModeSnapshotRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunPersistenceService: ComplexPlanRunPersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanPersistenceService: ComplexPlanPersistenceService
|
||||
|
||||
@Autowired
|
||||
private lateinit var complexPlanRunRepository: ComplexPlanRunRepository
|
||||
|
||||
@MockitoSpyBean
|
||||
private lateinit var satelliteModeRepositorySpy: SatelliteModeRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
satelliteModeBookedSlotRepository.deleteAll()
|
||||
satelliteModeCellRepository.deleteAll()
|
||||
satelliteModeRepository.deleteAll()
|
||||
satelliteModeSnapshotRepository.deleteAll()
|
||||
complexPlanRunRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteModesForInterval removes only intersecting satellite modes and relations`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L, 56756L),
|
||||
intervalStart = intervalStart.minusHours(2),
|
||||
intervalEnd = intervalEnd.plusHours(2),
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 100,
|
||||
time = intervalStart.plusMinutes(10),
|
||||
timeStop = intervalStart.plusMinutes(20),
|
||||
roll = 5.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
bookedSlotIds = listOf(1001L),
|
||||
cellNums = listOf(42L)
|
||||
)
|
||||
),
|
||||
56756L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 200,
|
||||
time = intervalEnd.plusHours(1),
|
||||
timeStop = intervalEnd.plusHours(2),
|
||||
roll = 7.0,
|
||||
contourWKT = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))",
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
bookedSlotIds = listOf(2001L),
|
||||
cellNums = listOf(99L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
satelliteModePersistenceService.deleteModesForInterval(listOf(22L), intervalStart, intervalEnd)
|
||||
|
||||
val remaining = satelliteModeRepository.findAll()
|
||||
assertEquals(1, remaining.size)
|
||||
assertEquals(56756L, remaining.single().satelliteId)
|
||||
assertEquals(1, satelliteModeSnapshotRepository.count())
|
||||
assertEquals(1, satelliteModeBookedSlotRepository.findAll().size)
|
||||
assertEquals(1, satelliteModeCellRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveModesForInterval persists source booked slots and cell numbers`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 300,
|
||||
time = intervalStart.plusMinutes(5),
|
||||
timeStop = intervalStart.plusMinutes(25),
|
||||
roll = 8.0,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
bookedSlotIds = listOf(101L, 102L),
|
||||
cellNums = listOf(42L, 43L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), intervalStart, intervalEnd)
|
||||
assertEquals(1, modes.size)
|
||||
assertEquals("SLOTS", modes.single().source)
|
||||
assertEquals(listOf(101L, 102L), modes.single().bookedSlotIds)
|
||||
assertEquals(listOf(42L, 43L), modes.single().cellNums)
|
||||
assertEquals(-1L, modes.single().cellNum)
|
||||
assertTrue(satelliteModeCellRepository.findAll().size == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findModesByInterval reads active snapshot by satellite and filters returned modes by interval`() {
|
||||
val snapshotStart = LocalDateTime.of(2026, 3, 26, 8, 0)
|
||||
val snapshotEnd = LocalDateTime.of(2026, 3, 26, 14, 0)
|
||||
val requestedStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val requestedEnd = LocalDateTime.of(2026, 3, 26, 11, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = snapshotStart,
|
||||
intervalEnd = snapshotEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 301,
|
||||
time = snapshotStart.plusMinutes(5),
|
||||
timeStop = snapshotStart.plusMinutes(25),
|
||||
roll = 7.0,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.SLOTS
|
||||
),
|
||||
SurveyMode(
|
||||
revolution = 302,
|
||||
time = requestedStart.plusMinutes(10),
|
||||
timeStop = requestedStart.plusMinutes(20),
|
||||
roll = 8.0,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), requestedStart, requestedEnd)
|
||||
|
||||
assertEquals(1, modes.size)
|
||||
assertEquals(302L, modes.single().revolution)
|
||||
assertEquals("COMPLAN", modes.single().source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveModesForInterval stores complex plan run relation`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
val run = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
satelliteIds = listOf(22L),
|
||||
requestedBy = "tester",
|
||||
requestPayload = "{}"
|
||||
)
|
||||
)
|
||||
|
||||
complexPlanPersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 400,
|
||||
time = intervalStart.plusMinutes(5),
|
||||
timeStop = intervalStart.plusMinutes(15),
|
||||
roll = 8.0,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
cellNums = listOf(42L)
|
||||
)
|
||||
)
|
||||
),
|
||||
complexPlanRunId = run.id
|
||||
)
|
||||
|
||||
val linkedModes = satelliteModeRepository.findAllByComplexPlanRun_IdOrderByStartTimeAsc(run.id!!)
|
||||
assertEquals(1, linkedModes.size)
|
||||
assertEquals(run.id, linkedModes.single().complexPlanRun?.id)
|
||||
assertEquals(run.id, linkedModes.single().snapshot?.complexPlanRun?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceModesForInterval creates new active snapshot and keeps old history`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 100,
|
||||
time = intervalStart.plusMinutes(1),
|
||||
timeStop = intervalStart.plusMinutes(10),
|
||||
roll = 5.0,
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
bookedSlotIds = listOf(101L),
|
||||
cellNums = listOf(42L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
complexPlanPersistenceService.replaceModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 200,
|
||||
time = intervalStart.plusMinutes(20),
|
||||
timeStop = intervalStart.plusMinutes(30),
|
||||
roll = 6.0,
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
bookedSlotIds = listOf(202L),
|
||||
cellNums = listOf(43L, 44L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), intervalStart, intervalEnd)
|
||||
assertEquals(1, modes.size)
|
||||
assertEquals("COMPLAN", modes.single().source)
|
||||
assertEquals(listOf(202L), modes.single().bookedSlotIds)
|
||||
assertEquals(listOf(43L, 44L), modes.single().cellNums)
|
||||
val snapshots = satelliteModeSnapshotRepository.findAll().sortedBy { it.id }
|
||||
assertEquals(2, snapshots.size)
|
||||
assertTrue(snapshots.last().isActive)
|
||||
assertTrue(!snapshots.first().isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveModesForInterval keeps only one active snapshot per satellite across different intervals`() {
|
||||
val firstStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val firstEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
val secondStart = LocalDateTime.of(2026, 3, 27, 10, 0)
|
||||
val secondEnd = LocalDateTime.of(2026, 3, 27, 12, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = firstStart,
|
||||
intervalEnd = firstEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 401,
|
||||
time = firstStart.plusMinutes(5),
|
||||
timeStop = firstStart.plusMinutes(15),
|
||||
roll = 4.0,
|
||||
source = SatelliteModeSource.SLOTS
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = secondStart,
|
||||
intervalEnd = secondEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 402,
|
||||
time = secondStart.plusMinutes(5),
|
||||
timeStop = secondStart.plusMinutes(15),
|
||||
roll = 5.0,
|
||||
source = SatelliteModeSource.COMPLAN
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val snapshots = satelliteModeSnapshotRepository.findAll().filter { it.satelliteId == 22L }.sortedBy { it.createdAt }
|
||||
assertEquals(2, snapshots.size)
|
||||
assertEquals(1, snapshots.count { it.isActive })
|
||||
assertTrue(!snapshots.first().isActive)
|
||||
assertTrue(snapshots.last().isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceModesForInterval rolls back and keeps old result when save fails`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 300,
|
||||
time = intervalStart.plusMinutes(1),
|
||||
timeStop = intervalStart.plusMinutes(10),
|
||||
roll = 5.0,
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
bookedSlotIds = listOf(301L),
|
||||
cellNums = listOf(42L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
kotlin.test.assertFailsWith<Exception> {
|
||||
complexPlanPersistenceService.replaceModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 301,
|
||||
time = intervalStart.plusMinutes(20),
|
||||
timeStop = intervalStart.plusMinutes(30),
|
||||
roll = 6.0,
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
bookedSlotIds = listOf(302L),
|
||||
cellNums = listOf(43L)
|
||||
)
|
||||
)
|
||||
),
|
||||
complexPlanRunId = Long.MAX_VALUE
|
||||
)
|
||||
}
|
||||
|
||||
val modes = satelliteModeQueryService.findModesByInterval(listOf(22L), intervalStart, intervalEnd)
|
||||
assertEquals(1, modes.size)
|
||||
assertEquals("SLOTS", modes.single().source)
|
||||
assertEquals(listOf(301L), modes.single().bookedSlotIds)
|
||||
assertEquals(listOf(42L), modes.single().cellNums)
|
||||
assertEquals(1, satelliteModeSnapshotRepository.count())
|
||||
assertTrue(satelliteModeSnapshotRepository.findAll().single().isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveModesForInterval creates stage4 snapshot and keeps it active`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 28, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 28, 12, 0)
|
||||
|
||||
val result = satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 601,
|
||||
time = intervalStart.plusMinutes(10),
|
||||
timeStop = intervalStart.plusMinutes(20),
|
||||
roll = 5.0,
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, result.snapshotIds.size)
|
||||
assertEquals(result.snapshotIds, result.activeSnapshotIds)
|
||||
val snapshot = satelliteModeSnapshotRepository.findById(result.snapshotIds.single()).orElseThrow()
|
||||
assertEquals(22L, snapshot.satelliteId)
|
||||
assertTrue(snapshot.isActive)
|
||||
assertEquals(snapshot.id, satelliteModeRepository.findAll().single().snapshot?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveModesForInterval persists MANUAL source`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 450,
|
||||
time = intervalStart.plusMinutes(10),
|
||||
timeStop = intervalStart.plusMinutes(20),
|
||||
roll = 6.0,
|
||||
source = SatelliteModeSource.MANUAL,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
cellNums = listOf(42L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val savedMode = satelliteModeRepository.findAll().single()
|
||||
assertEquals(SatelliteModeSource.MANUAL, savedMode.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveModesForInterval derives coordinates from contour for COMPLAN and MIXED`() {
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
|
||||
|
||||
satelliteModePersistenceService.saveModesForInterval(
|
||||
satelliteIds = listOf(22L),
|
||||
intervalStart = intervalStart,
|
||||
intervalEnd = intervalEnd,
|
||||
surveysBySatelliteId = mapOf(
|
||||
22L to listOf(
|
||||
SurveyMode(
|
||||
revolution = 500,
|
||||
time = intervalStart.plusMinutes(5),
|
||||
timeStop = intervalStart.plusMinutes(15),
|
||||
roll = 4.0,
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))",
|
||||
cellNums = listOf(42L)
|
||||
),
|
||||
SurveyMode(
|
||||
revolution = 501,
|
||||
time = intervalStart.plusMinutes(20),
|
||||
timeStop = intervalStart.plusMinutes(30),
|
||||
roll = 4.0,
|
||||
source = SatelliteModeSource.MIXED,
|
||||
contourWKT = "POLYGON ((10 10, 12 10, 12 11, 10 11, 10 10))",
|
||||
cellNums = listOf(43L)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val savedModes = satelliteModeRepository.findAll().sortedBy { it.startTime }
|
||||
assertEquals(2, savedModes.size)
|
||||
assertEquals(0.5, savedModes[0].lat)
|
||||
assertEquals(1.0, savedModes[0].longitude)
|
||||
assertEquals(10.5, savedModes[1].lat)
|
||||
assertEquals(11.0, savedModes[1].longitude)
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeEntity
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeType
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeBookedSlotRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeCellRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeRepository
|
||||
import space.nstart.pcp.pcp_satellites_service.repository.SatelliteModeSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SatelliteModeQueryServiceTest {
|
||||
|
||||
@Test
|
||||
fun `loadDayConstraintModes uses full day boundaries for single day interval`() {
|
||||
val satelliteModeRepository = mock(SatelliteModeRepository::class.java)
|
||||
val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java)
|
||||
val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java)
|
||||
val cellRepository = mock(SatelliteModeCellRepository::class.java)
|
||||
val service = SatelliteModeQueryService(
|
||||
satelliteModeRepository = satelliteModeRepository,
|
||||
satelliteModeSnapshotRepository = satelliteModeSnapshotRepository,
|
||||
satelliteModeBookedSlotRepository = bookedSlotRepository,
|
||||
satelliteModeCellRepository = cellRepository,
|
||||
satelliteModeMapper = SatelliteModeMapper(),
|
||||
satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper()
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 15)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 45)
|
||||
val dayMode = modeEntity(
|
||||
id = 1L,
|
||||
revolution = 501L,
|
||||
startTime = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
endTime = LocalDateTime.of(2026, 3, 26, 12, 10)
|
||||
)
|
||||
val sameRevolutionOutsideDayWindow = modeEntity(
|
||||
id = 2L,
|
||||
revolution = 501L,
|
||||
startTime = LocalDateTime.of(2026, 3, 25, 23, 40),
|
||||
endTime = LocalDateTime.of(2026, 3, 25, 23, 50)
|
||||
)
|
||||
|
||||
doReturn(listOf(dayMode)).`when`(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
listOf(22L),
|
||||
LocalDateTime.of(2026, 3, 26, 0, 0),
|
||||
LocalDateTime.of(2026, 3, 27, 0, 0)
|
||||
)
|
||||
doReturn(emptyList<space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeBookedSlotEntity>())
|
||||
.`when`(bookedSlotRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList())
|
||||
doReturn(emptyList<space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeCellEntity>())
|
||||
.`when`(cellRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList())
|
||||
|
||||
val loaded = service.loadDayConstraintModes(listOf(22L), intervalStart, intervalEnd)
|
||||
|
||||
assertEquals(1, loaded.getValue(22L).size)
|
||||
verify(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
listOf(22L),
|
||||
LocalDateTime.of(2026, 3, 26, 0, 0),
|
||||
LocalDateTime.of(2026, 3, 27, 0, 0)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadDayConstraintModes covers all touched days when interval crosses midnight`() {
|
||||
val satelliteModeRepository = mock(SatelliteModeRepository::class.java)
|
||||
val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java)
|
||||
val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java)
|
||||
val cellRepository = mock(SatelliteModeCellRepository::class.java)
|
||||
val service = SatelliteModeQueryService(
|
||||
satelliteModeRepository = satelliteModeRepository,
|
||||
satelliteModeSnapshotRepository = satelliteModeSnapshotRepository,
|
||||
satelliteModeBookedSlotRepository = bookedSlotRepository,
|
||||
satelliteModeCellRepository = cellRepository,
|
||||
satelliteModeMapper = SatelliteModeMapper(),
|
||||
satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper()
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 23, 0)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 27, 1, 0)
|
||||
|
||||
doReturn(emptyList<SatelliteModeEntity>()).`when`(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
listOf(22L),
|
||||
LocalDateTime.of(2026, 3, 26, 0, 0),
|
||||
LocalDateTime.of(2026, 3, 28, 0, 0)
|
||||
)
|
||||
|
||||
service.loadDayConstraintModes(listOf(22L), intervalStart, intervalEnd)
|
||||
|
||||
verify(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
listOf(22L),
|
||||
LocalDateTime.of(2026, 3, 26, 0, 0),
|
||||
LocalDateTime.of(2026, 3, 28, 0, 0)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRevolutionConstraintModes loads same satellite revolution context outside day window`() {
|
||||
val satelliteModeRepository = mock(SatelliteModeRepository::class.java)
|
||||
val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java)
|
||||
val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java)
|
||||
val cellRepository = mock(SatelliteModeCellRepository::class.java)
|
||||
val service = SatelliteModeQueryService(
|
||||
satelliteModeRepository = satelliteModeRepository,
|
||||
satelliteModeSnapshotRepository = satelliteModeSnapshotRepository,
|
||||
satelliteModeBookedSlotRepository = bookedSlotRepository,
|
||||
satelliteModeCellRepository = cellRepository,
|
||||
satelliteModeMapper = SatelliteModeMapper(),
|
||||
satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper()
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 15)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 45)
|
||||
val dayMode = modeEntity(
|
||||
id = 1L,
|
||||
revolution = 501L,
|
||||
startTime = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
endTime = LocalDateTime.of(2026, 3, 26, 12, 10)
|
||||
)
|
||||
val sameRevolutionOutsideDayWindow = modeEntity(
|
||||
id = 2L,
|
||||
revolution = 501L,
|
||||
startTime = LocalDateTime.of(2026, 3, 25, 23, 40),
|
||||
endTime = LocalDateTime.of(2026, 3, 25, 23, 50)
|
||||
)
|
||||
|
||||
doReturn(listOf(dayMode)).`when`(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
listOf(22L),
|
||||
LocalDateTime.of(2026, 3, 26, 0, 0),
|
||||
LocalDateTime.of(2026, 3, 27, 0, 0)
|
||||
)
|
||||
doReturn(listOf(dayMode, sameRevolutionOutsideDayWindow)).`when`(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc(22L, setOf(501L))
|
||||
doReturn(emptyList<space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeBookedSlotEntity>())
|
||||
.`when`(bookedSlotRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList())
|
||||
doReturn(emptyList<space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeCellEntity>())
|
||||
.`when`(cellRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList())
|
||||
|
||||
val loaded = service.loadRevolutionConstraintModes(listOf(22L), intervalStart, intervalEnd)
|
||||
|
||||
assertEquals(2, loaded.getValue(22L).size)
|
||||
assertTrue(loaded.getValue(22L).any { it.time == sameRevolutionOutsideDayWindow.startTime })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadConstraintModes returns merged deduplicated context`() {
|
||||
val satelliteModeRepository = mock(SatelliteModeRepository::class.java)
|
||||
val satelliteModeSnapshotRepository = mock(SatelliteModeSnapshotRepository::class.java)
|
||||
val bookedSlotRepository = mock(SatelliteModeBookedSlotRepository::class.java)
|
||||
val cellRepository = mock(SatelliteModeCellRepository::class.java)
|
||||
val service = SatelliteModeQueryService(
|
||||
satelliteModeRepository = satelliteModeRepository,
|
||||
satelliteModeSnapshotRepository = satelliteModeSnapshotRepository,
|
||||
satelliteModeBookedSlotRepository = bookedSlotRepository,
|
||||
satelliteModeCellRepository = cellRepository,
|
||||
satelliteModeMapper = SatelliteModeMapper(),
|
||||
satelliteModeSnapshotMapper = SatelliteModeSnapshotMapper()
|
||||
)
|
||||
|
||||
val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 15)
|
||||
val intervalEnd = LocalDateTime.of(2026, 3, 26, 11, 45)
|
||||
val dayMode = modeEntity(
|
||||
id = 1L,
|
||||
revolution = 501L,
|
||||
startTime = LocalDateTime.of(2026, 3, 26, 12, 0),
|
||||
endTime = LocalDateTime.of(2026, 3, 26, 12, 10)
|
||||
)
|
||||
val sameRevolutionOutsideDayWindow = modeEntity(
|
||||
id = 2L,
|
||||
revolution = 501L,
|
||||
startTime = LocalDateTime.of(2026, 3, 25, 23, 40),
|
||||
endTime = LocalDateTime.of(2026, 3, 25, 23, 50)
|
||||
)
|
||||
|
||||
doReturn(listOf(dayMode)).`when`(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdInAndSnapshot_IsActiveTrueAndEndTimeGreaterThanAndStartTimeLessThanOrderByStartTimeAsc(
|
||||
listOf(22L),
|
||||
LocalDateTime.of(2026, 3, 26, 0, 0),
|
||||
LocalDateTime.of(2026, 3, 27, 0, 0)
|
||||
)
|
||||
doReturn(listOf(dayMode, sameRevolutionOutsideDayWindow)).`when`(satelliteModeRepository)
|
||||
.findAllBySnapshot_SatelliteIdAndSnapshot_IsActiveTrueAndRevolutionInOrderByStartTimeAsc(22L, setOf(501L))
|
||||
doReturn(emptyList<space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeBookedSlotEntity>())
|
||||
.`when`(bookedSlotRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList())
|
||||
doReturn(emptyList<space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeCellEntity>())
|
||||
.`when`(cellRepository).findAllByMode_IdIn(org.mockito.ArgumentMatchers.anyList())
|
||||
|
||||
val loaded = service.loadConstraintModes(listOf(22L), intervalStart, intervalEnd)
|
||||
|
||||
assertEquals(2, loaded.getValue(22L).size)
|
||||
assertEquals(
|
||||
listOf(sameRevolutionOutsideDayWindow.startTime, dayMode.startTime),
|
||||
loaded.getValue(22L).map { it.time }
|
||||
)
|
||||
}
|
||||
|
||||
private fun modeEntity(
|
||||
id: Long,
|
||||
revolution: Long,
|
||||
startTime: LocalDateTime,
|
||||
endTime: LocalDateTime
|
||||
) = SatelliteModeEntity(
|
||||
id = id,
|
||||
satelliteId = 22L,
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
type = SatelliteModeType.SURVEY,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
revolution = revolution,
|
||||
duration = java.time.Duration.between(startTime, endTime).seconds.toDouble(),
|
||||
roll = 3.0
|
||||
)
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DailyDurationDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.RevolutionDurationDTO
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SatelliteServiceTest {
|
||||
|
||||
@Test
|
||||
fun `mission statistics groups durations by revolution`() {
|
||||
val service = SatelliteService(testSatelliteCatalogClient())
|
||||
val satellite = service.satellites.first { it.satelliteId == 22L }
|
||||
satellite.longMission.surveys.clear()
|
||||
|
||||
satellite.longMission.surveys.add(
|
||||
SurveyMode(
|
||||
revolution = 101,
|
||||
time = LocalDateTime.of(2026, 3, 26, 10, 0),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 10, 5),
|
||||
duration = 300.0
|
||||
)
|
||||
)
|
||||
satellite.longMission.surveys.add(
|
||||
SurveyMode(
|
||||
revolution = 101,
|
||||
time = LocalDateTime.of(2026, 3, 26, 10, 10),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 10, 12),
|
||||
duration = 120.0
|
||||
)
|
||||
)
|
||||
satellite.longMission.surveys.add(
|
||||
SurveyMode(
|
||||
revolution = 102,
|
||||
time = LocalDateTime.of(2026, 3, 26, 11, 0),
|
||||
timeStop = LocalDateTime.of(2026, 3, 26, 11, 3),
|
||||
duration = 180.0,
|
||||
contourWKT = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))"
|
||||
)
|
||||
)
|
||||
satellite.longMission.surveys.add(
|
||||
SurveyMode(
|
||||
revolution = 103,
|
||||
time = LocalDateTime.of(2026, 3, 26, 23, 59),
|
||||
timeStop = LocalDateTime.of(2026, 3, 27, 0, 1),
|
||||
duration = 120.0,
|
||||
contourWKT = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
)
|
||||
)
|
||||
|
||||
val stats = service.missionStatistics(22L)
|
||||
|
||||
assertEquals(4, stats.modesCount)
|
||||
assertEquals(3.0, stats.totalContourArea)
|
||||
assertContentEquals(
|
||||
listOf(
|
||||
RevolutionDurationDTO(revolution = 101, totalDuration = 420.0),
|
||||
RevolutionDurationDTO(revolution = 102, totalDuration = 180.0),
|
||||
RevolutionDurationDTO(revolution = 103, totalDuration = 120.0)
|
||||
),
|
||||
stats.revolutionDurations
|
||||
)
|
||||
assertContentEquals(
|
||||
listOf(
|
||||
DailyDurationDTO(date = LocalDate.of(2026, 3, 26), totalDuration = 660.0),
|
||||
DailyDurationDTO(date = LocalDate.of(2026, 3, 27), totalDuration = 60.0)
|
||||
),
|
||||
stats.dailyDurations
|
||||
)
|
||||
}
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
package space.nstart.pcp.pcp_satellites_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.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
|
||||
import space.nstart.pcp.pcp_satellites_service.model.BLS
|
||||
import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel
|
||||
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SurveyPlacementServiceTest {
|
||||
|
||||
private val service = SurveyPlacementService()
|
||||
|
||||
@Test
|
||||
fun `candidate fits without trimming`() {
|
||||
val surveys = mutableListOf<SurveyMode>()
|
||||
val satellite = satellite()
|
||||
|
||||
val decision = service.tryPlaceSurvey(surveys, survey(start = 0, end = 40), satellite)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertFalse(decision.trimmed)
|
||||
assertEquals(1, surveys.size)
|
||||
assertEquals(40.0, surveys.single().duration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `candidate exceeds daily max duration and is trimmed then accepted`() {
|
||||
val surveys = mutableListOf(survey(start = 600, end = 670, revolution = 2, roll = 20.0))
|
||||
val satellite = satellite(dailyMaxDuration = 100.0)
|
||||
|
||||
val decision = service.tryPlaceSurvey(surveys, survey(start = 0, end = 50), satellite)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertTrue(decision.trimmed)
|
||||
assertEquals(2, surveys.size)
|
||||
assertEquals(30.0, decision.adjustedCandidate?.duration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `candidate exceeds daily max duration and is rejected below duration min`() {
|
||||
val surveys = mutableListOf(survey(start = 600, end = 695, revolution = 2, roll = 20.0))
|
||||
val satellite = satellite(dailyMaxDuration = 100.0)
|
||||
|
||||
val decision = service.tryPlaceSurvey(surveys, survey(start = 0, end = 20), satellite)
|
||||
|
||||
assertFalse(decision.accepted)
|
||||
assertEquals("daily_max_duration", decision.rejectionReason)
|
||||
assertEquals(1, surveys.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `candidate exceeds revolution max duration and is trimmed then accepted`() {
|
||||
val surveys = mutableListOf(survey(start = 600, end = 670, revolution = 5, roll = 20.0))
|
||||
val satellite = satellite(revolutionMaxDuration = 100.0)
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(start = 0, end = 50, revolution = 5),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertTrue(decision.trimmed)
|
||||
assertEquals(30.0, decision.adjustedCandidate?.duration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `candidate exceeds merged chain duration max and is trimmed then accepted`() {
|
||||
val surveys = mutableListOf(survey(start = 0, end = 80, roll = 10.0))
|
||||
val satellite = satellite(durationMax = 100.0, mmi = 10.0)
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(start = 50, end = 140, roll = 10.0),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertTrue(decision.trimmed)
|
||||
assertEquals(1, surveys.size)
|
||||
assertEquals(0, secondsFromBase(surveys.single().time))
|
||||
assertEquals(100, secondsFromBase(surveys.single().timeStop))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge allowed when time overlaps and geometry intersects`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
roll = 10.0,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite()
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 20,
|
||||
end = 70,
|
||||
roll = 10.0,
|
||||
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertEquals(1, surveys.size)
|
||||
assertTrue(surveys.single().contourWKT.startsWith("POLYGON"))
|
||||
assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different roll conflict resolved by trimming`() {
|
||||
val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0))
|
||||
val satellite = satellite(mmi = 10.0)
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(start = 0, end = 70, roll = 10.0),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertTrue(decision.trimmed)
|
||||
assertEquals(40.0, decision.adjustedCandidate?.duration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different roll conflict that cannot be resolved is rejected`() {
|
||||
val surveys = mutableListOf(survey(start = 5, end = 40, roll = 20.0))
|
||||
val satellite = satellite(mmi = 10.0)
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(start = 0, end = 30, roll = 10.0),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertFalse(decision.accepted)
|
||||
assertEquals("different_roll_conflict", decision.rejectionReason)
|
||||
assertEquals(1, surveys.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epsilon based roll comparison treats nearly equal rolls as same chain`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
roll = 10.0,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite()
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 20,
|
||||
end = 70,
|
||||
roll = 10.005,
|
||||
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertEquals(1, surveys.size)
|
||||
assertEquals(70, secondsFromBase(surveys.single().timeStop))
|
||||
assertNotNull(decision.appliedSurvey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge forbidden when time overlaps but geometry does not intersect`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite()
|
||||
|
||||
val logs = captureLogs {
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 20,
|
||||
end = 70,
|
||||
contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
}
|
||||
|
||||
assertEquals(2, surveys.size)
|
||||
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge forbidden when contours only touch by boundary`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite()
|
||||
|
||||
val logs = captureLogs {
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 20,
|
||||
end = 70,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
}
|
||||
|
||||
assertEquals(2, surveys.size)
|
||||
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge forbidden when intervals do not overlap but gap is within mmi`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite(mmi = 10.0)
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 45,
|
||||
end = 70,
|
||||
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertEquals(2, surveys.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge allowed when intervals are adjacent and contours are continuous`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite()
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 40,
|
||||
end = 70,
|
||||
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertEquals(1, surveys.size)
|
||||
assertEquals(0, secondsFromBase(surveys.single().time))
|
||||
assertEquals(70, secondsFromBase(surveys.single().timeStop))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `defensive fallback skips merge when union returns multipolygon`() {
|
||||
val logs = captureLogs {
|
||||
val method = service.javaClass.getDeclaredMethod(
|
||||
"unionContoursIfPolygon",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
Long::class.javaPrimitiveType,
|
||||
SurveyMode::class.java,
|
||||
SurveyMode::class.java
|
||||
)
|
||||
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,
|
||||
survey(start = 20, end = 70, contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"),
|
||||
survey(start = 0, end = 40, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")
|
||||
)
|
||||
|
||||
assertEquals(null, result)
|
||||
}
|
||||
|
||||
assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no multipolygon persisted for merged route`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite()
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 20,
|
||||
end = 70,
|
||||
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertEquals(1, surveys.size)
|
||||
assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON"))
|
||||
}
|
||||
|
||||
private fun satellite(
|
||||
durationMin: Double = 10.0,
|
||||
durationMax: Double = 300.0,
|
||||
mmi: Double = 10.0,
|
||||
dailyMaxDuration: Double = 1_000.0,
|
||||
revolutionMaxDuration: Double = 1_000.0
|
||||
) = SatelliteModel(
|
||||
satelliteId = 22,
|
||||
name = "T",
|
||||
bls = BLS(
|
||||
durationMin = durationMin,
|
||||
durationMax = durationMax,
|
||||
mmi = mmi,
|
||||
dailyMaxDuration = dailyMaxDuration,
|
||||
revolutionMaxDuration = revolutionMaxDuration
|
||||
)
|
||||
)
|
||||
|
||||
private fun survey(
|
||||
start: Long,
|
||||
end: Long,
|
||||
revolution: Long = 1,
|
||||
roll: Double = 10.0,
|
||||
source: SatelliteModeSource = SatelliteModeSource.COMPLAN,
|
||||
contour: String = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
|
||||
) = SurveyMode(
|
||||
revolution = revolution,
|
||||
time = baseTime().plusSeconds(start),
|
||||
timeStop = baseTime().plusSeconds(end),
|
||||
roll = roll,
|
||||
duration = Duration.between(baseTime().plusSeconds(start), baseTime().plusSeconds(end)).seconds.toDouble(),
|
||||
contourWKT = contour,
|
||||
source = source
|
||||
)
|
||||
|
||||
private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0)
|
||||
|
||||
private fun secondsFromBase(value: LocalDateTime): Long = Duration.between(baseTime(), value).seconds
|
||||
|
||||
private fun captureLogs(block: () -> Unit): List<ILoggingEvent> {
|
||||
val logger = LoggerFactory.getLogger(SurveyPlacementService::class.java) as Logger
|
||||
val appender = ListAppender<ILoggingEvent>()
|
||||
appender.start()
|
||||
logger.addAppender(appender)
|
||||
try {
|
||||
block()
|
||||
return appender.list.toList()
|
||||
} finally {
|
||||
logger.detachAppender(appender)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package space.nstart.pcp.pcp_satellites_service.service
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.Primary
|
||||
import org.springframework.context.annotation.Profile
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
|
||||
|
||||
fun testSatelliteCatalogClient(): SatelliteCatalogClient = object : SatelliteCatalogClient {
|
||||
override fun allSatellites(): List<SatelliteDTO> = listOf(
|
||||
satelliteDto(10L, "Reflexio", SatelliteVisualizationDTO(125, 125, 78)),
|
||||
satelliteDto(11L, "Reflexio", SatelliteVisualizationDTO(125, 125, 78)),
|
||||
satelliteDto(22L, "Emissio", SatelliteVisualizationDTO(0, 125, 0)),
|
||||
satelliteDto(56756L, "KONDOR-FKA NO. 1", SatelliteVisualizationDTO(255, 0, 0), scanTle = true),
|
||||
satelliteDto(62138L, "KONDOR-FKA NO. 2", SatelliteVisualizationDTO(255, 125, 0), scanTle = true)
|
||||
)
|
||||
}
|
||||
|
||||
private fun satelliteDto(
|
||||
id: Long,
|
||||
name: String,
|
||||
visualization: SatelliteVisualizationDTO,
|
||||
scanTle: Boolean = false
|
||||
) = SatelliteDTO(
|
||||
id = id,
|
||||
noradId = if (scanTle) id else null,
|
||||
code = "TEST-$id",
|
||||
name = name,
|
||||
typeCode = name.uppercase().replace(' ', '_'),
|
||||
active = true,
|
||||
scanTle = scanTle,
|
||||
visualization = visualization,
|
||||
observationProfile = SatelliteObservationProfileDTO()
|
||||
)
|
||||
|
||||
@Configuration
|
||||
@Profile("test")
|
||||
class TestSatelliteCatalogClientConfiguration {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
fun satelliteCatalogClient(): SatelliteCatalogClient = testSatelliteCatalogClient()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
spring:
|
||||
cloud:
|
||||
config:
|
||||
enabled: false
|
||||
datasource:
|
||||
url: jdbc:h2:mem:pcp_satellites;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.H2Dialect
|
||||
flyway:
|
||||
enabled: false
|
||||
|
||||
settings:
|
||||
calculation-interval: 3
|
||||
ballistics-service: http://localhost:7003
|
||||
earth-grid-service: http://localhost:7005
|
||||
complex-mission-service: http://localhost:7002
|
||||
stations-service: http://localhost:7009
|
||||
slots-service: http://localhost:7006
|
||||
coverage-scheme-service: http://localhost:7011
|
||||
Reference in New Issue
Block a user