This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,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)
}
@@ -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
}
@@ -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)
}
}
@@ -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()) }
}
@@ -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()
}
@@ -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
}
}
@@ -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())
}
}
@@ -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)
}
@@ -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
}
}
@@ -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?
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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()
)
@@ -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
)
@@ -0,0 +1,8 @@
package space.nstart.pcp.pcp_satellites_service.entity
enum class ComplexPlanRunStatus {
CREATED,
RUNNING,
COMPLETED,
FAILED
}
@@ -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
)
@@ -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
)
@@ -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()
)
@@ -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()
)
@@ -0,0 +1,8 @@
package space.nstart.pcp.pcp_satellites_service.entity
enum class SatelliteModeSource {
SLOTS,
COMPLAN,
MIXED,
MANUAL
}
@@ -0,0 +1,5 @@
package space.nstart.pcp.pcp_satellites_service.entity
enum class SatelliteModeType {
SURVEY
}
@@ -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
){
}
@@ -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
}
}
@@ -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
}
}
@@ -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
)
}
@@ -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>
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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>
}
@@ -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()
}
@@ -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)
}
}
@@ -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
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
@@ -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
)
}
@@ -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")
}
}
}
@@ -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())
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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>>
}
@@ -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")
}
@@ -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)
}
}
@@ -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"
@@ -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
}
}
@@ -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()
}
@@ -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
)
@@ -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)
}
}
@@ -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
)
}
}
@@ -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
}
}
@@ -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
)
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
@@ -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);
@@ -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);
@@ -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'));
@@ -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;
@@ -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);
@@ -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'));
@@ -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;
@@ -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;