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,8 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
@@ -0,0 +1,112 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
jacoco
// id("org.graalvm.buildtools.native")
}
version = "1.0.0"
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
dependencies {
implementation(project(":libs:pcp-types-lib"))
implementation(project(":libs:ballistics-lib"))
// implementation("org.nstart.dep265:ballistics:2.4.0")
implementation("${property("dep.spring.actuator")}")
implementation("org.springframework.boot:spring-boot-starter-logging")
implementation("org.springframework.boot:spring-boot-starter-validation")
// implementation("org.springframework.boot:spring-boot-starter-web")
implementation("jakarta.validation:jakarta.validation-api")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.4")
implementation("com.github.alexej520:clipper-kotlin:1.0")
implementation("org.springframework.kafka:spring-kafka")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// implementation("org.springframework.kafka:spring-kafka")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
implementation("jakarta.validation:jakarta.validation-api")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.locationtech.jts:jts-core:1.19.0")
testImplementation("junit:junit")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:junit-jupiter")
testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}")
}
}
tasks.withType<Jar> {
manifest {
attributes["Built-By"] = "nstart"
attributes["Implementation-Version"] = archiveVersion
}
}
tasks.withType<Test> {
enabled = true
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
systemProperty("spring.profiles.active", "test")
}
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
html.required = true
csv.required = false
}
}
sonar {
properties {
property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}")
}
}
@@ -0,0 +1,93 @@
package space.nstart.pcp.pcp_request_service
import org.slf4j.LoggerFactory
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.ApplicationListener
import org.springframework.core.env.ConfigurableEnvironment
import org.springframework.core.env.EnumerablePropertySource
import org.springframework.core.env.PropertySource
import org.springframework.kafka.annotation.EnableKafka
import org.springframework.scheduling.annotation.EnableAsync
@SpringBootApplication
@EnableAsync
@EnableKafka
class PcpBallisticsServiceApplication
fun main(args: Array<String>) {
runApplication<PcpBallisticsServiceApplication>(*args) {
addListeners(BallisticsStartupConfigurationLogger())
}
}
private class BallisticsStartupConfigurationLogger : ApplicationListener<ApplicationEnvironmentPreparedEvent> {
private val logger = LoggerFactory.getLogger(BallisticsStartupConfigurationLogger::class.java)
override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) {
val environment = event.environment
val activeProfiles = environment.activeProfiles.takeIf { it.isNotEmpty() } ?: environment.defaultProfiles
logger.info(
"Ballistics startup config client: activeProfiles={}, configServerUri={}, configProfile={}, configLabel={}",
activeProfiles.joinToString(","),
maskIfSensitive("spring.cloud.config.uri", environment.getProperty("spring.cloud.config.uri")),
environment.getProperty("spring.cloud.config.profile"),
environment.getProperty("spring.cloud.config.label"),
)
val configSources = environment.configServerPropertySources()
if (configSources.isEmpty()) {
logger.warn("Ballistics received no config server property sources")
return
}
configSources.forEach { source ->
logger.info("Ballistics received config source '{}': {}", source.name, source.printableProperties(environment))
}
}
private fun ConfigurableEnvironment.configServerPropertySources(): List<PropertySource<*>> =
propertySources
.asSequence()
.filter { it.name.startsWith("configserver:") || it.name == "configClient" }
.toList()
private fun PropertySource<*>.printableProperties(environment: ConfigurableEnvironment): String {
if (this !is EnumerablePropertySource<*>) {
return "<non-enumerable>"
}
return propertyNames
.sorted()
.joinToString(", ") { propertyName ->
formatProperty(propertyName, getProperty(propertyName), environment.getProperty(propertyName))
}
}
private fun formatProperty(propertyName: String, rawValue: Any?, resolvedValue: String?): String {
val printableResolvedValue = maskIfSensitive(propertyName, resolvedValue)
val printableRawValue = maskIfSensitive(propertyName, rawValue)
if (printableRawValue == printableResolvedValue) {
return "$propertyName=$printableResolvedValue"
}
return "$propertyName=$printableResolvedValue (raw=$printableRawValue)"
}
private fun maskIfSensitive(propertyName: String, value: Any?): String {
if (value == null) {
return "<null>"
}
val normalizedName = propertyName.lowercase()
val sensitiveMarkers = listOf("password", "passwd", "secret", "token", "credential", "private-key", "api-key", "apikey")
return if (sensitiveMarkers.any { normalizedName.contains(it) }) {
"******"
} else {
value.toString()
}
}
}
@@ -0,0 +1,46 @@
package space.nstart.pcp.pcp_request_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,138 @@
package space.nstart.pcp.pcp_request_service.configuration
import org.apache.kafka.clients.admin.AdminClientConfig
import org.apache.kafka.clients.admin.NewTopic
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.header.Header
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.kafka.common.serialization.StringSerializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.KafkaAdmin
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
import org.springframework.kafka.listener.adapter.RecordFilterStrategy
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import java.nio.charset.StandardCharsets
import kotlin.String
@ConditionalOnProperty(name=["spring.kafka.bootstrap-servers"], matchIfMissing = false)
@Configuration
class KafkaConfig {
companion object {
const val HEADER_NAME: String = "type"
const val TARGET_CONSUMER_HEADER_NAME: String = "target_consumer"
}
@Value("\${spring.kafka.template.default-topic}")
private lateinit var defaultTopic: String
@Bean
fun tleTopic(): NewTopic =
NewTopic(defaultTopic, 1, 1)
@Value("\${spring.kafka.bootstrap-servers}")
private lateinit var addr: String
@Value("\${spring.kafka.consumer.group-id}")
private lateinit var groupId: String
@Bean
fun template(): KafkaTemplate<String, Any> {
return KafkaTemplate(producerFactory())
}
@Bean
fun kafkaAdmin(): KafkaAdmin {
val configs = hashMapOf<String, Any>()
configs[AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG] = addr
return KafkaAdmin(configs)
}
@Bean
fun consumerFactory(): ConsumerFactory<String, Any> {
val props = hashMapOf<String, Any>()
props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr
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, Any> =
ConcurrentKafkaListenerContainerFactory<String, Any>()
.apply { this.setConsumerFactory(consumerFactory()) }
@Bean
fun producerFactory(): ProducerFactory<String, Any> {
val configProps = hashMapOf<String, Any>()
configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = addr
configProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java
configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java
return DefaultKafkaProducerFactory(configProps)
}
private fun getRecordFilterStrategy(event: PcpKafkaEvent): RecordFilterStrategy<String, String> {
return RecordFilterStrategy { consumerRecord: ConsumerRecord<String, String> ->
val eventTypeHeader: Header? = consumerRecord.headers().lastHeader(HEADER_NAME)
if (eventTypeHeader != null) {
val headerValue = String(eventTypeHeader.value(), StandardCharsets.UTF_8)
return@RecordFilterStrategy event.name != headerValue
}
true
}
}
private fun getRecordFilterStrategyByConsumer(): RecordFilterStrategy<String, String> {
return RecordFilterStrategy { consumerRecord: ConsumerRecord<String, String> ->
val consumerGroupHeader: Header? = consumerRecord.headers().lastHeader(TARGET_CONSUMER_HEADER_NAME)
if (consumerGroupHeader != null) {
val headerValue = String(consumerGroupHeader.value(), StandardCharsets.UTF_8)
return@RecordFilterStrategy "respondedListener" != headerValue
}
true
}
}
@Bean("placedIcFilter")
fun placedFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategy(PcpKafkaEvent.ICPlacedEvent)
}
@Bean("placedIcRvFilter")
fun placedIcRvFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategy(PcpKafkaEvent.ICRVPlacedEvent)
}
@Bean("updatedIcFilter")
fun updatedIcFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategy(PcpKafkaEvent.ICUpdatedEvent)
}
@Bean("satelliteDeletedFilter")
fun satelliteDeletedFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategy(PcpKafkaEvent.SatelliteDeletedEvent)
}
@Bean("respondedFilter")
fun respondedFilter(): RecordFilterStrategy<String, String> {
return getRecordFilterStrategyByConsumer()
}
}
@@ -0,0 +1,14 @@
package space.nstart.pcp.pcp_request_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,37 @@
package space.nstart.pcp.pcp_request_service.controller
import jakarta.validation.Valid
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.RestController
import space.nstart.pcp.pcp_request_service.service.SatelliteService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
@RestController
@RequestMapping("/api/obj-view")
class ObjViewController {
@Autowired
private lateinit var satelliteService: SatelliteService
@PostMapping("/mpl-square")
fun mplSquare(
@Valid @RequestBody body : ObjViewRequestDTO
) =
satelliteService.mplSquare(body)
@PostMapping("/mpl-point")
fun mplPoint(
@Valid @RequestBody body : ObjViewRequestDTO
) =
satelliteService.mplPoint(body)
@GetMapping("cell-coverage/{number}")
fun cellCov(@PathVariable number : Long) = satelliteService.getCellCovering(number)
}
@@ -0,0 +1,21 @@
package space.nstart.pcp.pcp_request_service.controller
import org.springframework.beans.factory.annotation.Autowired
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.RestController
import space.nstart.pcp.pcp_request_service.service.RVAService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.IcRvaRequestDTO
@RestController
@RequestMapping("api/rva")
class RVAController {
@Autowired
private lateinit var rvaService: RVAService
@PostMapping
fun getZRV(@RequestBody body : IcRvaRequestDTO) = rvaService.calculateRVA(body)
}
@@ -0,0 +1,126 @@
package space.nstart.pcp.pcp_request_service.controller
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid
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_request_service.service.SatelliteService
import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import java.time.LocalDateTime
@RestController
@RequestMapping("/api/satellites")
class SatelliteController {
@Autowired
private lateinit var satelliteService: SatelliteService
@PostMapping("/{satellite_id}/extract-time")
fun extractTime(
@PathVariable("satellite_id") satelliteId : Long,
@Valid @RequestBody body : ExactTimePositionRequestDTO
) =
satelliteService.exactTime(satelliteId, body)
@Operation(
summary = "Получить ПДЦМ",
description = "Возвраает ПДЦМ для заданного аппарата на заданном интервале времени с шагом 60 с"
)
@GetMapping("/{norad_id}/orbit")
fun getPoints(
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime?,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime?
) =
satelliteService.getOrbPoints(noradId, timeStart, timeStrop)
@Operation(
summary = "Получить доступные интервалы прогноза по всем КА",
description = "Возвращает минимальное и максимальное время ПДЦМ для каждого КА. Если time задан, возвращаются только КА, у которых это время попадает в доступный интервал"
)
@GetMapping("/orbit/availability")
fun getOrbitAvailability(
@Parameter(description = "Проверяемое время. Если null, возвращаются все КА, для которых есть точки ПДЦМ")
@RequestParam("time", required = false) time: LocalDateTime?
) =
satelliteService.getOrbitAvailability(time)
@GetMapping("/orbit/availability/{id}")
fun getOrbitAvailabilityById(
@PathVariable id : Long
) =
satelliteService.getOrbitAvailability(null).filter { it.satelliteId == id }.firstOrNull()?:
throw CustomValidationException("Спутник с заданным идентификатором не зарегистрирован")
@Operation(
summary = "Получить параметры прохождения ВУЗов",
description = "Возвраает массив ВУЗов для заданного аппарата на заданном интервале времени"
)
@GetMapping("/{norad_id}/asc-node")
fun getAskNodes(
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime?,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime?
) =
satelliteService.getAscNodes(noradId, timeStart, timeStrop)
@Operation(
summary = "Получить параметры трассы полета и полосы обзора",
description = "Возвращает параметры ТП и ПО на заданном интервале времен с шагом 60 с"
)
@GetMapping("/{norad_id}/flight-line")
fun getFL(
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime?,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime?
) =
satelliteService.getFL(noradId, timeStart, timeStrop, 60.0)
@Operation(
summary = "Получить параметры ЗРВ",
description = "Возвращает параметры ЗРВ на заданном интервале времен"
)
@GetMapping("/{norad_id}/rva")
fun getRVA(
@Parameter(description = "Идентификатор NORAD", example = "56756")
@PathVariable("norad_id") noradId : Long,
@Parameter(description = "Время начала (если null, то мин. время для выбранного КА)")
@RequestParam("time_start") timeStart : LocalDateTime,
@Parameter(description = "Время конца (если null, то макс. время для выбранного КА)")
@RequestParam("time_stop") timeStrop : LocalDateTime
) =
satelliteService.getRVA(noradId, timeStart, timeStrop)
@PostMapping("/{satellite_id}/clear")
fun clear(
@PathVariable("satellite_id") satelliteId : Long
) =
satelliteService.clear(satelliteId)
}
@@ -0,0 +1,30 @@
package space.nstart.pcp.pcp_request_service.controller
import ballistics.types.OrbitalPoint
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_request_service.service.TLEService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
@RestController
@RequestMapping("/api/tle")
class TLEController {
@Autowired
private lateinit var tleService: TLEService
@PostMapping("/parse")
fun orbit(@RequestBody tle : TLEDTO) = tleService.parseTLE(tle)
@PostMapping("/rva")
fun rva(@RequestBody req : TleRvaRequestDTO) = tleService.rva(req)
}
@@ -0,0 +1,110 @@
package space.nstart.pcp.pcp_request_service.entity
import ballistics.types.KeplerParams
import ballistics.types.RevolutionParameter
import ballistics.utils.fromDateTime
import ballistics.utils.toDateTime
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO
import java.time.LocalDateTime
import kotlin.math.PI
@Entity
@Table(name = "asc_node")
class AscNodeEntity(
@Id
@Column(name = "asc_node_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id : Long? = null,
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
val revolution : Long = 0,
val longitude : Double = 0.0,
val height : Double = 0.0,
val x: Double = 0.0,
val y: Double = 0.0,
val z: Double = 0.0,
val vx: Double = 0.0,
val vy: Double = 0.0,
val vz: Double = 0.0,
) {
constructor(an : RevolutionParameter, sat : Long) : this(
null,
sat,
toDateTime(an.vuz.t),
an.vuz.vit.toLong(),
an.lVuz * 180 / PI,
an.hVuz,
an.vuz.r.x,
an.vuz.r.y,
an.vuz.r.z,
an.vuz.v.x,
an.vuz.v.y,
an.vuz.v.z,
)
private companion object {
const val SECONDS_IN_DAY = 86_400.0
const val NANOS_IN_DAY = 86_400_000_000_000L
}
private fun positiveModulo(value: Double, modulo: Double): Double {
val remainder = value % modulo
return if (remainder < 0) remainder + modulo else remainder
}
/**
* Местное среднее солнечное время на долготе longitudeRad.
* Используется та же идея, что в tmest(t, Lvuz): UTC-время суток плюс поправка по долготе.
*/
private fun localMeanSolarSeconds(epochSeconds: Double, longitudeRad: Double): Double {
val utcSecondsOfDay = positiveModulo(epochSeconds, SECONDS_IN_DAY)
val longitudeShiftSeconds = longitudeRad / (2.0 * PI) * SECONDS_IN_DAY
return positiveModulo(utcSecondsOfDay + longitudeShiftSeconds, SECONDS_IN_DAY)
}
fun tmest(t : LocalDateTime, l : Double) : LocalDateTime
{
var dl = l;
if (dl > PI)
dl = dl - 2 * PI;
var tm = t.plusSeconds(-60*60*3);
tm = tm.plusNanos((dl/2.0/PI * 86400.0*1e9).toLong());
return tm;
}
fun toDTO(keps : KeplerParams, rekv : Double) = AscNodeDTO(
time = this.time,
revolution = this.revolution.toInt(),
long = this.longitude,
height = this.height,
x = this.x,
y = this.y,
z = this.z,
vx = this.vx,
vy = this.vy,
vz = this.vz,
keps = KeplersDTO(
(keps.ael - rekv) / 1000.0,
keps.e,
keps.nakl * 180 / PI,
keps.omegab * 180 / PI,
keps.omegam * 180 / PI
),
localMeanTime = tmest(this.time, this.longitude * PI / 180.0)
)
}
@@ -0,0 +1,58 @@
package space.nstart.pcp.pcp_request_service.entity
import ballistics.utils.toDateTime
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.time.LocalDateTime
import kotlin.math.PI
@Entity
@Table(name = "cell_covering")
class EarthCellViewsEntity(
@Id
@Column(name = "cell_covering_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id : Long? = null,
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(nullable = false)
val cellNum : Long = 0,
@Column(name = "rev_begin", nullable = false)
val revolutionBegin : Long = 0,
@Column(nullable = false)
val timeBegin : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
val revSignBegin : Int = 0,
@Column(name = "rev_end", nullable = false)
val revolutionEnd: Long = 0,
@Column(nullable = false)
val timeEnd : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
val revSignEnd : Int = 0,
@Column(name = "roll_min")
val gammaMin : Double = 0.0,
@Column(name = "roll_max")
val gammaMax : Double = 0.0,
@Column(name = "contour_wkt", nullable = false)
val contour : String = ""
) {
fun toDTO() = SquareViewParamDTO(
this.satelliteId,
this.cellNum.toString(),
this.revolutionBegin,
this.timeBegin,
if (this.revSignBegin == 0) RevolutionSign.ASC else RevolutionSign.DESC,
this.revolutionBegin,
this.timeEnd,
if (this.revSignEnd == 0) RevolutionSign.ASC else RevolutionSign.DESC,
this.gammaMin,
this.gammaMax
)
}
@@ -0,0 +1,36 @@
package space.nstart.pcp.pcp_request_service.entity
import ballistics.types.FleghtLineSector
import ballistics.utils.toDateTime
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
@Entity
@Table(name = "earth_coverage")
class EarthCoverageEntity(
@Id
@Column(name = "earth_coverage_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id : Long? = null,
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(nullable = false)
val cellNumber : Long = 0,
@Column(nullable = false)
val tStart : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
val tStop : LocalDateTime = LocalDateTime.now(),
) {
constructor(fl : FleghtLineSector, sector : Int, sat : Long) : this(
null,
sat,
sector.toLong(),
toDateTime(fl.tStart),
toDateTime(fl.tStop)
)
}
@@ -0,0 +1,77 @@
package space.nstart.pcp.pcp_request_service.entity
import ballistics.types.FlightLine
import ballistics.utils.toDateTime
import jakarta.persistence.*
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime
import kotlin.math.PI
@Entity
@Table(name = "flight_line")
class FlightLineEntity(
@Id
@Column(name = "flight_line_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id : Long? = null,
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
val revolution : Long = 0,
@Column(nullable = false)
val revolutionSign: String = RevolutionSign.ASC.toString(),
val longitude : Double = 0.0,
val latitude : Double = 0.0,
val longitudeOuterLeft : Double = 0.0,
val latitudeOuterLeft : Double = 0.0,
val longitudeInnerLeft : Double = 0.0,
val latitudeInnerLeft : Double = 0.0,
val longitudeInnerRight : Double = 0.0,
val latitudeInnerRight : Double = 0.0,
val longitudeOuterRight : Double = 0.0,
val latitudeOuterRight : Double = 0.0,
) {
constructor(fl : FlightLine, sat : Long) : this(
null,
sat,
toDateTime(fl.t),
fl.vit.toLong(),
if (fl.pv == 0) RevolutionSign.ASC.toString() else RevolutionSign.DESC.toString(),
fl.flightLine.long * 180 / PI,
fl.flightLine.lat * 180 / PI,
fl.leftOuterSwath.long * 180 / PI,
fl.leftOuterSwath.lat * 180 / PI,
fl.leftInternalSwath.long * 180 / PI,
fl.leftInternalSwath.lat * 180 / PI,
fl.rightInternalSwath.long * 180 / PI,
fl.rightInternalSwath.lat * 180 / PI,
fl.rightOuterSwath.long * 180 / PI,
fl.rightOuterSwath.lat * 180 / PI,
)
fun toDTO() = FlightLineDTO(
time = time,
revolution = revolution,
lat = latitude ,
long = longitude,
latLeft = latitudeOuterLeft,
longLeft = longitudeOuterLeft,
latInnerLeft = latitudeInnerLeft,
longInnerLeft = longitudeInnerLeft,
latInnerRight = latitudeInnerRight,
longInnerRight = longitudeInnerRight,
latRight = latitudeOuterRight,
longRight = longitudeOuterRight,
revSign = RevolutionSign.valueOf(revolutionSign)
)
}
@@ -0,0 +1,56 @@
package space.nstart.pcp.pcp_request_service.entity
import ballistics.types.OrbitalPoint
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import ballistics.utils.toDateTime
import jakarta.persistence.*
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import java.time.LocalDateTime
@Entity
@Table(name = "pdcm")
class PDCMEntity (
@Id
@Column(name = "pdcm_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id : Long? = null,
@Column(nullable = false)
val satelliteId : Long = 0,
@Column(name = "t", nullable = false)
val time : LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
val revolution : Long = 0,
val x: Double = 0.0,
val y: Double = 0.0,
val z: Double = 0.0,
val vx: Double = 0.0,
val vy: Double = 0.0,
val vz: Double = 0.0,
){
constructor(op : OrbitalPoint, sat : Long) : this(
null,
sat,
toDateTime(op.t),
op.vit.toLong(),
op.r.x,
op.r.y,
op.r.z,
op.v.x,
op.v.y,
op.v.z
)
fun toOrbitalPoint() = OrbitalPoint(
t = fromDateTime(time),
vit = revolution.toInt(),
r = Vector3D(x,y,z),
v = Vector3D(vx, vy,vz)
)
fun toDTO() = OrbPointDTO(
time = time,
revolution,
vx,vy,vz,x,y,z
)
}
@@ -0,0 +1,20 @@
package space.nstart.pcp.pcp_request_service.message
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
@Component
class KafkaListener {
private var log: Logger = LoggerFactory.getLogger(this::class.java)
@KafkaListener(id = "placedIcFilter", topics = ["\${spring.kafka.template.default-topic}"], filter = "placedIcFilter")
fun listener(data: String?) {
log.info("Received message [{}] in group1 with header1", data)
}
}
@@ -0,0 +1,66 @@
package space.nstart.pcp.pcp_request_service.message
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_request_service.service.SatelliteService
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO
import tools.jackson.databind.ObjectMapper
private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-ballistics-service-satellite-deleted"
@Component
class SatelliteDeletedKafkaListener(
objectMapperProvider: ObjectProvider<ObjectMapper>,
private val satelliteService: SatelliteService
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val objectMapper = objectMapperProvider.ifAvailable
?: throw IllegalStateException("ObjectMapper bean is required for SatelliteDeletedKafkaListener")
@KafkaListener(
id = "satelliteDeletedFilter",
topics = ["\${app.kafka.topics.satellites:pcp.satellites}"],
groupId = SATELLITE_DELETED_CONSUMER_GROUP,
filter = "satelliteDeletedFilter"
)
fun consume(message: String?) {
if (message.isNullOrBlank()) {
logger.warn("Received empty SatelliteDeletedEvent Kafka message")
return
}
try {
val rootNode = objectMapper.readTree(message)
val payloadNode = rootNode["data"] ?: throw IllegalArgumentException("В сообщении отсутствует поле data")
val event = objectMapper.treeToValue(payloadNode, SatelliteDeletedEventDTO::class.java)
logger.info(
"Received SatelliteDeletedEvent in ballistics-service: satelliteId={}, noradId={}, consumerGroup={}",
event.satelliteId,
event.noradId,
SATELLITE_DELETED_CONSUMER_GROUP
)
deleteByIdentifiers(event)
} catch (exception: Exception) {
logger.error("Failed to process SatelliteDeletedEvent in ballistics-service: {}", exception.message, exception)
throw exception
}
}
private fun deleteByIdentifiers(event: SatelliteDeletedEventDTO) {
event.deleteIdentifiers().forEach { satelliteId ->
satelliteService.clear(satelliteId)
}
logger.info(
"SatelliteDeletedEvent processed in ballistics-service: satelliteId={}, noradId={}, consumerGroup={}",
event.satelliteId,
event.noradId,
SATELLITE_DELETED_CONSUMER_GROUP
)
}
private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List<Long> =
listOfNotNull(satelliteId, noradId).distinct()
}
@@ -0,0 +1,70 @@
package space.nstart.pcp.pcp_request_service.message
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.node.ObjectNode
@Component
class SatelliteIcRvKafkaListener(
objectMapperProvider: ObjectProvider<ObjectMapper>,
private val satelliteIcEventService: SatelliteIcEventService
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val objectMapper = objectMapperProvider.ifAvailable
?: throw IllegalStateException("ObjectMapper bean is required for SatelliteIcRvKafkaListener")
@KafkaListener(
id = "placedIcRvFilter",
topics = ["\${spring.kafka.template.default-topic}"],
filter = "placedIcRvFilter"
)
fun listener(data: String?) {
consume(data)
}
fun consume(data: String?) {
if (data.isNullOrBlank()) {
logger.warn("Received empty satellite initial conditions Kafka message")
return
}
try {
logger.info("Received satellite initial conditions Kafka message body: {}", data)
val rootNode = objectMapper.readTree(data)
val payloadNode = rootNode["data"] ?: throw IllegalArgumentException("В сообщении отсутствует поле data")
val payload = objectMapper.treeToValue(normalizePayload(payloadNode), SatelliteICDTO::class.java)
satelliteIcEventService.handlePlacedIcRv(payload)
} catch (exception: Exception) {
logger.error("Ошибка обработки сообщения о начальных условиях КА: {}", exception.message, exception)
throw exception
}
}
/**
* Keeps consumer backward compatible with already published messages where optional IC coefficients were null.
*/
private fun normalizePayload(payloadNode: tools.jackson.databind.JsonNode): tools.jackson.databind.JsonNode {
val normalizedPayload = payloadNode.deepCopy()
val initialConditionsNode = normalizedPayload["ic"]
if (initialConditionsNode is ObjectNode) {
if (initialConditionsNode["sBall"] == null || initialConditionsNode["sBall"].isNull) {
initialConditionsNode.put("sBall", DEFAULT_INITIAL_CONDITIONS.sBall)
}
if (initialConditionsNode["f81"] == null || initialConditionsNode["f81"].isNull) {
initialConditionsNode.put("f81", DEFAULT_INITIAL_CONDITIONS.f81)
}
}
return normalizedPayload
}
companion object {
private val DEFAULT_INITIAL_CONDITIONS = InitialConditionsDTO()
}
}
@@ -0,0 +1,39 @@
package space.nstart.pcp.pcp_request_service.model
import ballistics.utils.toDateTime
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import kotlin.math.PI
data class SquareViewParamsModel(
var id: Long = 0,
var objID : String,
var pointNumb: Int = 0,
var vitN: Int = 0,
var tN: Double = 0.0,
var pvN : RevolutionSign = RevolutionSign.ASC,
var vitK: Int = 0,
var tK: Double = 0.0,
var pvK: RevolutionSign = RevolutionSign.ASC,
var krenMin: Double = 0.0,
var krenMax: Double = 0.0
): Cloneable {
fun toDTO(): SquareViewParamDTO =
SquareViewParamDTO(
this.id,
this.objID,
this.vitN.toLong(),
toDateTime(this.tN),
this.pvN,
this.vitK.toLong(),
toDateTime(this.tK),
this.pvK,
this.krenMin * 180 / PI,
this.krenMax * 180 / PI,
)
public override fun clone(): SquareViewParamsModel {
return super.clone() as SquareViewParamsModel
}
}
@@ -0,0 +1,27 @@
package space.nstart.pcp.pcp_request_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_request_service.entity.AscNodeEntity
import java.time.LocalDateTime
interface AscNodeRepository : JpaRepository<AscNodeEntity, Long>{
@Modifying
@Transactional
@Query("DELETE FROM asc_node WHERE satellite_id =:sat", nativeQuery = true)
fun deleteBySatId(@Param("sat") sat : Long) : Int
fun findBySatelliteId(id : Long) : List<AscNodeEntity>
fun findBySatelliteIdAndTimeBetween(id : Long, t1 : LocalDateTime, t2 : LocalDateTime) : List<AscNodeEntity>
fun findBySatelliteIdAndTimeAfter(id : Long, t : LocalDateTime): List<AscNodeEntity>
fun findBySatelliteIdAndTimeBefore(id : Long, t : LocalDateTime): List<AscNodeEntity>
}
@@ -0,0 +1,22 @@
package space.nstart.pcp.pcp_request_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_request_service.entity.AscNodeEntity
import space.nstart.pcp.pcp_request_service.entity.EarthCellViewsEntity
interface EarthCellViewRepository : JpaRepository<EarthCellViewsEntity, Long>{
@Modifying
@Transactional
@Query("DELETE FROM cell_covering WHERE satellite_id =:sat", nativeQuery = true)
fun deleteBySatId(@Param("sat") sat : Long) : Int
fun findBySatelliteId(id : Long) : List<EarthCellViewsEntity>
fun findByCellNum(num : Long) : List<EarthCellViewsEntity>
}
@@ -0,0 +1,22 @@
package space.nstart.pcp.pcp_request_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_request_service.entity.EarthCoverageEntity
import java.time.LocalDateTime
interface EarthCoverageRepository : JpaRepository<EarthCoverageEntity, Long>{
@Modifying
@Transactional
@Query("DELETE FROM earth_coverage WHERE satellite_id =:sat", nativeQuery = true)
fun deleteBySatId(@Param("sat") sat : Long) : Int
fun findBySatelliteIdAndTStartGreaterThanAndTStopLessThanAndCellNumberIn
(id : Long, tn : LocalDateTime, tk: LocalDateTime, cells : List<Long>) : List<EarthCoverageEntity>
}
@@ -0,0 +1,21 @@
package space.nstart.pcp.pcp_request_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_request_service.entity.FlightLineEntity
import java.time.LocalDateTime
interface FlightLineRepository : JpaRepository<FlightLineEntity, Long>{
@Modifying
@Transactional
@Query("DELETE FROM flight_line WHERE satellite_id =:sat", nativeQuery = true)
fun deleteBySatId(@Param("sat") sat : Long) : Int
fun findBySatelliteIdAndTimeBetween(id : Long, tn : LocalDateTime, tk: LocalDateTime) : List<FlightLineEntity>
}
@@ -0,0 +1,49 @@
package space.nstart.pcp.pcp_request_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_request_service.entity.PDCMEntity
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
import java.time.LocalDateTime
interface PDCMRepository : JpaRepository<PDCMEntity, Long>{
@Modifying
@Transactional
@Query("DELETE FROM pdcm WHERE satellite_id =:sat", nativeQuery = true)
fun deleteBySatId(@Param("sat") sat : Long) : Int
fun findBySatelliteIdAndTimeBetween(id : Long, tBegin : LocalDateTime, tEnd : LocalDateTime) : List<PDCMEntity>
@Query(
"""
select new space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO(
p.satelliteId,
min(p.time),
max(p.time)
)
from PDCMEntity p
group by p.satelliteId
order by p.satelliteId
"""
)
fun findSatelliteOrbitAvailability(): List<SatelliteOrbitAvailabilityDTO>
@Query(
"""
select new space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO(
p.satelliteId,
min(p.time),
max(p.time)
)
from PDCMEntity p
group by p.satelliteId
having :time between min(p.time) and max(p.time)
order by p.satelliteId
"""
)
fun findSatelliteOrbitAvailabilityAtTime(@Param("time") time: LocalDateTime): List<SatelliteOrbitAvailabilityDTO>
}
@@ -0,0 +1,97 @@
package space.nstart.pcp.pcp_request_service.service
import ballistics.Ballistics
import ballistics.types.BallisticsError
import ballistics.types.InitialConditions
import ballistics.types.ModDVType
import ballistics.types.OrbitalPoint
import ballistics.types.PPI
import ballistics.types.VisibilityParametersZRV
import ballistics.types.ZRV
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import ballistics.utils.toDateTime
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_request_service.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.IcRvaRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import kotlin.math.PI
@Service
class RVAService {
fun calculateRVA(icRvaRequest : IcRvaRequestDTO) : List<RadioVisibilityAreaDTO> {
val bal = Ballistics()
val mdv = ModDVType.fromInt((icRvaRequest.modDvType ?: 1).toInt())
bal.modDVType = mdv
var result = bal.calculateOrbPoints(
InitialConditions(
OrbitalPoint(
fromDateTime(icRvaRequest.ic.orbPoint.time),
icRvaRequest.ic.orbPoint.revolution.toInt(),
Vector3D(
icRvaRequest.ic.orbPoint.x,
icRvaRequest.ic.orbPoint.y,
icRvaRequest.ic.orbPoint.z,
),
Vector3D(
icRvaRequest.ic.orbPoint.vx,
icRvaRequest.ic.orbPoint.vy,
icRvaRequest.ic.orbPoint.vz,
)
),
icRvaRequest.ic.sBall,
icRvaRequest.ic.f81
),
fromDateTime(icRvaRequest.timeStart),
fromDateTime(icRvaRequest.timeStop)
)
if (result != BallisticsError.OK)
throw CustomErrorException("Ошибка расчета ПДЦМ. Код ошибки : $result")
result = bal.calculateZRV(
listOf(
PPI(
1, 1,
icRvaRequest.station.position.lat / 180 * PI,
icRvaRequest.station.position.long / 180 * PI,
icRvaRequest.station.position.height,
icRvaRequest.station.elevationMin / 180 * PI,
icRvaRequest.station.elevationMax / 180 * PI,
null, null
)
),
fromDateTime(icRvaRequest.timeStart),
fromDateTime(icRvaRequest.timeStop)
)
if (result != BallisticsError.OK)
throw CustomErrorException("Ошибка расчета ЗРВ. Код ошибки : $result")
return bal.zrv.map { mkRVA(it, 0L) }
}
fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO(
toDateTime(p.t),
p.elevation * 180 / PI,
p.azimuth * 180 / PI,
p.range
)
fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO(
noradId = id,
stationId = p.ppi.toLong(),
revolution = p.vit.toLong(),
onStart = mkTP(p.zoneIn),
onMaximum = mkTP(p.zoneMax),
onStop = mkTP(p.zoneOut)
)
}
@@ -0,0 +1,46 @@
package space.nstart.pcp.pcp_request_service.service
import ballistics.Ballistics
import ballistics.types.BallisticsError
import ballistics.types.TLE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_request_service.entity.AscNodeEntity
import space.nstart.pcp.pcp_request_service.entity.EarthCoverageEntity
import space.nstart.pcp.pcp_request_service.entity.FlightLineEntity
import space.nstart.pcp.pcp_request_service.entity.PDCMEntity
import space.nstart.pcp.pcp_request_service.repository.AscNodeRepository
import space.nstart.pcp.pcp_request_service.repository.EarthCellViewRepository
import space.nstart.pcp.pcp_request_service.repository.EarthCoverageRepository
import space.nstart.pcp.pcp_request_service.repository.FlightLineRepository
import space.nstart.pcp.pcp_request_service.repository.PDCMRepository
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.math.PI
@Service
open class SatelliteIcEventService {
private val logger = LoggerFactory.getLogger(this::class.java)
@Autowired
private lateinit var satelliteService: SatelliteService
open fun handlePlacedIcRv(message: SatelliteICDTO) {
CoroutineScope(Dispatchers.Default).launch {
satelliteService.resieveRV(message)
}
}
}
@@ -0,0 +1,882 @@
package space.nstart.pcp.pcp_request_service.service
import ballistics.Ballistics
import ballistics.orbitalPoints.timeStepper.RungeStepper
import ballistics.types.BallisticsError
import ballistics.types.EarthType
import ballistics.types.FleghtLineSector
import ballistics.types.InitialConditions
import ballistics.types.KeplerParams
import ballistics.types.OPKatObj
import ballistics.types.OrbitalPoint
import ballistics.types.PPI
import ballistics.types.PointViewParams
import ballistics.types.TLE
import ballistics.types.VisibilityParametersZRV
import ballistics.types.ZRV
import ballistics.utils.astro.AstronomerJ2000
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import ballistics.utils.toDateTime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.nstart.dep265.tletools.zeptomoby.core.Globals.sqr
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import space.nstart.pcp.pcp_request_service.configuration.CustomErrorException
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_request_service.entity.AscNodeEntity
import space.nstart.pcp.pcp_request_service.entity.EarthCellViewsEntity
import space.nstart.pcp.pcp_request_service.entity.EarthCoverageEntity
import space.nstart.pcp.pcp_request_service.entity.FlightLineEntity
import space.nstart.pcp.pcp_request_service.entity.PDCMEntity
import space.nstart.pcp.pcp_request_service.model.SquareViewParamsModel
import space.nstart.pcp.pcp_request_service.repository.AscNodeRepository
import space.nstart.pcp.pcp_request_service.repository.EarthCellViewRepository
import space.nstart.pcp.pcp_request_service.repository.EarthCoverageRepository
import space.nstart.pcp.pcp_request_service.repository.FlightLineRepository
import space.nstart.pcp.pcp_request_service.repository.PDCMRepository
import space.nstart.pcp.pcp_request_service.utils.ContourClipService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamsWithPointsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import kotlin.collections.forEach
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.log
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.truncate
@Service
class SatelliteService {
@Value("\${settings.calculation-interval:7}")
val intervalForward : Int = 7
@Autowired
private lateinit var om: ObjectMapper
private val logger : Logger = LoggerFactory.getLogger(this::class.java)
@Autowired
private lateinit var pdcmRepository: PDCMRepository
@Autowired
private lateinit var ascNodeRepository: AscNodeRepository
@Autowired
private lateinit var flightLineRepository: FlightLineRepository
@Autowired
private lateinit var earthCoverageRepository: EarthCoverageRepository
@Autowired
private lateinit var earthCellViewRepository: EarthCellViewRepository
val astro = AstronomerJ2000(EarthType.PZ90d02)
@KafkaListener(id = "updatedIcFilter", topics = ["\${spring.kafka.template.default-topic}"], groupId = "\${spring.kafka.consumer.group-id}", filter = "updatedIcFilter")
fun listener2(data: String?) {
val tleMessage: TLEDTO =
try {
val rootNode = om.readTree(data)
val payloadNode = rootNode["data"] ?: throw IllegalArgumentException("В сообщении отсутствует поле data")
om.treeToValue(payloadNode, TLEDTO::class.java)
}
catch (e: Exception) {
logger.error("Ошибка парсинга сообщения от каталога: ${e.message}")
return
}
logger.info("Полученно сообщение об изменении TLE [{}]", tleMessage)
CoroutineScope(Dispatchers.Default).launch {
resieveTLE(tleMessage)
}
logger.info("Выход из KafkaListener")
}
suspend fun resieveTLE(tle: TLEDTO) {
val bal = Ballistics()
val params = bal.getTLEParams(TLE(tle.first, tle.second), tle.header ?: "")
logger.info("Начало расчета баллистики по TLE noradId = ${params.noradId}")
bal.rollMax = (45.0 * PI / 180.0)
bal.rollMin = (20.0 * PI / 180.0)
var br = bal.calculateOrbPoints(
TLE(tle1 = tle.first, tle.second),
86400.0 * intervalForward
)
if (br != BallisticsError.OK) {
logger.warn("Ошибка при расчете ПДЦМ для noradId=${params.noradId}. Код ошибки: $br")
return
}
pdcmRepository.deleteBySatId(params.noradId)
pdcmRepository.saveAll(bal.points.map { p -> PDCMEntity(p, params.noradId) })
logger.info("Точки орбиты КА ${params.noradId} сохранены в БД")
ascNodeRepository.deleteBySatId(params.noradId)
ascNodeRepository.saveAll(bal.revolutions.map { AscNodeEntity(it, params.noradId) })
logger.info("ВУЗы орбиты КА ${params.noradId} сохранены в БД")
val tInt = bal.points.first().t
br = bal.calculateFlightLine(tInt, tInt + 86400 * intervalForward)
if (br != BallisticsError.OK) {
logger.warn("Ошибка при расчете ТП и ПО для ka=${params.noradId}. Код ошибки: $br")
return
}
flightLineRepository.deleteBySatId(params.noradId)
flightLineRepository.saveAll(bal.flightLine.map { FlightLineEntity(it, params.noradId) })
logger.info("Параметры трассы полеты и полосы обзора КА ${params.noradId} сохранены в БД")
earthCoverageRepository.deleteBySatId(params.noradId)
earthCoverageRepository.saveAll(bal.coverings.flatMap { (k,v) -> mergeSectors(v).map { EarthCoverageEntity(it, k , params.noradId) } })
logger.info("Параметры покрытия земной поверхности КА ${params.noradId} сохранены в БД")
prepareEarthCovering(bal, params.noradId)
bal.clear()
logger.info("Расчет для КА ${params.noradId} завершился успешно")
}
private fun InitialConditionsDTO.toIC() = InitialConditions(
OrbitalPoint(
t = fromDateTime(orbPoint.time),
vit = orbPoint.revolution.toInt(),
r = Vector3D(orbPoint.x,orbPoint.y,orbPoint.z),
v = Vector3D(orbPoint.vx,orbPoint.vy,orbPoint.vz)
),
f81 = f81,
sBall = sBall
)
suspend fun resieveRV(rv: SatelliteICDTO) {
val bal = Ballistics()
logger.info("Начало расчета баллистики по RV id = ${rv.satelliteId}")
bal.rollMax = (45.0 * PI / 180.0)
bal.rollMin = (20.0 * PI / 180.0)
var br = bal.calculateOrbPoints(
rv.ic.toIC(),
fromDateTime(rv.ic.orbPoint.time),
fromDateTime(rv.ic.orbPoint.time)+ 86400 * intervalForward
)
if (br != BallisticsError.OK) {
logger.warn("Ошибка при расчете ПДЦМ для noradId=${rv.satelliteId}. Код ошибки: $br")
return
}
pdcmRepository.deleteBySatId(rv.satelliteId)
pdcmRepository.saveAll(bal.points.map { p -> PDCMEntity(p, rv.satelliteId) })
logger.info("Точки орбиты КА ${rv.satelliteId} сохранены в БД")
ascNodeRepository.deleteBySatId(rv.satelliteId)
ascNodeRepository.saveAll(bal.revolutions.map { AscNodeEntity(it, rv.satelliteId) })
logger.info("ВУЗы орбиты КА ${rv.satelliteId} сохранены в БД")
val tInt = bal.points.first().t
br = bal.calculateFlightLine(tInt, tInt + 86400 * intervalForward)
if (br != BallisticsError.OK) {
logger.warn("Ошибка при расчете ТП и ПО для ka=${rv.satelliteId}. Код ошибки: $br")
return
}
flightLineRepository.deleteBySatId(rv.satelliteId)
flightLineRepository.saveAll(bal.flightLine.map { FlightLineEntity(it, rv.satelliteId) })
logger.info("Параметры трассы полеты и полосы обзора КА ${rv.satelliteId} сохранены в БД")
earthCoverageRepository.deleteBySatId(rv.satelliteId)
earthCoverageRepository.saveAll(bal.coverings.flatMap { (k,v) -> mergeSectors(v).map { EarthCoverageEntity(it, k , rv.satelliteId) } })
logger.info("Параметры покрытия земной поверхности КА ${rv.satelliteId} сохранены в БД")
prepareEarthCovering(bal, rv.satelliteId)
bal.clear()
logger.info("Расчет для КА ${rv.satelliteId} завершился успешно")
}
fun mergeSectors(sectors: List<FleghtLineSector>): List<FleghtLineSector> {
if (sectors.isEmpty()) return emptyList()
val sorted = sectors.sortedBy { it.tStart }
val result = mutableListOf<FleghtLineSector>()
var current = sorted[0]
for (i in 1 until sorted.size) {
val next = sorted[i]
if (abs(next.tStart - current.tStop) < 2) {
// Объединяем: расширяем tStop текущего сектора
current = FleghtLineSector(current.tStart, maxOf(current.tStop, next.tStop))
} else {
result.add(current)
current = next
}
}
result.add(current)
return result
}
fun clear(id : Long): SatelliteBallisticsDeletionSummary {
val pdcmDeleted = pdcmRepository.deleteBySatId(id)
val ascNodesDeleted = ascNodeRepository.deleteBySatId(id)
val flightLinesDeleted = flightLineRepository.deleteBySatId(id)
val earthCoverageDeleted = earthCoverageRepository.deleteBySatId(id)
val earthCellViewsDeleted = earthCellViewRepository.deleteBySatId(id)
val summary = SatelliteBallisticsDeletionSummary(
satelliteId = id,
pdcmDeleted = pdcmDeleted,
ascNodesDeleted = ascNodesDeleted,
flightLinesDeleted = flightLinesDeleted,
earthCoverageDeleted = earthCoverageDeleted,
earthCellViewsDeleted = earthCellViewsDeleted
)
logger.info(
"Deleted ballistics data for satellite: satelliteId={}, pdcm={}, ascNodes={}, flightLines={}, earthCoverage={}, earthCellViews={}",
summary.satelliteId,
summary.pdcmDeleted,
summary.ascNodesDeleted,
summary.flightLinesDeleted,
summary.earthCoverageDeleted,
summary.earthCellViewsDeleted
)
return summary
}
fun exactTime(satId : Long, request : ExactTimePositionRequestDTO) = Flux.create {
sink ->
logger.info("Запрос расчета выхода на заданное время для КА $satId ")
val points = pdcmRepository.findBySatelliteIdAndTimeBetween(
satId,
request.time.minusSeconds(100),
request.timeStop?.plusSeconds(100) ?: request.time.plusSeconds(100)
)
if (points.isEmpty())
throw CustomErrorException("Нет точек орбиты для КА $satId на интервале ${request.time} - ${request.timeStop ?: request.time}")
val stepper = RungeStepper(points.map { p -> p.toOrbitalPoint() }.toMutableList(), EarthType.PZ90d02)
var t = request.time
val tk = request.timeStop ?: t.plusNanos(5)
var step = request.stepMs ?: 125
if (step < 1)
step = 125
while (t <= tk) {
val op = stepper.calculate(fromDateTime(t))
if (op == null) {
logger.error("Ошибка расчета выхода на заданное время $t")
throw CustomValidationException("Внутренняя ошибка расчета выхода на заданное время")
}
sink.next( OrbPointDTO(
toDateTime(op.t),
op.vit.toLong(),
op.v.x,
op.v.y,
op.v.z,
op.r.x,
op.r.y,
op.r.z)
)
t = t.plusNanos(step * 1000000)
}
if (request.timeStop != null && t.minusNanos(step * 1000000) < tk) {
val op = stepper.calculate(fromDateTime(tk))
if (op == null) {
logger.error("Ошибка расчета выхода на заданное время $t")
throw CustomValidationException("Внутренняя ошибка расчета выхода на заданное время")
}
sink.next(OrbPointDTO(
toDateTime(op.t),
op.vit.toLong(),
op.v.x,
op.v.y,
op.v.z,
op.r.x,
op.r.y,
op.r.z)
)
}
sink.complete()
}
fun getCellCovering(num : Long) : List<SquareViewParamsWithPointsDTO>{
return earthCellViewRepository.findByCellNum(num).map { view ->
val points = pdcmRepository.findBySatelliteIdAndTimeBetween(
view.satelliteId,
view.timeBegin.minusSeconds(100),
view.timeEnd.plusSeconds(100)
)
SquareViewParamsWithPointsDTO(
view.toDTO(),
points.map { it.toDTO() }
)
}
}
fun mplPoint(req : ObjViewRequestDTO) : Flux<PointViewParamDTO>{
return Flux.fromIterable(
req.satellites
.flatMap { sat ->
val bal = Ballistics()
bal.setOrbitalPoints(
loadOrbitalPoints(sat, req.timeStart, req.timeStop),
EarthType.PZ90d02
)
bal.rollMin = 0.0
bal.rollMax = 45.0 / 180 * PI
val indexes = mkCovIndexes(listOf(req.obj.position?: PositionDTO()))
val sectors = loadEarthCoverageSectors(sat, req.timeStart, req.timeStop, indexes)
bal.setEarthCoverage(
sectors
)
val t1 = req.timeStart
val t2 = req.timeStop
bal.sunAngleMin = req.sunAngleMin?.let { it / 180 * PI } ?: (-PI / 2.0)
val r = bal.calculateMPL(
fromDateTime(t1),
fromDateTime(t2),
listOf(OPKatObj(0,0,"",0,
(req.obj.position?.lat?:0.0) * PI / 180,
(req.obj.position?.long?:0.0) * PI / 180,
req.obj.position?.height?:0.0,
0.0, 0.0, 0.0
)
)
)
if (r != BallisticsError.OK) {
logger.error("Ошибка при расчете параметров наблюдения объекта (${req.obj}) для КА $sat. Код ошибки : $r")
listOf()
} else
bal.mpl.map { PointViewParamDTO(
noradId = sat,
objectId = req.obj.id?:"",
time = toDateTime(it.traverz),
revolution = it.vit.toLong(),
gamma = it.orientation.kren * 180 / PI,
sunAngle = it.sunAngle * 180 / PI,
range = it.range,
revSign = if (it.pv ==0 ) RevolutionSign.ASC else RevolutionSign.DESC
) }
}
)
}
fun mplSquare(req : ObjViewRequestDTO) : Flux<SquareViewParamDTO>{
logger.info("Запрос МПЛ по площадному объекту")
return Flux.fromIterable(
req.satellites
.flatMap { sat ->
val bal = Ballistics()
bal.setOrbitalPoints(
loadOrbitalPoints(sat, req.timeStart, req.timeStop),
EarthType.PZ90d02
)
bal.rollMin = 0.0
bal.rollMax = 45.0 / 180 * PI
val contourService = ContourClipService()
val contour = contourService.getObjContour(req.obj)
if (contour == null || contour.count() < 3) {
logger.error("Контур площадного объекта пуст или содержит меньше 3-х точек")
listOf()
}
else {
val indexes = mkCovIndexes(contour)
val sectors = loadEarthCoverageSectors(sat, req.timeStart, req.timeStop, indexes)
bal.setEarthCoverage(
sectors
)
val t1 = req.timeStart
val t2 = req.timeStop
bal.sunAngleMin = req.sunAngleMin?.let { it / 180 * PI } ?: (-PI / 2.0)
val r = bal.calculateMPL(
fromDateTime(t1),
fromDateTime(t2),
contourService.splitPolySides(contour.map { p ->
PositionDTO(
p.lat / 180 * PI,
p.long / 180 * PI,
p.height
)
})
)
if (r != BallisticsError.OK) {
logger.error("Ошибка при расчете параметров наблюдения площадного объекта (${req.obj}) для КА $sat. Код ошибки : $r")
listOf()
} else
squareViewFromMPL(sat, bal.mpl, req.obj.id ?: "")
}
}
).map { square -> square.toDTO() }
}
private fun squareViewFromMPL(id : Long, mpl: Iterable<PointViewParams>, n: String): Iterable<SquareViewParamsModel>{
val svp = mutableListOf<SquareViewParamsModel>()
val vp = SquareViewParamsModel(
tN = -1.0,
tK = 0.0,
objID = n,
id = id
)
sortPointViews(mpl).forEach { m ->
if (m.traverz - vp.tK > 60.0*3) {
// начало нового интервала наблюдения! Надо сохранить старый, если такой есть.
if (vp.tN > 0)
svp.add(vp.clone())
vp.tN = m.traverz
vp.vitN = m.vit
vp.tK = m.traverz
vp.vitK = m.vit
vp.pvN = if (m.pv == 0) RevolutionSign.ASC else RevolutionSign.DESC
vp.pvK = if (m.pv == 0) RevolutionSign.ASC else RevolutionSign.DESC
vp.krenMin = m.orientation.kren
vp.krenMax = m.orientation.kren
} else {
vp.tK = m.traverz
vp.vitK = m.vit
vp.pvK = if (m.pv == 0) RevolutionSign.ASC else RevolutionSign.DESC
if (vp.krenMax < m.orientation.kren)
vp.krenMax = m.orientation.kren
if (vp.krenMin > m.orientation.kren)
vp.krenMin = m.orientation.kren
}
}
if (vp.tN > 0)
svp.add(vp.clone())
return svp
}
internal fun sortPointViews(mpl: Iterable<PointViewParams>): List<PointViewParams> =
mpl.sortedWith(
compareBy<PointViewParams>(
{ it.traverz },
{ it.vit },
{ it.pointNumb },
{ it.pv },
{ it.orientation.kren }
)
)
private fun loadOrbitalPoints(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime) =
pdcmRepository.findBySatelliteIdAndTimeBetween(satelliteId, timeStart, timeStop)
.sortedWith(
compareBy<PDCMEntity>(
{ it.time },
{ it.revolution },
{ it.id ?: Long.MAX_VALUE }
)
)
.map { it.toOrbitalPoint() }
private fun loadEarthCoverageSectors(
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime,
cellIndexes: List<Long>
): Map<Int, MutableList<FleghtLineSector>> =
earthCoverageRepository.findBySatelliteIdAndTStartGreaterThanAndTStopLessThanAndCellNumberIn(
satelliteId,
timeStart,
timeStop,
cellIndexes
)
.sortedWith(
compareBy<EarthCoverageEntity>(
{ it.cellNumber },
{ it.tStart },
{ it.tStop },
{ it.id ?: Long.MAX_VALUE }
)
)
.groupBy { it.cellNumber.toInt() }
.entries
.sortedBy { it.key }
.associateTo(linkedMapOf()) { (cellNumber, views) ->
cellNumber to views.map { view ->
FleghtLineSector(
fromDateTime(view.tStart),
fromDateTime(view.tStop)
)
}.toMutableList()
}
fun getBInd(b: Double): Long {
var b2: Double = b * 180.0 / PI
b2 = 90.0 - b2
var k: Long = truncate(b2 / 2.0).toLong()
if (k > 89) k = 89
return k
}
fun getLInd(l2: Double): Long {
var l: Double = l2 * 180.0 / PI
if (l < 0) l += 360.0
var k: Long = truncate(l / 2.0).toLong()
if (k > 179) k = 179
return k
}
fun getInd(
b: Double,
l: Double,
): Long {
return getBInd(b) * 180 + getLInd(l)
}
fun mkCovIndexes(contour : Iterable<PositionDTO>) : List<Long>{
val indexes = mutableListOf<Long>()
var bMin = 100.0
var bMax = -100.0
var lMin = 370.0
var lMax = -370.0
contour.forEach { c ->
val b = c.lat
val l = c.long
if (b < bMin) bMin = b
if (b > bMax) bMax = b
if (l < lMin) lMin = l
if (l > lMax) lMax = l
}
while (bMin < bMax + 0.6){
var l = lMin
while (l < lMax + 0.6){
val ind = getInd(bMin * PI / 180, l * PI / 180)
if (!indexes.contains(ind))
indexes.add(ind)
l += 0.5
}
bMin += 0.5
}
return indexes
}
fun getBInd2(b: Double): Long {
var b2: Double = b * 180.0 / PI
b2 = 90.0 - b2
var k: Long = truncate(b2).toLong()
if (k > 180) k = 180
return k
}
fun getLInd2(l2: Double): Long {
var l: Double = l2 * 180.0 / PI
if (l < 0) l += 360.0
var k: Long = truncate(l).toLong()
if (k > 359) k = 359
return k
}
fun getInd2(
b: Double,
l: Double,
): Long {
return getBInd2(b) * 360 + getLInd2(l)
}
fun prepareEarthCovering(bal : Ballistics, satId : Long){
logger.info("Начало расчета покрытия ячеек земной поверхности для КА $satId")
earthCellViewRepository.deleteBySatId(satId)
logger.info("Функционал не используется. Старые результаты удалены.")
return
// val contourService = ContourClipService()
// val step = 1.0
//
// bal.sunAngleMin = -PI/2
//
// val num : Long = 1
// var b = -90 + step
// while (b <= 90){
// var l = 0.0
// val line = mutableListOf<EarthCellViewsEntity>()
// while (l <= 360){
// val cellInd = getInd2(b * PI / 180.0,l* PI / 180.0)
// val t1 = bal.points.first().t
// val t2 = bal.points.last().t
// val cont : String = "POLYGON (($l $b, ${l-1} ${b}, ${l-1} ${b-1}, $l ${b-1}, $l $b))"
// val contour = contourService.getObjContour(ObjDTO(contourWKT = cont))
// if (contour == null || contour.count() < 3) {
// logger.error("Контур площадного объекта пуст или содержит меньше 3-х точек")
// continue
// }
//
// val objs = contourService.splitPolySides(contour.map { p ->
// PositionDTO(
// p.lat / 180 * PI,
// p.long / 180 * PI,
// p.height
// )
// }
// )
// val r = bal.calculateMPL(
// t1,
// t2,
// objs
//
// )
// val views =
// if (r != BallisticsError.OK) {
// logger.error("Ошибка при расчете параметров наблюдения площадного объекта (${cellInd}) для КА $satId. Код ошибки : $r")
// listOf()
// } else
// squareViewFromMPL(satId, bal.mpl, "")
//
// for (view in views) {
// line.add(
// EarthCellViewsEntity(
// null,
// satId,
// cellInd,
// view.vitN.toLong(),
// toDateTime(view.tN),
// if(view.pvN == RevolutionSign.ASC) 0 else 1,
// view.vitK.toLong(),
// toDateTime(view.tK),
// if(view.pvK == RevolutionSign.ASC) 1 else 0,
// view.krenMin * 180 / PI,
// view.krenMax * 180 / PI,
// cont
// )
// )
// }
// l += step
// if (line.isEmpty()) {
// logger.info("нет наблюдений ячейки $cont")
// }
// earthCellViewRepository.saveAll(line)
// line.clear()
// }
// b += step
// }
// logger.info("Конец расчета покрытия ячеек земной поверхности для КА $satId")
}
fun getOrbPoints(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) =
pdcmRepository.findBySatelliteIdAndTimeBetween(
id,
tn?: LocalDateTime.now(),
tk?: LocalDateTime.now().plusDays(1))
.map { it.toDTO()
}
fun getOrbitAvailability(time: LocalDateTime?): List<SatelliteOrbitAvailabilityDTO> =
if (time == null) {
pdcmRepository.findSatelliteOrbitAvailability()
} else {
pdcmRepository.findSatelliteOrbitAvailabilityAtTime(time)
}
fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) =
flightLineRepository.findBySatelliteIdAndTimeBetween(id,
tn?: LocalDateTime.now(),
tk?: LocalDateTime.now().plusDays(1)
)
.map { it.toDTO() }
fun getRVA(id : Long, tn : LocalDateTime, tk : LocalDateTime) : List<RadioVisibilityAreaDTO> {
val bal = Ballistics()
val points = pdcmRepository.findBySatelliteIdAndTimeBetween(id,
tn,
tk).map { it.toOrbitalPoint() }
if (points.isEmpty())
throw CustomErrorException("Нет ПДЦМ для КА $id на интервале времени $tn - $tk")
bal.setOrbitalPoints(points,
EarthType.PZ90d02)
val tstart = points.first().t
val tstop = points.last().t
bal.rollMin = 0.0
bal.rollMax = 45.0 / 180 * PI
val r = bal.calculateZRV(
listOf(PPI(1,1,0.35, 0.45, 100.0, 0.0, PI/2)),
tstart,
tstop
)
if (r != BallisticsError.OK) {
logger.error("Ошибка при расчете параметров ЗРВ : $r")
return listOf()
} else
return bal.zrv.map { mkRVA(it, id)}
}
fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO(
noradId = id,
stationId = p.ppi.toLong(),
revolution = p.vit.toLong(),
onStart = mkTP(p.zoneIn),
onMaximum = mkTP(p.zoneMax),
onStop = mkTP(p.zoneOut)
)
fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO(
toDateTime(p.t),
p.elevation * 180 / PI,
p.azimuth * 180 / PI,
p.range
)
fun calculateKeplerParams(point: OrbitalPoint): KeplerParams {
val res = KeplerParams()
val aL00 = 6.25648106E+7
val ask = astro.grinvToASK(point)
val r = sqrt(sqr(ask.r.x) + sqr(ask.r.y) + sqr(ask.r.z))
val v = sqrt(sqr(ask.v.x) + sqr(ask.v.y) + sqr(ask.v.z))
val mu = astro.earth.fM
val k = (r * sqr(v)) / mu
val sinO = (ask.r.x * ask.v.x + ask.r.y * ask.v.y + ask.r.z * ask.v.z) / (r * v)
val cosO = sqrt(1 - sqr(sinO))
val c1 = ask.r.y * ask.v.z - ask.r.z * ask.v.y
val c2 = ask.r.z * ask.v.x - ask.r.x * ask.v.z
val c3 = ask.r.x * ask.v.y - ask.r.y * ask.v.x
val c = sqrt(sqr(c1) + sqr(c2) + sqr(c3))
res.ael = r / (2 - k)
val lambda = (1 / res.ael) * sqrt(mu / res.ael)
res.t = (2 * PI * res.ael * sqrt(res.ael)) / sqrt(astro.earth.middleRadius * aL00)
res.e = sqrt(1 - k * (2 - k) * (1 - sinO * sinO))
res.v = atan((k * sinO * cosO) / (k * sqr(cosO) - 1))
res.u = atan2((ask.r.z * c), (ask.r.y * c1 - ask.r.x * c2))
res.eA = atan((sqrt(1 - sqr(res.e)) * k * sinO * cosO) / (sqr(res.e) + (k * sqr(cosO) - 1)))
res.tau = ask.t + ((res.e * sin(res.eA) - res.eA) / lambda)
res.nakl = 0.5 * PI - asin(c3 / c)
res.omegab = atan2(c1, -c2)
res.omegam = res.u - res.v
res.o = asin(sinO)
res.rA = res.ael * (1 + res.e)
res.rP = res.ael * (1 - res.e)
res.dmv = point.t
if (res.omegam < 0) res.omegam += 2 * PI
if (res.tau < 0) res.tau += 1
return res
}
fun getAscNodes(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) : List<AscNodeDTO> {
return ascNodeRepository.findBySatelliteIdAndTimeBetween(
id,
tn ?: LocalDateTime.now(),
tk ?: LocalDateTime.now().plusDays(1)
)
.map {
val keps = calculateKeplerParams(
OrbitalPoint(
fromDateTime(it.time),
1,
Vector3D(it.x,it.y,it.z),
Vector3D(it.vx,it.vy,it.vz)
))
it.toDTO(keps, astro.earth.ekvRadius)
}
}
}
data class SatelliteBallisticsDeletionSummary(
val satelliteId: Long,
val pdcmDeleted: Int,
val ascNodesDeleted: Int,
val flightLinesDeleted: Int,
val earthCoverageDeleted: Int,
val earthCellViewsDeleted: Int
)
@@ -0,0 +1,86 @@
package space.nstart.pcp.pcp_request_service.service
import ballistics.Ballistics
import ballistics.types.BallisticsError
import ballistics.types.PPI
import ballistics.types.TLE
import ballistics.types.TLEParams
import ballistics.types.VisibilityParametersZRV
import ballistics.types.ZRV
import ballistics.utils.fromDateTime
import ballistics.utils.toDateTime
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_request_service.configuration.CustomErrorException
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
import kotlin.math.PI
@Service
class TLEService() {
fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO(
toDateTime(p.t),
p.elevation * 180 / PI,
p.azimuth * 180 / PI,
p.range
)
fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO(
noradId = id,
stationId = p.ppi.toLong(),
revolution = p.vit.toLong(),
onStart = mkTP(p.zoneIn),
onMaximum = mkTP(p.zoneMax),
onStop = mkTP(p.zoneOut)
)
fun parseTLE(tle: TLEDTO) : TLEParams{
val bal = Ballistics()
return bal.getTLEParams(TLE(tle.first, tle.second), tle.header?:"empty")
}
fun rva(req : TleRvaRequestDTO) : Iterable<RadioVisibilityAreaDTO> {
val bal = Ballistics()
var tleParams : TLEParams
try {
tleParams = bal.getTLEParams(TLE(req.tle.first, req.tle.second))
if (req.timeStart >= req.timeStop || req.timeStart < tleParams.time)
throw CustomValidationException("Ошибка задания интервала расчета")
}catch (ex : Exception){
throw CustomValidationException("Ошибка формата TLE : ${ex.message}")
}
val duration = fromDateTime(req.timeStop) - fromDateTime(req.timeStart)
val r = bal.calculateOrbPoints(TLE(req.tle.first, req.tle.second), duration)
if (r != BallisticsError.OK)
throw CustomErrorException("Ошибка расчета ПДЦМ : $r")
val rz = bal.calculateZRV(
listOf(PPI(
req.station.number.toInt(),
0,
req.station.position.lat * PI / 180.0,
req.station.position.long * PI / 180.0,
req.station.position.height,
req.station.elevationMin * PI / 180.0,
req.station.elevationMax * PI / 180.0)
),
fromDateTime(req.timeStart),
fromDateTime(req.timeStop)
)
if (rz != BallisticsError.OK)
throw CustomErrorException("Ошибка расчета зон радио-видимости : $rz")
return bal.zrv.map { mkRVA(it, tleParams.noradId) }
}
}
@@ -0,0 +1,176 @@
package space.nstart.pcp.pcp_request_service.utils
import ballistics.types.OPKatObj
import clipper.IntPoint
import clipper.Path
import clipper.containsPoint
import org.locationtech.jts.io.WKTReader
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sqrt
class ContourClipService {
fun prceWKT(wkt : String) : Iterable<PositionDTO>?{
try {
val contour = mutableListOf<PositionDTO>()
val reader = WKTReader()
val geom = reader.read(wkt)
for (c in geom.coordinates)
contour.add(PositionDTO(c.y, c.x, c.z))
if (contour.size < 3)
return null
return contour
} catch (ex : Exception){
throw CustomValidationException("Ошибка пазбора WKT-формата")
}
}
fun getObjContour(obj : ObjDTO) : Iterable<PositionDTO>?{
obj.contourWKT?.let { return prceWKT(obj.contourWKT!!) }
return null
}
fun splitPolySides(poly : Iterable<PositionDTO>, internal : Boolean = true) : Iterable<OPKatObj>{
// return poly.map { p1 ->
// OPKatObj(
// 1, 1, "", 1, p1.lat, p1.long,
// 0.0, 0.0, PI / 2, -PI / 2
// )
// }
val res = mutableListOf<OPKatObj>()
for (i in 1 ..poly.count()-1){
res.addAll(splitLine(poly.elementAt(i-1), poly.elementAt(i)))
}
// if (internal)
res.addAll(splitPoly(poly, 2.0 * PI / 180.0))
return res
}
private fun splitLine(p1 : PositionDTO, p2 : PositionDTO) : Iterable<OPKatObj>{
var x1 = p1.long
var x2 = p2.long
if (abs(x1 - x2) > PI){
if (x1 < x2)
x1 += 2 * PI
else
x2 = 2 * PI
}
if (x1 > x2){
val buf = x1
x1 = x2
x2 = buf
}
var y1 = p1.lat
var y2 = p2.lat
if (y1 > y2){
val buf = y1
y1 = y2
y2 = buf
}
val dx = x2 - x1
val dy = y2 - y1
val dl = sqrt((x2-x1)*(x2-x1)+(y2-y1)*(y2-y1))
val res = mutableListOf<OPKatObj>()
res.add(OPKatObj(1, 1, "", 1, p1.lat, p1.long, 0.0, 0.0, PI / 2, -PI / 2))
if (dl > 0.01) {
res.add(OPKatObj(1, 1, "", 2, p2.lat, p2.long, 0.0, 0.0, PI / 2, -PI / 2))
var pn = 3
val step = 0.1 * PI / 180
if (dx > dy) {
var x = x1 + step
val k = dy / dx
val b = y2 - k * x2
(0..(dx / step).toInt()).forEach { i ->
if (x < x2) {
val y = k * x + b
res.add(OPKatObj(1, 1, "", pn, y, x, 0.0, 0.0, PI / 2, -PI / 2))
++pn
}
x += step
}
} else {
if (dx < 0.000001) {
var y = y1 + step
(0..(dy / step).toInt()).forEach { i ->
if (y < y2) {
res.add(OPKatObj(1, 1, "", pn, y, x1, 0.0, 0.0, PI / 2, -PI / 2))
++pn
}
y += step
}
} else {
var y = y1 + step
val k = dy / dx
val b = y2 - k * x2
(0..(dy / step).toInt()).forEach { i ->
if (y < y2) {
val x = (y - b) / k
res.add(OPKatObj(1, 1, "", pn, y, x, 0.0, 0.0, PI / 2, -PI / 2))
++pn
}
y += step
}
}
}
}
return res
}
private fun splitPoly(poly : Iterable<PositionDTO>, step : Double? = null) : Iterable<OPKatObj> {
val res = mutableListOf<OPKatObj>()
val path = Path()
val clipVal = 1000000
var bMin = poly.minOf { it.lat }
val bMax = poly.maxOf { it.lat }
var lMin = poly.minOf { it.long }
var lMax = poly.maxOf { it.long }
for (p in poly) {
path.add(IntPoint((p.lat * clipVal).toLong(), (p.long * clipVal).toLong()))
if (step != null)
res.add(OPKatObj(1, 1, "", 1, p.lat, p.long, 0.0, 0.0, PI / 2, -PI / 2))
}
path.add(path.first())
if ((lMin < 0 && lMax > 0) && (lMax - lMin > PI)) {
val b = lMax
lMax = lMin + PI * 2
lMin = b
}
if (lMax - lMin > PI) {
val b = lMax
lMax = lMin
lMin = b - 2 * PI
}
var stepL : Double
var stepB : Double
if (step != null){
stepL = step
stepB = step
}else {
stepL = (lMax - lMin) / 10
if (stepL > 0.1 * PI / 180)
stepL = 0.1 * PI / 180
stepB = (bMax - bMin) / 10
if (stepB > 0.1 * PI / 180)
stepB = 0.1 * PI / 180
}
while (bMin <= bMax) {
var l = lMin
while (l <= lMax) {
if (path.containsPoint(IntPoint( (bMin * clipVal).toLong(), (l * clipVal).toLong())) == 1)
res.add(OPKatObj(1, 1, "", 1, bMin, l, 0.0, 0.0, PI / 2, -PI / 2))
l += stepL
}
bMin += stepB
}
return res
}
}
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-ballistics-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS pdcm (
pdcm_id BIGSERIAL PRIMARY KEY,
satellite_id BIGINT NOT NULL,
t TIMESTAMP NOT NULL,
revolution BIGINT NOT NULL,
x DOUBLE PRECISION,
y DOUBLE PRECISION,
z DOUBLE PRECISION,
vx DOUBLE PRECISION,
vy DOUBLE PRECISION,
vz DOUBLE PRECISION
);
CREATE INDEX idx_pdcm_satellite_id ON pdcm(satellite_id);
CREATE INDEX idx_pdcm_t ON pdcm(t);
CREATE INDEX idx_pdcm_revolution ON pdcm(revolution);
@@ -0,0 +1,51 @@
CREATE TABLE IF NOT EXISTS asc_node (
asc_node_id BIGSERIAL PRIMARY KEY,
satellite_id BIGINT NOT NULL,
t TIMESTAMP NOT NULL,
revolution BIGINT NOT NULL,
longitude DOUBLE PRECISION,
height DOUBLE PRECISION,
x DOUBLE PRECISION,
y DOUBLE PRECISION,
z DOUBLE PRECISION,
vx DOUBLE PRECISION,
vy DOUBLE PRECISION,
vz DOUBLE PRECISION
);
CREATE INDEX idx_asc_node_satellite_id ON asc_node(satellite_id);
CREATE INDEX idx_asc_node_t ON asc_node(t);
CREATE INDEX idx_asc_node_revolution ON asc_node(revolution);
CREATE TABLE IF NOT EXISTS flight_line (
flight_line_id BIGSERIAL PRIMARY KEY,
satellite_id BIGINT NOT NULL,
t TIMESTAMP NOT NULL,
revolution BIGINT NOT NULL,
revolution_sign VARCHAR(4) NOT NULL CHECK (revolution_sign IN ('ASC', 'DESC')),
longitude DOUBLE PRECISION,
latitude DOUBLE PRECISION,
longitude_outer_left DOUBLE PRECISION,
latitude_outer_left DOUBLE PRECISION,
longitude_inner_left DOUBLE PRECISION,
latitude_inner_left DOUBLE PRECISION,
longitude_inner_right DOUBLE PRECISION,
latitude_inner_right DOUBLE PRECISION,
longitude_outer_right DOUBLE PRECISION,
latitude_outer_right DOUBLE PRECISION
);
CREATE INDEX idx_flight_line_satellite_id ON flight_line(satellite_id);
CREATE INDEX idx_flight_line_t ON flight_line(t);
CREATE INDEX idx_flight_line_revolution ON flight_line(revolution);
CREATE TABLE IF NOT EXISTS earth_coverage (
earth_coverage_id BIGSERIAL PRIMARY KEY,
satellite_id BIGINT NOT NULL,
cell_number BIGINT NOT NULL,
t_start TIMESTAMP NOT NULL,
t_stop TIMESTAMP NOT NULL
);
CREATE INDEX idx_earth_coverage_satellite_id ON earth_coverage(satellite_id);
CREATE INDEX idx_earth_coverage_t ON earth_coverage(t_start);
CREATE INDEX idx_earth_coverage_cell_number ON earth_coverage(cell_number);
@@ -0,0 +1,23 @@
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS cell_covering (
cell_covering_id BIGSERIAL PRIMARY KEY,
satellite_id BIGINT NOT NULL,
cell_num BIGINT NOT NULL,
rev_begin BIGINT NOT NULL,
time_begin TIMESTAMP NOT NULL,
rev_sign_begin INT NOT NULL,
rev_end BIGINT NOT NULL,
time_end TIMESTAMP NOT NULL,
rev_sign_end INT NOT NULL,
roll_min DOUBLE PRECISION,
roll_max DOUBLE PRECISION,
contour_wkt TEXT NOT NULL
);
ALTER TABLE cell_covering ADD COLUMN IF NOT EXISTS contour_geom geometry(Polygon, 4326)
GENERATED ALWAYS AS (ST_GeomFromText(contour_wkt)::geometry(Polygon, 4326)) STORED;
CREATE INDEX idx_cell_covering_cell_num ON cell_covering(cell_num);
CREATE INDEX idx_cell_covering_satellite_id ON cell_covering(satellite_id);
CREATE INDEX idx_cell_covering_contour_geom ON cell_covering USING GIST (contour_geom);
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_request_service
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class PcpBallisticsServiceApplicationTests {
@Test
fun contextLoads() {
}
}
@@ -0,0 +1,106 @@
package space.nstart.pcp.pcp_request_service.message
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.readText
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class SatelliteDeletedKafkaListenerGroupIdTest {
private val listeners = mapOf(
"slots-service" to ListenerExpectation(
path = "services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteDeletedKafkaListener.kt",
groupId = "pcp-slots-service-satellite-deleted",
hasEventFilter = true
),
"pcp-ballistics-service" to ListenerExpectation(
path = "services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteDeletedKafkaListener.kt",
groupId = "pcp-ballistics-service-satellite-deleted",
hasEventFilter = true
),
"tle-monitoring-service" to ListenerExpectation(
path = "services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/message/SatelliteDeletedKafkaListener.kt",
groupId = "pcp-tle-monitoring-service-satellite-deleted",
hasEventFilter = true
),
"pcp-complex-mission-service" to ListenerExpectation(
path = "services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SatelliteDeletedKafkaListener.kt",
groupId = "pcp-complex-mission-service-satellite-deleted"
),
"pcp-mission-planing-service" to ListenerExpectation(
path = "services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/SatelliteDeletedKafkaListener.kt",
groupId = "pcp-mission-planing-service-satellite-deleted"
),
"pcp-dynamic-plan-service" to ListenerExpectation(
path = "services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/SatelliteDeletedKafkaListener.kt",
groupId = "pcp-dynamic-plan-service-satellite-deleted"
)
)
@Test
fun `satellite deleted listeners use unique explicit consumer groups`() {
val resolvedGroupIds = listeners.mapValues { (_, expectation) ->
val source = projectRoot().resolve(expectation.path).readText()
val annotationBody = kafkaListenerAnnotationBody(source)
val groupReference = annotationParameter(annotationBody, "groupId")
assertNotNull(groupReference, "SatelliteDeletedEvent listener must define explicit groupId: ${expectation.path}")
assertEquals("SATELLITE_DELETED_CONSUMER_GROUP", groupReference)
assertTrue(
annotationBody.contains("""topics = ["\${'$'}{app.kafka.topics.satellites:pcp.satellites}"]"""),
"SatelliteDeletedEvent listener must keep satellite topic: ${expectation.path}"
)
if (expectation.hasEventFilter) {
assertTrue(
annotationBody.contains("""filter = "satelliteDeletedFilter""""),
"SatelliteDeletedEvent listener must keep event filter: ${expectation.path}"
)
}
val groupId = consumerGroupConstantValue(source)
assertEquals(expectation.groupId, groupId)
groupId
}
assertEquals(
resolvedGroupIds.size,
resolvedGroupIds.values.toSet().size,
"SatelliteDeletedEvent consumer groups must be unique per service: $resolvedGroupIds"
)
}
private fun kafkaListenerAnnotationBody(source: String): String {
val match = Regex("@KafkaListener\\((.*?)\\)", RegexOption.DOT_MATCHES_ALL).find(source)
assertNotNull(match, "SatelliteDeletedEvent listener must have @KafkaListener")
return match.groupValues[1]
}
private fun annotationParameter(annotationBody: String, parameterName: String): String? =
Regex("""$parameterName\s*=\s*([^,\n)]+)""")
.find(annotationBody)
?.groupValues
?.get(1)
?.trim()
private fun consumerGroupConstantValue(source: String): String {
val match = Regex("""private const val SATELLITE_DELETED_CONSUMER_GROUP\s*=\s*"([^"]+)"""").find(source)
assertNotNull(match, "SatelliteDeletedEvent listener must declare SATELLITE_DELETED_CONSUMER_GROUP")
return match.groupValues[1]
}
private fun projectRoot(): Path {
generateSequence(Path.of(System.getProperty("user.dir")).toAbsolutePath()) { it.parent }
.firstOrNull { Files.exists(it.resolve("settings.gradle.kts")) }
?.let { return it }
error("Cannot locate project root from ${System.getProperty("user.dir")}")
}
private data class ListenerExpectation(
val path: String,
val groupId: String,
val hasEventFilter: Boolean = false
)
}
@@ -0,0 +1,96 @@
package space.nstart.pcp.pcp_request_service.message
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import tools.jackson.databind.ObjectMapper
class SatelliteIcRvKafkaListenerTest {
private val objectMapper = ObjectMapper()
private val satelliteIcEventService = RecordingSatelliteIcEventService()
private val listener = SatelliteIcRvKafkaListener(
objectMapperProvider = SingleObjectProvider(objectMapper),
satelliteIcEventService = satelliteIcEventService
)
@Test
fun `listener parses ICRV placed event payload and delegates to service`() {
listener.consume(
"""
{
"type": "ICRVPlacedEvent",
"data": {
"satelliteId": 56756,
"ic": {
"orbPoint": {
"time": "2026-04-16T12:00:00",
"revolution": 42,
"vx": 1.0,
"vy": 2.0,
"vz": 3.0,
"x": 4.0,
"y": 5.0,
"z": 6.0
},
"sBall": 0.7,
"f81": 140.0
}
}
}
""".trimIndent()
)
assertEquals(56756L, satelliteIcEventService.lastMessage?.satelliteId)
assertEquals(42L, satelliteIcEventService.lastMessage?.ic?.orbPoint?.revolution)
assertEquals(140.0, satelliteIcEventService.lastMessage?.ic?.f81)
}
@Test
fun `listener substitutes default initial condition coefficients when payload contains nulls`() {
listener.consume(
"""
{
"type": "ICRVPlacedEvent",
"data": {
"satelliteId": 56756,
"ic": {
"orbPoint": {
"time": "2026-04-16T12:00:00",
"revolution": 42,
"vx": 1.0,
"vy": 2.0,
"vz": 3.0,
"x": 4.0,
"y": 5.0,
"z": 6.0
},
"sBall": null,
"f81": null
}
}
}
""".trimIndent()
)
assertEquals(0.0, satelliteIcEventService.lastMessage?.ic?.sBall)
assertEquals(147.8, satelliteIcEventService.lastMessage?.ic?.f81)
}
private class RecordingSatelliteIcEventService : SatelliteIcEventService() {
var lastMessage: SatelliteICDTO? = null
override fun handlePlacedIcRv(message: SatelliteICDTO) {
lastMessage = message
}
}
private class SingleObjectProvider<T : Any>(private val value: T) : ObjectProvider<T> {
override fun getObject(vararg args: Any?): T = value
override fun getIfAvailable(): T = value
override fun getIfUnique(): T = value
override fun getObject(): T = value
}
}
@@ -0,0 +1,125 @@
package space.nstart.pcp.pcp_request_service.repository
import jakarta.persistence.EntityManager
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.common.serialization.StringDeserializer
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.listener.adapter.RecordFilterStrategy
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_request_service.entity.PDCMEntity
import java.time.LocalDateTime
import kotlin.test.assertEquals
@SpringBootTest(
properties = [
"spring.cloud.config.enabled=false",
"spring.cloud.config.import-check.enabled=false",
"spring.kafka.bootstrap-servers=false",
"spring.kafka.listener.auto-startup=false",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.url=jdbc:h2:mem:pdcm-repository-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect"
]
)
@Transactional
class PDCMRepositoryTest {
@Autowired
private lateinit var entityManager: EntityManager
@Autowired
private lateinit var repository: PDCMRepository
@Test
fun `find satellite orbit availability returns all satellites with points when time is null`() {
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0))
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 12, 0))
persistPoint(22L, LocalDateTime.of(2026, 4, 15, 9, 30))
persistPoint(22L, LocalDateTime.of(2026, 4, 15, 18, 45))
val actual = repository.findSatelliteOrbitAvailability()
assertEquals(2, actual.size)
assertEquals(11L, actual[0].satelliteId)
assertEquals(LocalDateTime.of(2026, 4, 14, 10, 0), actual[0].timeStart)
assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual[0].timeStop)
assertEquals(22L, actual[1].satelliteId)
assertEquals(LocalDateTime.of(2026, 4, 15, 9, 30), actual[1].timeStart)
assertEquals(LocalDateTime.of(2026, 4, 15, 18, 45), actual[1].timeStop)
}
@Test
fun `find satellite orbit availability filters satellites whose interval contains requested time`() {
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0))
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 12, 0))
persistPoint(22L, LocalDateTime.of(2026, 4, 15, 9, 30))
persistPoint(22L, LocalDateTime.of(2026, 4, 15, 18, 45))
val actual = repository.findSatelliteOrbitAvailabilityAtTime(LocalDateTime.of(2026, 4, 14, 11, 0))
assertEquals(1, actual.size)
assertEquals(11L, actual.single().satelliteId)
assertEquals(LocalDateTime.of(2026, 4, 14, 10, 0), actual.single().timeStart)
assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual.single().timeStop)
}
private fun persistPoint(satelliteId: Long, time: LocalDateTime) {
entityManager.persist(
PDCMEntity(
satelliteId = satelliteId,
time = time,
revolution = 1,
x = 1.0,
y = 2.0,
z = 3.0,
vx = 4.0,
vy = 5.0,
vz = 6.0
)
)
entityManager.flush()
}
@TestConfiguration
class KafkaFilterStubConfiguration {
@Bean("kafkaListenerContainerFactory")
fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory<String, String> =
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
setConsumerFactory(testConsumerFactory())
setAutoStartup(false)
}
private fun testConsumerFactory(): ConsumerFactory<String, String> =
DefaultKafkaConsumerFactory(
mapOf(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9092",
ConsumerConfig.GROUP_ID_CONFIG to "test",
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java
)
)
@Bean("placedIcFilter")
fun placedIcFilter(): RecordFilterStrategy<String, String> = RecordFilterStrategy { false }
@Bean("placedIcRvFilter")
fun placedIcRvFilter(): RecordFilterStrategy<String, String> = RecordFilterStrategy { false }
@Bean("updatedIcFilter")
fun updatedIcFilter(): RecordFilterStrategy<String, String> = RecordFilterStrategy { false }
@Bean("satelliteDeletedFilter")
fun satelliteDeletedFilter(): RecordFilterStrategy<String, String> = RecordFilterStrategy { false }
}
}
@@ -0,0 +1,56 @@
package space.nstart.pcp.pcp_request_service.service
import ballistics.types.Orientation
import ballistics.types.PointViewParams
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class SatelliteServiceDeterminismTest {
@Test
fun `sortPointViews orders mpl points deterministically`() {
val service = SatelliteService()
val sorted = service.sortPointViews(
listOf(
pointView(traverz = 120.0, vit = 3, pointNumb = 2, pv = 1, kren = 0.3),
pointView(traverz = 100.0, vit = 4, pointNumb = 1, pv = 0, kren = 0.5),
pointView(traverz = 100.0, vit = 3, pointNumb = 2, pv = 0, kren = 0.4),
pointView(traverz = 100.0, vit = 3, pointNumb = 1, pv = 1, kren = 0.2)
)
)
assertEquals(
listOf(
100.0 to 3,
100.0 to 3,
100.0 to 4,
120.0 to 3
),
sorted.map { it.traverz to it.vit }
)
assertEquals(listOf(1, 2, 1, 2), sorted.map { it.pointNumb })
}
private fun pointView(
traverz: Double,
vit: Int,
pointNumb: Int,
pv: Int,
kren: Double
) = PointViewParams(
objON = 0,
objN = 0,
objUUID = "",
pointNumb = pointNumb,
vit = vit,
traverz = traverz,
latTraverz = 0.0,
longTraverz = 0.0,
orientation = Orientation(0.0, kren, 0.0),
range = 0.0,
sunAngle = 0.0,
sightAngle = 0.0,
pv = pv
)
}
@@ -0,0 +1,69 @@
spring:
application:
name: pcp-ballistics-service
cloud:
config:
enabled: false
lifecycle.timeout-per-shutdown-phase: 40s
jackson:
default-property-inclusion: non_null
kafka:
bootstrap-servers: 192.168.60.68:29092
consumer:
group-id: pcp-ballistics-service
auto-offset-reset: earliest
template:
default-topic: pcp.tle
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.60.68:5432/pcp_ballistics
username: postgres
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
springdoc:
swagger-ui:
enabled: true
layout: BaseLayout
path: /swagger/ui
api-docs:
enabled: true
path: /api-docs
logging:
level:
.: ERROR
file:
name: ./logs/application.log
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
info:
enabled: true
settings:
calculation-interval: 28
ic-service: http://192.168.60.68:9080
server:
port: 7003
@@ -0,0 +1,8 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
@@ -0,0 +1,111 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
jacoco
// id("org.graalvm.buildtools.native")
}
version = "1.0.0"
kotlin {
jvmToolchain((property("versions.java") as String).toInt())
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String))
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
dependencies {
implementation(project(":libs:pcp-types-lib"))
implementation(project(":libs:ballistics-lib"))
implementation("${property("dep.spring.actuator")}")
implementation("org.springframework.boot:spring-boot-starter-logging")
implementation("org.springframework.boot:spring-boot-starter-validation")
// implementation("org.springframework.boot:spring-boot-starter-web")
implementation("jakarta.validation:jakarta.validation-api")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-webflux")
// implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.4")
implementation("org.springframework.kafka:spring-kafka")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.locationtech.jts:jts-core:1.19.0")
testImplementation("junit:junit")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:junit-jupiter")
testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}")
}
}
tasks.withType<Jar> {
manifest {
attributes["Built-By"] = "nstart"
attributes["Implementation-Version"] = archiveVersion
}
}
tasks.withType<Test> {
enabled = true
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
systemProperty("spring.profiles.active", "test")
}
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
html.required = true
csv.required = false
}
}
sonar {
properties {
property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}")
}
}
@@ -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)
}
}

Some files were not shown because too many files have changed in this diff Show More