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,57 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
version = "1.0.0"
description = "Комплексный план. Динамические слоты"
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))
}
}
dependencies {
implementation(project(":libs:pcp-types-lib"))
implementation(project(":libs:ballistics-lib"))
implementation("${property("dep.spring.actuator")}")
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.springframework.kafka:spring-kafka")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.locationtech.jts:jts-core:1.19.0")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
@@ -0,0 +1,13 @@
package space.nstart.pcp.complan
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class ComPlan
fun main(args: Array<String>) {
runApplication<space.nstart.pcp.complan.ComPlan>(*args)
}
@@ -0,0 +1,136 @@
package space.nstart.pcp.complan
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.HttpStatus
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.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.complan.apl.BasicPlanCalculator
import space.nstart.pcp.complan.service.DynamicPlanRunService
import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO
import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO
import space.nstart.pcp.complan.types.DynamicPlanIntervalDTO
import space.nstart.pcp.complan.types.DynamicPlanRouteDTO
import space.nstart.pcp.complan.types.DynamicPlanRunDTO
import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import space.nstart.pcp.complan.types.GreedyMathDebugRequestDTO
import space.nstart.pcp.complan.types.GreedyMathDebugResultDTO
import space.nstart.pcp.complan.types.ObservationParametersDebugRequestDTO
import space.nstart.pcp.complan.types.ObservationParametersDebugResultDTO
import space.nstart.pcp.complan.types.TestCalcMarsDebugRequestDTO
import space.nstart.pcp.complan.types.TestCalcMarsDebugResultDTO
import java.nio.file.NoSuchFileException
import java.util.UUID
@RestController
@RequestMapping("/v1/main")
@Tag(name = "Расчет комплексного плана", description = "API расчета комплексного плана")
class ComPlanController(
private val dynamicPlanRunService: DynamicPlanRunService,
private val basicPlanCalculator: BasicPlanCalculator
) {
@PostMapping("/calcPlan")
@ResponseStatus(HttpStatus.ACCEPTED)
fun calcPlan(
@RequestBody request: ComplexPlanCalculationRequestDTO
): DynamicPlanRunDTO = dynamicPlanRunService.startCalculation(request)
@GetMapping("/calcPlan/runs")
fun getCalculations(
@RequestParam(required = false) status: DynamicPlanRunStatus?,
@RequestParam(defaultValue = "50") limit: Int,
@RequestParam(defaultValue = "0") offset: Int
): List<DynamicPlanRunDTO> = dynamicPlanRunService.listRuns(status, limit, offset)
@GetMapping("/calcPlan/{runId}")
fun getCalculation(
@PathVariable runId: UUID
): DynamicPlanRunDTO = dynamicPlanRunService.getRun(runId)
@GetMapping("/calcPlan/{runId}/result")
fun getCalculationResult(
@PathVariable runId: UUID
): ComplexPlanCalculationResultDTO = dynamicPlanRunService.getResult(runId)
@GetMapping("/calcPlan/{runId}/routes")
fun getCalculationRoutes(
@PathVariable runId: UUID,
@RequestParam(defaultValue = "1000") limit: Int,
@RequestParam(defaultValue = "0") offset: Int
): List<DynamicPlanRouteDTO> = dynamicPlanRunService.getRoutes(runId, limit, offset)
@GetMapping("/calcPlan/{runId}/intervals")
fun getCalculationIntervals(
@PathVariable runId: UUID,
@RequestParam(defaultValue = "1000") limit: Int,
@RequestParam(defaultValue = "0") offset: Int
): List<DynamicPlanIntervalDTO> = dynamicPlanRunService.getIntervals(runId, limit, offset)
@PostMapping("/calcPlan/debug/observation-parameters")
fun debugObservationParameters(
@RequestBody request: ObservationParametersDebugRequestDTO
): ObservationParametersDebugResultDTO {
if (request.calculationEnd <= request.calculationStart) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart")
}
if (request.satelliteIds.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty")
}
if (request.chunkSize != null && request.chunkSize < 1) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "chunkSize must be greater than or equal to 1")
}
if (request.parallelism != null && request.parallelism < 1) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "parallelism must be greater than or equal to 1")
}
return basicPlanCalculator.calculateObservationParametersDebug(request)
}
@PostMapping("/calcPlan/debug/greedy-math")
fun debugGreedyMath(
@RequestBody request: GreedyMathDebugRequestDTO
): GreedyMathDebugResultDTO {
if (request.calculationEnd <= request.calculationStart) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart")
}
if (request.satelliteIds.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty")
}
return try {
basicPlanCalculator.calculateGreedyMathDebug(request)
} catch (error: NoSuchFileException) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"observation parameters file not found: ${error.file}",
error
)
}
}
@PostMapping("/calcPlan/debug/testcalcMars")
fun testCalcMars(
@RequestBody request: TestCalcMarsDebugRequestDTO
): TestCalcMarsDebugResultDTO {
if (request.calculationEnd <= request.calculationStart) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart")
}
if (request.satelliteIds.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty")
}
return try {
basicPlanCalculator.calculateTestCalcMarsDebug(request)
} catch (error: NoSuchFileException) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"observation parameters file not found: ${error.file}",
error
)
}
}
}
@@ -0,0 +1,35 @@
package space.nstart.pcp.complan
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JacksonConfig {
@Bean
fun objectMapper(): ObjectMapper {
val mapper = ObjectMapper()
// Настраиваем формат для LocalDateTime
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz")
// module.addSerializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeSerializer(formatter)
// )
// module.addDeserializer<LocalDateTime?>(
// LocalDateTime::class.java,
// LocalDateTimeDeserializer(formatter)
// )
mapper.registerModule(JavaTimeModule())
.registerKotlinModule()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
return mapper
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
package space.nstart.pcp.complan.apl
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import kotlin.math.PI
import kotlin.math.atan
import kotlin.math.sqrt
class CaptureAngleCalculator {
fun calculateCaptureAngle(routeWidthKm: Double, points: List<OrbPointDTO>): Double? {
if (routeWidthKm <= 0.0 || points.isEmpty()) {
return null
}
val averageHeightMeters = points
.map { it.orbitRadiusMeters() - EARTH_RADIUS_METERS }
.filter { it > 0.0 }
.average()
if (averageHeightMeters.isNaN() || averageHeightMeters <= 0.0) {
return null
}
val halfWidthMeters = routeWidthKm * METERS_IN_KILOMETER / 2.0
return atan(halfWidthMeters / averageHeightMeters).toDegrees()
}
private fun OrbPointDTO.orbitRadiusMeters(): Double = sqrt(x * x + y * y + z * z)
private fun Double.toDegrees(): Double = this * 180.0 / PI
private companion object {
const val EARTH_RADIUS_METERS = 6_378_136.0
const val METERS_IN_KILOMETER = 1_000.0
}
}
@@ -0,0 +1,26 @@
package space.nstart.pcp.complan.apl
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.nstart.pcp.complan.types.GeoJson
import space.nstart.pcp.complan.types.GeoJsonItem
private val DEFAULT_GEO_JSON_LAYER_OUTPUT_DIR =
"${System.getProperty("java.io.tmpdir")}/pcp-dynamic-plan-service/geo-layers"
@Service
class GeoJsonLayerWriter(
@param:Value("\${complex-plan.geo-layers.enabled:false}")
private val enabled: Boolean = false,
@param:Value("\${complex-plan.geo-layers.output-dir:\${java.io.tmpdir}/pcp-dynamic-plan-service/geo-layers}")
private val outputDir: String = DEFAULT_GEO_JSON_LAYER_OUTPUT_DIR
) {
fun write(geoms: List<GeoJsonItem>, fileName: String, radToDeg: Boolean = false) {
if (!enabled) {
return
}
GeoJson.createGeoJson(geoms, fileName, outputDir, radToDeg)
}
}
@@ -0,0 +1,16 @@
package space.nstart.pcp.complan.apl
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.operation.union.UnaryUnionOp
class IntervalCoverageChecker {
fun findUncoveredGeometry(contour: Geometry, intervals: List<Geometry>): Geometry {
if (intervals.isEmpty()) {
return contour.copy()
}
val intervalsUnion = UnaryUnionOp(intervals).union()
return contour.difference(intervalsUnion)
}
}
@@ -0,0 +1,306 @@
package space.nstart.pcp.complan.apl
import org.locationtech.jts.geom.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.lang.Double.max
import java.lang.Double.min
import kotlin.collections.get
import kotlin.compareTo
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.cos
/**
* Класс построения интервалов съемки
* Под интервалами съемки полагаются марш
*/
@Service
class IntervalsBuilder(private val writeLog : Boolean = false) {
/** Логирование*/
private val log = LoggerFactory.getLogger(this::class.java)
/** Фабрика geometry*/
private val geomFactory = GeometryFactory()
private val erthEcvator = 111.3213777 * (180 / PI)
/**
* Создание линии параллельной заданной подвинутой на delX
* @param delX дельта перемещения по оси Х
* @param line эталонная линия
* @return Возвращает линию параллельную эталонной подвинутой на значение delX
*/
private fun createParallelLine(
line: LineString,
delX: Double
): LineString {
val newCoordinates = line.coordinates.map {
Coordinate(it.x + delX, it.y)
}.toTypedArray()
return geomFactory.createLineString(newCoordinates)
}
/**
* Создание полигона параллельного заданному подвинутому на delX
* @param delX дельта перемещения по оси Х
* @param pol эталонная полигон
* @return Возвращает полигон параллельный эталонному подвинутому на значение delX
*/
private fun createParallelPol(
pol: Polygon,
delX: Double
): Polygon {
val newCoordinates = pol.coordinates.map {
Coordinate(it.x + delX, it.y)
}.toTypedArray()
return geomFactory.createPolygon(newCoordinates)
}
/**
* Получение описывающего многоугольника
* @param geom контур геометрии
* @return Возвращает описывающий многоугольник [org.locationtech.jts.geom.Envelope]
*/
private fun getBoxInternal(geom: Geometry) = geom.envelopeInternal
/**
* Возвращает расстояние в км на определенной широте
* @param distanceKm дистанция в км
* @param lat широта
* @return Возвращает значение в радианах эквивалентное дистанции в километрах на данной широте
*/
private fun getCaptureDistanceInKm(distanceKm: Double, lat: Double) = (distanceKm / (erthEcvator * cos(lat / 180 * PI ))) * 180 / PI //.toRad()
//для градусов, но мы считаем в радианах
// private fun getCaptureDistanceInKm(distanceKm: Double, lat: Double) = (distanceKm / (erthecvator * cos(lat))).toRad()
/**
* Возвращает одно значение из двух, которое по модулю ближе к 0
* @param a ервое значение
* @param b второе значение
* @return Возвращает значение по модулю близкое к 0
*/
private fun closerToZero(a: Double, b: Double) = if(abs(a) < abs(b)) a else b
/**
* Передвижение маршрута ближе к контуру
* Точность до 1/10 ширины маршрута
* @param line линия
* @param contour контур
* @param offset ширина маршрута
* @return Возвращает маршрут отступающий от контура не более чем на 0,1 ширины маршрута
*/
private fun moveBorderMarToContour(
line: LineString,
contour: Polygon,
offset: Double
): Polygon {
var line = line
var step = offset
val leftLine = line
val rightLine = createParallelLine(line, -1 * step) //3
var pol = geomFactory.createPolygon(createMar(rightLine, leftLine))
while (true) {
if (!pol.intersects(contour)) {
// if(i > 150) throw IllegalArgumentException("Бесконечный цикл нахождения граничных лииний")
pol = createParallelPol(pol, step)
} else {
pol = createParallelPol(pol, -step)
if (step == offset / 10)
break
step = step / 10
}
}
return pol
}
/**
* Нахождение ширины региона
* @param borderLines граничные линии для контура
* @return Возвращает ширину региона между его граничными линиями
*/
private fun getRegionWidth(borderpols: Pair<Polygon, Polygon>, offset: Double): Double {
val firstEnvelope = borderpols.first.envelopeInternal
val secondEnvelope = borderpols.second.envelopeInternal
return (secondEnvelope.maxX - firstEnvelope.maxX) - 1*offset //3
}
/**
* Нахождение граничных полигонов
* @param flightLine линия полета
* @param contour контур региона
* @param internalBox описывающий многоугольник
* @param offset ширина маршрута
* @return Возвращает пару полигонов в порядке: **ЛЕВЫЙ**, **ПРАВЫЙ**
*/
private fun findBorderPols(
flightLine: LineString,
contour: Polygon,
internalBox: Envelope,
offset: Double
): Pair<Polygon, Polygon> {
val xUp = LineShifter().getPointOnLineY(flightLine, internalBox.maxY) ?: throw IllegalStateException("Ошибка нахождения граничных точек")
val xDown = LineShifter()
.getPointOnLineY(flightLine, internalBox.minY) ?: throw IllegalStateException("Ошибка нахождения граничных точек")
val minXValue = min(min(internalBox.minX - xUp.x, internalBox.maxX - xUp.x), min(internalBox.minX - xDown.x, internalBox.maxX - xDown.x))
val maxXValue = max(max(internalBox.minX - xUp.x, internalBox.maxX - xUp.x), max(internalBox.minX - xDown.x, internalBox.maxX - xDown.x))
val leftLine = LineShifter().lineShift(flightLine, minXValue) ?: throw IllegalStateException("Ошибка нахождения граничных точек")
val rightLine = LineShifter().lineShift(flightLine, maxXValue) ?: throw IllegalStateException("Ошибка нахождения граничных точек")
//двигаем линии ближе к контуру региона
//первая линия
val firstPol = moveBorderMarToContour(leftLine, contour, offset)
//последняя линия
val lastPol = moveBorderMarToContour(rightLine, contour, -offset)
if (writeLog) { log.info("Граничные точки найдены") }
return Pair(firstPol, lastPol)
}
/**
* Построение интервалов
* @param mars список маршрутов
* @param contour контур региона
* @return Возвращает список интервалов
*/
private fun buildIntervals(
mars: List<Geometry>,
contour: Polygon
): MutableList<Polygon> {
var intervals = mutableListOf<Polygon>()
for (mar in mars) {
val interval = contour.intersection(mar)
if(interval is Polygon) {
intervals.add(interval)
}
else if(interval is MultiPolygon) {
for (i in 0 until interval.numGeometries) {
intervals.add(interval.getGeometryN(i) as Polygon)
}
}
}
return intervals
}
/**
* Создание маршрута
* @param leftLine левая граничная линия маршрута
* @param rightLine правая граничная линия маршрута
* @return Возвращает маршрут заключенный между граничными линиями
*/
private fun createMar(
rightLine: LineString,
leftLine: LineString
): Array<Coordinate>{
val coordinates = mutableListOf<Coordinate>()
for (j in rightLine.coordinates)
coordinates.add(Coordinate(j.x, j.y))
for (j in leftLine.coordinates.size - 1 downTo 0)
coordinates.add(Coordinate(leftLine.coordinates[j].x, leftLine.coordinates[j].y))
coordinates.add(Coordinate(rightLine.coordinates[0].x, rightLine.coordinates[0].y))
return coordinates.toTypedArray()
}
/**
* Построение маршрутов
* @param width ширина региона
* @param lines граничные линии первого маршрута
* @param offset ширина маршрута
* @param overlap перекрытие маршрутов
* @return Возвращает список маршрутов для покрытия региона
*/
private fun buildMars(
width: Double,
firstPol: Polygon,
offset: Double,
overlap: Double
): List<Geometry> {
val offsetWithOverlap = offset - overlap
//расчет минимального количества маршрутов для накрытия полигона
var marCount = ((width - offset) / offsetWithOverlap) + 1
val intMarCount = ceil(marCount)
//расчет дополнительного перекрытия
val deltaOverlapMars = ((intMarCount * offsetWithOverlap - width) / (intMarCount - 1))//((intMarCount - marCount) / (intMarCount - 1)) * offset
val marOffset = offsetWithOverlap - deltaOverlapMars // if(deltaOverlapMars > overlap) (offset - deltaOverlapMars) else (offset - overlap)
var mars = mutableListOf<Geometry>()
var marPol = firstPol
for (i in 0 until intMarCount.toInt()) {
if(i == 0){
marPol = createParallelPol(firstPol, offset)
}
else {
val step = marOffset
marPol = createParallelPol(marPol, step)
}
mars.add(marPol)
}
if (writeLog) { log.info("Модель накрытия района выполнена") }
return mars
}
/**
* Получение интервалов съемки
* @param contour контур региона
* @param flightLine линия полета
* @param offsetKm ширина маршрута в км
* @param overlapKm значение минимального перекрытия в км
* @return Возвращает список интервалов для заданных параметров
*/
fun getIntervals(
contour: Polygon,
flightLine: LineString,
offsetKm: Double,
overlapKm: Double
): List<Polygon> {
//TODO(Исправить определение граничных точек района.
// При построении интервалов некоторая часть района(граничная слева/справа) пропадает.)
//расчет описывающего прямоугольника
val internalBox = getBoxInternal(contour)
if (internalBox.maxY > 80.0 || internalBox.minY < -80.0) {
return emptyList()
//TODO("Такой район пока что не обрабатывается, нужно определить способ построения интервалов для таких районов")
}
//получаем минимальную широту
val minY = closerToZero(internalBox.maxY, internalBox.minY)
//находим перекрытие и ширину маршрута учитывая широту
val offset = getCaptureDistanceInKm(offsetKm, minY)
val overlap = getCaptureDistanceInKm(overlapKm, minY)
//если ширина контура не больше чем ширина маршрута - возвращаем контур как интервал
if((internalBox.maxX - internalBox.minX) < offset) {
if (writeLog) { log.info("Контур района менее контура маршрута") }
return listOf(contour)
}
try {
//ищем граничные линии для контура
val borderPols = findBorderPols(flightLine, contour, internalBox, offset)
//строим маршруты
val mars = buildMars(getRegionWidth(borderPols, offset), borderPols.first, offset, overlap)
return buildIntervals(mars, contour)
}catch (e: Exception){
log.info(e.toString())
throw e
}
}
}
@@ -0,0 +1,79 @@
package space.nstart.pcp.complan.apl
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.CoordinateFilter
import org.locationtech.jts.geom.LineSegment
import org.locationtech.jts.geom.LineString
import kotlin.collections.get
/**
* Класс для "сдвига" линии на заданную координату по X, не меняя направление сегментов
* Поиск значений координат на заданном отрезке по X и Y
* */
class LineShifter {
/**
* Сдвигает линию на заданное значение X
* Если линия состоит менее чем из 2-х точек, возвращает null
* */
fun lineShift(line : LineString, xValue : Double) : LineString? {
if (line.coordinates.size < 2) { return null }
val copy = line.copy() as LineString
// Применяем фильтр, который конвертирует радианы → градусы
copy.apply(CoordinateFilter { coord ->
coord.x = coord.x + xValue // долгота
coord.y = coord.y // широта
})
return copy
}
/**
* Находит первую точку на полилинии, лежащую на заданной координате Y
* Возвращает null, если значение Y лежит вне проекции ее на отрезок
* Например отрезок LineSegment(Coordinate(x=0.0,y=0.0), Coordinate(x=10.0,y=5.0))
* y должен принадлежать [0.0, 5.0], иначе null
* */
fun getPointOnLineY(line : LineString, yValue : Double) : Coordinate? {
if (line.coordinates.size < 2) { return null }
for (i in 1..<line.coordinates.count()) {
val lineSegment = LineSegment(line.coordinates[i-1], line.coordinates[i])
val needPoint = getPointOnLineY(lineSegment, yValue)
if (needPoint != null) {
return needPoint
}
}
return null
}
/**
* Находит точку на отрезке, лежащую на заданной координате Y
* Возвращает null, если значение Y лежит вне проекции ее на отрезок
* Например отрезок LineSegment(Coordinate(x=0.0,y=0.0), Coordinate(x=10.0,y=5.0))
* y должен принадлежать [0.0, 5.0], иначе null
* */
private fun getPointOnLineY(line : LineSegment, yValue : Double) : Coordinate? {
// Случай: отрезок горизонтальный
if (line.p0.y == line.p1.y) {
return if (yValue == line.p0.y) {
// Можно вернуть среднюю X или любую в диапазоне
Coordinate((line.p0.x + line.p1.x) / 2.0, yValue)
} else {
null // нет пересечения
}
}
// Проверяем, лежит ли targetY между y1 и y2
val minY = minOf(line.p0.y, line.p1.y)
val maxY = maxOf(line.p0.y, line.p1.y)
if (yValue !in minY..maxY) {
return null // Y вне отрезка
}
// Линейная интерполяция
val t = (yValue - line.p0.y) / (line.p1.y - line.p0.y)
return Coordinate(line.p0.x + t * (line.p1.x - line.p0.x), yValue)
}
}
@@ -0,0 +1,41 @@
package space.nstart.pcp.complan.apl
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.nstart.pcp.complan.types.ObservationParametersDebugFileDTO
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
private val DEFAULT_OBSERVATION_PARAMETERS_OUTPUT_DIR =
"/home/dsolovyov/ws/264/pcp/services/pcp-dynamic-plan-service/obsParams"
@Service
class ObservationParametersDebugFileWriter(
private val objectMapper: ObjectMapper,
@param:Value("\${complex-plan.observation-parameters.debug-output-dir:/home/dsolovyov/ws/264/pcp/services/pcp-dynamic-plan-service/obsParams}")
private val outputDir: String = DEFAULT_OBSERVATION_PARAMETERS_OUTPUT_DIR
) {
fun write(artifact: ObservationParametersDebugFileDTO): Path {
val directory = Path.of(outputDir).toAbsolutePath().normalize()
Files.createDirectories(directory)
val file = filePathFor(artifact.requestId)
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), artifact)
return file
}
fun read(file: Path): ObservationParametersDebugFileDTO =
objectMapper.readValue(file.toFile())
fun readByRequestId(requestId: UUID): ObservationParametersDebugFileDTO =
read(filePathFor(requestId))
fun filePathFor(requestId: UUID): Path {
val directory = Path.of(outputDir).toAbsolutePath().normalize()
return directory.resolve("observation-parameters-$requestId.json")
}
}
@@ -0,0 +1,90 @@
package space.nstart.pcp.complan.apl
import ballistics.flightLine.PointOnEarthCalculator
import ballistics.types.EarthType
import ballistics.types.Orientation
import ballistics.types.OrbitalPoint
import ballistics.types.THBLPoint
import ballistics.types.WorkCSType
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.io.WKTWriter
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import kotlin.math.PI
@Service
class SurveyContourBuilder {
private val logger = LoggerFactory.getLogger(this::class.java)
private val geometryFactory = GeometryFactory()
private val wktWriter = WKTWriter()
fun build(points: List<OrbPointDTO>, roll: Double, capture: Double): String? {
val orderedPoints = points.sortedBy { it.time }
if (orderedPoints.size < 2) {
logger.warn("Контур маршрута не построен: недостаточно точек орбиты, points={}", orderedPoints.size)
return null
}
return try {
val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
val rightCoordinates = ArrayList<Coordinate>(orderedPoints.size)
val leftCoordinates = ArrayList<Coordinate>(orderedPoints.size)
orderedPoints.map { it.toOrbitalPoint() }.forEach { point ->
val rightPoint = viewParams(pointOnEarthCalculator, point, roll + capture)
?: THBLPoint(0.0, 0.0, 0.0, 0.0)
rightCoordinates += Coordinate(rightPoint.long.toDegrees(), rightPoint.lat.toDegrees(), 0.0)
val leftPoint = viewParams(pointOnEarthCalculator, point, roll - capture)
?: THBLPoint(0.0, 0.0, 0.0, 0.0)
leftCoordinates += Coordinate(leftPoint.long.toDegrees(), leftPoint.lat.toDegrees(), 0.0)
}
val shellCoordinates = ArrayList<Coordinate>(rightCoordinates.size + leftCoordinates.size + 1)
shellCoordinates.addAll(rightCoordinates)
for (index in leftCoordinates.indices.reversed()) {
shellCoordinates += leftCoordinates[index]
}
if (shellCoordinates.isNotEmpty()) {
shellCoordinates += Coordinate(shellCoordinates.first().x, shellCoordinates.first().y, 0.0)
}
val distinctVertices = shellCoordinates
.dropLast(1)
.map { it.x to it.y }
.distinct()
if (shellCoordinates.size < 4 || distinctVertices.size < 3) {
logger.warn("Контур маршрута не построен: недостаточно уникальных точек полигона")
return null
}
val polygon = geometryFactory.createPolygon(shellCoordinates.toTypedArray())
wktWriter.write(polygon)
} catch (error: Exception) {
logger.warn("Контур маршрута не построен: {}", error.message)
null
}
}
private fun viewParams(
pointOnEarthCalculator: PointOnEarthCalculator,
point: OrbitalPoint,
gamma: Double
): THBLPoint? = pointOnEarthCalculator.pointOnEarth(point, Orientation(0.0, gamma.toRadians(), 0.0))
private fun OrbPointDTO.toOrbitalPoint() = OrbitalPoint(
t = fromDateTime(time),
vit = revolution.toInt(),
r = Vector3D(x, y, z),
v = Vector3D(vx, vy, vz)
)
private fun Double.toDegrees(): Double = this * 180.0 / PI
private fun Double.toRadians(): Double = this * PI / 180.0
}
@@ -0,0 +1,18 @@
package space.nstart.pcp.complan.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@Configuration
class DynamicPlanExecutorConfig {
@Bean(destroyMethod = "shutdown")
fun dynamicPlanExecutor(): ExecutorService =
Executors.newSingleThreadExecutor { task ->
Thread(task, "dynamic-plan-calculation").apply {
isDaemon = false
}
}
}
@@ -0,0 +1,40 @@
package space.nstart.pcp.complan.config
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,22 @@
package space.nstart.pcp.complan.db.entity
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Column
import jakarta.persistence.Table
@Table(name = "aplsettings")
@Entity
class AplSettingsEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
var id : Int? = null,
@Column(name = "settings")
var settings : String
) {
constructor() : this(settings = "")
}
@@ -0,0 +1,52 @@
package space.nstart.pcp.complan.db.entity
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.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.util.UUID
@Table(name = "dynamic_plan_interval")
@Entity
class DynamicPlanIntervalEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
var id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "run_id", nullable = false)
var run: DynamicPlanRunEntity? = null,
@Column(name = "request_id", nullable = false)
var requestId: UUID? = null,
@Column(name = "interval_id", nullable = false)
var intervalId: UUID? = null,
@Column(name = "region_id", nullable = false)
var regionId: UUID? = null,
@Enumerated(EnumType.STRING)
@Column(name = "rev_sign", nullable = false)
var revSign: RevolutionSign = RevolutionSign.ASC,
@Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT")
var contourWkt: String = "",
@Column(name = "observation_parameters_count", nullable = false)
var observationParametersCount: Int = 0,
@Column(name = "interval_order", nullable = false)
var intervalOrder: Int = 0
) {
constructor() : this(id = null)
}
@@ -0,0 +1,55 @@
package space.nstart.pcp.complan.db.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 java.time.LocalDateTime
import java.util.UUID
@Table(name = "dynamic_plan_route")
@Entity
class DynamicPlanRouteEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
var id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "run_id", nullable = false)
var run: DynamicPlanRunEntity? = null,
@Column(name = "request_id", nullable = false)
var requestId: UUID? = null,
@Column(name = "satellite_id", nullable = false)
var satelliteId: Long = 0,
@Column(name = "start_time", nullable = false)
var startTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "end_time", nullable = false)
var endTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "duration", nullable = false)
var duration: Double = 0.0,
@Column(name = "revolution", nullable = false)
var revolution: Long = 0,
@Column(name = "roll", nullable = false)
var roll: Double = 0.0,
@Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT")
var contourWkt: String = "",
@Column(name = "route_order", nullable = false)
var routeOrder: Int = 0
) {
constructor() : this(id = null)
}
@@ -0,0 +1,74 @@
package space.nstart.pcp.complan.db.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import space.nstart.pcp.complan.types.DynamicPlanCalculationMode
import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode
import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import java.time.LocalDateTime
import java.util.UUID
@Table(name = "dynamic_plan_run")
@Entity
class DynamicPlanRunEntity(
@Id
@Column(name = "id", nullable = false)
var id: UUID? = null,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
var status: DynamicPlanRunStatus = DynamicPlanRunStatus.PENDING,
@Column(name = "request_id", nullable = false)
var requestId: UUID? = null,
@Column(name = "satellite_ids", nullable = false)
var satelliteIds: String = "[]",
@Column(name = "calculation_start", nullable = false)
var calculationStart: LocalDateTime? = null,
@Column(name = "calculation_end", nullable = false)
var calculationEnd: LocalDateTime? = null,
@Enumerated(EnumType.STRING)
@Column(name = "calculation_mode", nullable = false)
var calculationMode: DynamicPlanCalculationMode = DynamicPlanCalculationMode.FULL,
@Enumerated(EnumType.STRING)
@Column(name = "revolution_mode", nullable = false)
var revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH,
@Column(name = "filter_covered_routes", nullable = false)
var filterCoveredRoutes: Boolean = false,
@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 = "result_status")
var resultStatus: String? = null,
@Column(name = "routes_count")
var routesCount: Int? = null,
@Column(name = "result_json", columnDefinition = "TEXT")
var resultJson: String? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null
) {
constructor() : this(id = null)
}
@@ -0,0 +1,12 @@
package space.nstart.pcp.complan.db.repository
import space.nstart.pcp.complan.db.entity.AplSettingsEntity
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
@Repository
@Transactional
interface AplSettingsRepository: JpaRepository<space.nstart.pcp.complan.db.entity.AplSettingsEntity, Long> {
fun findFirstByOrderByIdDesc() : space.nstart.pcp.complan.db.entity.AplSettingsEntity?
}
@@ -0,0 +1,27 @@
package space.nstart.pcp.complan.db.repository
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import space.nstart.pcp.complan.db.entity.DynamicPlanIntervalEntity
import java.util.UUID
@Repository
interface DynamicPlanIntervalRepository : JpaRepository<DynamicPlanIntervalEntity, Long> {
@Query(
value = """
select *
from dynamic_plan_interval
where run_id = :runId
order by interval_order asc, id asc
limit :limit offset :offset
""",
nativeQuery = true
)
fun findIntervalsByRunId(
@Param("runId") runId: UUID,
@Param("limit") limit: Int,
@Param("offset") offset: Int
): List<DynamicPlanIntervalEntity>
}
@@ -0,0 +1,34 @@
package space.nstart.pcp.complan.db.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 org.springframework.stereotype.Repository
import space.nstart.pcp.complan.db.entity.DynamicPlanRouteEntity
import java.util.UUID
@Repository
interface DynamicPlanRouteRepository : JpaRepository<DynamicPlanRouteEntity, Long> {
fun countBySatelliteId(satelliteId: Long): Long
@Modifying
@Query("delete from DynamicPlanRouteEntity route where route.satelliteId = :satelliteId")
fun deleteAllBySatelliteId(@Param("satelliteId") satelliteId: Long): Int
@Query(
value = """
select *
from dynamic_plan_route
where run_id = :runId
order by start_time asc, id asc
limit :limit offset :offset
""",
nativeQuery = true
)
fun findRoutesByRunId(
@Param("runId") runId: UUID,
@Param("limit") limit: Int,
@Param("offset") offset: Int
): List<DynamicPlanRouteEntity>
}
@@ -0,0 +1,74 @@
package space.nstart.pcp.complan.db.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 org.springframework.stereotype.Repository
import space.nstart.pcp.complan.db.entity.DynamicPlanRunEntity
import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import java.util.UUID
@Repository
interface DynamicPlanRunRepository : JpaRepository<DynamicPlanRunEntity, UUID> {
@Query(
value = """
select count(*)
from dynamic_plan_run run
where exists (
select 1
from jsonb_array_elements_text(run.satellite_ids::jsonb) satellite_id(value)
where satellite_id.value::bigint = :satelliteId
)
""",
nativeQuery = true
)
fun countBySatelliteId(@Param("satelliteId") satelliteId: Long): Long
@Modifying
@Query(
value = """
delete from dynamic_plan_run run
where exists (
select 1
from jsonb_array_elements_text(run.satellite_ids::jsonb) satellite_id(value)
where satellite_id.value::bigint = :satelliteId
)
""",
nativeQuery = true
)
fun deleteAllBySatelliteId(@Param("satelliteId") satelliteId: Long): Int
@Query(
value = """
select *
from dynamic_plan_run
order by created_at desc
limit :limit offset :offset
""",
nativeQuery = true
)
fun findRuns(
@Param("limit") limit: Int,
@Param("offset") offset: Int
): List<DynamicPlanRunEntity>
@Query(
value = """
select *
from dynamic_plan_run
where status = :status
order by created_at desc
limit :limit offset :offset
""",
nativeQuery = true
)
fun findRunsByStatus(
@Param("status") status: String,
@Param("limit") limit: Int,
@Param("offset") offset: Int
): List<DynamicPlanRunEntity>
fun findAllByStatusIn(statuses: Collection<DynamicPlanRunStatus>): List<DynamicPlanRunEntity>
}
@@ -0,0 +1,40 @@
package space.nstart.pcp.complan.service
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
import space.nstart.pcp.complan.db.repository.AplSettingsRepository
import space.nstart.pcp.complan.settings.AplSettings
import space.nstart.pcp.complan.settings.SettingsDTO
@Service
class AplSettingsLoader(
private val aplSetRep: AplSettingsRepository,
private val aplSettings: AplSettings,
private val objectMapper: ObjectMapper
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Transactional(readOnly = true)
fun loadLatestSettings() {
val settingsEntity = aplSetRep.findFirstByOrderByIdDesc()
if (settingsEntity == null) {
log.info("APL settings were not found in DB, current in-memory settings will be used")
return
}
aplSettings.settings = try {
objectMapper.readValue(settingsEntity.settings, SettingsDTO::class.java)
} catch (error: Exception) {
throw ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Failed to read APL settings from DB record id=${settingsEntity.id}",
error
)
}
log.info("APL settings loaded from DB record id={}", settingsEntity.id)
}
}
@@ -0,0 +1,98 @@
package space.nstart.pcp.complan.service
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder
import space.nstart.pcp.complan.types.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO as BallisticsObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO as BallisticsObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.time.LocalDateTime
/**Получение данных от сервиса баллистики*/
@Service
class BallTaskServce {
@Value("\${complex-plan.ballistics-service-url}")
private lateinit var ballisticsServiceUrl: String
fun getOrbitAvailability(noradId : Long): SatelliteOrbitAvailabilityDTO =
RestClient.create()
.get()
.uri(buildBallisticsUri("/api/satellites/orbit/availability/$noradId"))
.retrieve()
.body(SatelliteOrbitAvailabilityDTO::class.java)
?: throw IllegalStateException("Пустой ответ баллистики для доступности ПДЦМ КА $noradId")
fun getFlightLine(noradId: Long, timeStart : LocalDateTime, timeStop : LocalDateTime): List<FlightLineDTO> =
RestClient.create()
.get()
.uri(
UriComponentsBuilder
.fromUriString(ballisticsServiceUrl)
.path("/api/satellites/$noradId/flight-line")
.queryParam("time_start", timeStart)
.queryParam("time_stop", timeStop)
.toUriString()
)
.retrieve()
.body(FLIGHT_LINE_LIST_TYPE)
.orEmpty()
fun getExactTime(noradId: Long, request: ExactTimePositionRequestDTO): List<OrbPointDTO> =
RestClient.create()
.post()
.uri(buildBallisticsUri("/api/satellites/$noradId/extract-time"))
.body(request)
.retrieve()
.body(ORB_POINT_LIST_TYPE)
.orEmpty()
fun getMplForObjects(objects : ObjViewRequestDTO): List<SquareViewParamDTO> {
val satellites = objects.satellites?.toList().orEmpty()
val timeStart = objects.timeStart ?: throw IllegalArgumentException("timeStart must be specified for MPL request")
val timeStop = objects.timeStop ?: throw IllegalArgumentException("timeStop must be specified for MPL request")
return objects.objects.flatMap { obj ->
RestClient.create()
.post()
.uri(buildBallisticsUri("/api/obj-view/mpl-square"))
.body(
BallisticsObjViewRequestDTO(
satellites = satellites,
timeStart = timeStart,
timeStop = timeStop,
obj = BallisticsObjDTO(
id = obj.id.toString(),
position = obj.position,
contourWKT = obj.contourWKT
),
sunAngleMin = objects.sunAngleMin
)
)
.retrieve()
.body(SQUARE_VIEW_PARAM_LIST_TYPE)
.orEmpty()
}
}
private fun buildBallisticsUri(path: String): String =
UriComponentsBuilder
.fromUriString(ballisticsServiceUrl)
.path(path)
.toUriString()
private companion object {
val FLIGHT_LINE_LIST_TYPE = object : ParameterizedTypeReference<List<FlightLineDTO>>() {}
val ORB_POINT_LIST_TYPE = object : ParameterizedTypeReference<List<OrbPointDTO>>() {}
val SQUARE_VIEW_PARAM_LIST_TYPE = object : ParameterizedTypeReference<List<SquareViewParamDTO>>() {}
}
}
@@ -0,0 +1,324 @@
package space.nstart.pcp.complan.service
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.complan.apl.BasicPlanCalculator
import space.nstart.pcp.complan.db.entity.DynamicPlanIntervalEntity
import space.nstart.pcp.complan.db.entity.DynamicPlanRouteEntity
import space.nstart.pcp.complan.db.entity.DynamicPlanRunEntity
import space.nstart.pcp.complan.db.repository.DynamicPlanIntervalRepository
import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository
import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository
import space.nstart.pcp.complan.types.ComplexPlanCalculationOutput
import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO
import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO
import space.nstart.pcp.complan.types.DynamicPlanIntervalDTO
import space.nstart.pcp.complan.types.DynamicPlanRouteDTO
import space.nstart.pcp.complan.types.DynamicPlanRunDTO
import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import space.nstart.pcp.complan.types.IntervalItemDTO
import space.nstart.pcp.complan.types.SurveyContour
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.ExecutorService
import kotlin.math.roundToLong
@Service
class DynamicPlanRunService(
private val runRepository: DynamicPlanRunRepository,
private val routeRepository: DynamicPlanRouteRepository,
private val intervalRepository: DynamicPlanIntervalRepository,
private val basePlan: BasicPlanCalculator,
private val aplSettingsLoader: AplSettingsLoader,
private val objectMapper: ObjectMapper,
private val transactionTemplate: TransactionTemplate,
private val dynamicPlanExecutor: ExecutorService
) {
private val log = LoggerFactory.getLogger(this::class.java)
private val satelliteIdsType = object : TypeReference<List<Long>>() {}
fun startCalculation(request: ComplexPlanCalculationRequestDTO): DynamicPlanRunDTO {
validateRequest(request)
val runId = UUID.randomUUID()
val createdRun = transactionTemplate.execute {
runRepository.save(
DynamicPlanRunEntity(
id = runId,
status = DynamicPlanRunStatus.PENDING,
requestId = request.requestId,
satelliteIds = objectMapper.writeValueAsString(request.satelliteIds.distinct()),
calculationStart = request.calculationStart,
calculationEnd = request.calculationEnd,
calculationMode = request.calculationMode,
revolutionMode = request.revolutionMode,
filterCoveredRoutes = request.filterCoveredRoutes,
createdAt = LocalDateTime.now()
)
)
} ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create calculation run")
dynamicPlanExecutor.submit {
executeCalculation(runId)
}
return createdRun.toDto()
}
fun getRun(runId: UUID): DynamicPlanRunDTO =
findRun(runId).toDto()
fun listRuns(status: DynamicPlanRunStatus?, limit: Int, offset: Int): List<DynamicPlanRunDTO> {
validatePage(limit, offset)
val runs = when (status) {
null -> runRepository.findRuns(limit, offset)
else -> runRepository.findRunsByStatus(status.name, limit, offset)
}
return runs.map { run -> run.toDto() }
}
fun getResult(runId: UUID): ComplexPlanCalculationResultDTO {
val run = findRun(runId)
if (run.status != DynamicPlanRunStatus.COMPLETED) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId is not completed")
}
val resultJson = run.resultJson
?: throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId has no result")
val resultNode = objectMapper.readTree(resultJson)
if (resultNode is ObjectNode && !resultNode.has("calculationMode")) {
resultNode.put("calculationMode", run.calculationMode.name)
}
if (resultNode is ObjectNode && !resultNode.has("revolutionMode")) {
resultNode.put("revolutionMode", run.revolutionMode.name)
}
if (resultNode is ObjectNode && !resultNode.has("filterCoveredRoutes")) {
resultNode.put("filterCoveredRoutes", run.filterCoveredRoutes)
}
return objectMapper.treeToValue(resultNode, ComplexPlanCalculationResultDTO::class.java)
}
fun getRoutes(runId: UUID, limit: Int, offset: Int): List<DynamicPlanRouteDTO> {
validatePage(limit, offset)
val run = findRun(runId)
if (run.status != DynamicPlanRunStatus.COMPLETED) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId is not completed")
}
return routeRepository.findRoutesByRunId(runId, limit, offset)
.map { route -> route.toDto() }
}
fun getIntervals(runId: UUID, limit: Int, offset: Int): List<DynamicPlanIntervalDTO> {
validatePage(limit, offset)
val run = findRun(runId)
if (run.status != DynamicPlanRunStatus.COMPLETED) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Calculation $runId is not completed")
}
return intervalRepository.findIntervalsByRunId(runId, limit, offset)
.map { interval -> interval.toDto() }
}
fun recoverInterruptedRuns() {
val recoveredCount = transactionTemplate.execute {
val runs = runRepository.findAllByStatusIn(
listOf(DynamicPlanRunStatus.PENDING, DynamicPlanRunStatus.RUNNING)
)
if (runs.isEmpty()) {
return@execute 0
}
val finishedAt = LocalDateTime.now()
runs.forEach { run ->
run.status = DynamicPlanRunStatus.FAILED
run.finishedAt = finishedAt
run.errorMessage = "Calculation was interrupted by service restart"
}
runRepository.saveAll(runs)
runs.size
} ?: 0
if (recoveredCount > 0) {
log.warn("Recovered {} interrupted dynamic plan runs after startup", recoveredCount)
}
}
private fun executeCalculation(runId: UUID) {
val request = markRunningAndBuildRequest(runId)
if (request == null) {
log.warn("Dynamic plan run {} was not found before execution", runId)
return
}
try {
aplSettingsLoader.loadLatestSettings()
val output = basePlan.calculate(request)
markCompleted(runId, output)
} catch (error: Exception) {
log.error("Dynamic plan run {} failed", runId, error)
markFailed(runId, error)
}
}
private fun markRunningAndBuildRequest(runId: UUID): ComplexPlanCalculationRequestDTO? =
transactionTemplate.execute {
val run = runRepository.findById(runId).orElse(null) ?: return@execute null
run.status = DynamicPlanRunStatus.RUNNING
run.startedAt = LocalDateTime.now()
run.errorMessage = null
runRepository.save(run)
run.toRequest()
}
private fun markCompleted(runId: UUID, output: ComplexPlanCalculationOutput) {
transactionTemplate.execute {
val run = runRepository.findById(runId).orElseThrow()
val result = output.result
run.status = DynamicPlanRunStatus.COMPLETED
run.finishedAt = LocalDateTime.now()
run.durationMs = result.durationMs
run.resultStatus = result.status.name
run.routesCount = result.routesCount
run.resultJson = objectMapper.writeValueAsString(result)
run.errorMessage = null
intervalRepository.saveAll(
output.intervals.mapIndexed { index, interval -> interval.toEntity(run, result.requestId, index) }
)
routeRepository.saveAll(output.routes.mapIndexed { index, route -> route.toEntity(run, result.requestId, index) })
runRepository.save(run)
}
}
private fun markFailed(runId: UUID, error: Exception) {
transactionTemplate.execute {
val run = runRepository.findById(runId).orElseThrow()
run.status = DynamicPlanRunStatus.FAILED
run.finishedAt = LocalDateTime.now()
run.errorMessage = error.message ?: error::class.java.simpleName
runRepository.save(run)
}
}
private fun findRun(runId: UUID): DynamicPlanRunEntity =
runRepository.findById(runId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Calculation $runId was not found") }
private fun validateRequest(request: ComplexPlanCalculationRequestDTO) {
if (request.calculationEnd <= request.calculationStart) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "calculationEnd must be after calculationStart")
}
if (request.satelliteIds.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "satelliteIds must not be empty")
}
}
private fun validatePage(limit: Int, offset: Int) {
if (limit !in 1..10_000) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "limit must be between 1 and 10000")
}
if (offset < 0) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "offset must be greater than or equal to 0")
}
}
private fun DynamicPlanRunEntity.toRequest(): ComplexPlanCalculationRequestDTO =
ComplexPlanCalculationRequestDTO(
requestId = requestId ?: throw IllegalStateException("Run $id has no requestId"),
satelliteIds = readSatelliteIds(satelliteIds),
calculationStart = calculationStart ?: throw IllegalStateException("Run $id has no calculationStart"),
calculationEnd = calculationEnd ?: throw IllegalStateException("Run $id has no calculationEnd"),
calculationMode = calculationMode,
revolutionMode = revolutionMode,
filterCoveredRoutes = filterCoveredRoutes
)
private fun DynamicPlanRunEntity.toDto(): DynamicPlanRunDTO =
DynamicPlanRunDTO(
runId = id ?: throw IllegalStateException("Run has no id"),
status = status,
requestId = requestId ?: throw IllegalStateException("Run $id has no requestId"),
satelliteIds = readSatelliteIds(satelliteIds),
calculationStart = calculationStart ?: throw IllegalStateException("Run $id has no calculationStart"),
calculationEnd = calculationEnd ?: throw IllegalStateException("Run $id has no calculationEnd"),
calculationMode = calculationMode,
revolutionMode = revolutionMode,
filterCoveredRoutes = filterCoveredRoutes,
createdAt = createdAt,
startedAt = startedAt,
finishedAt = finishedAt,
durationMs = durationMs,
resultStatus = resultStatus,
routesCount = routesCount,
errorMessage = errorMessage
)
private fun readSatelliteIds(value: String): List<Long> =
objectMapper.readValue(value, satelliteIdsType)
private fun IntervalItemDTO.toEntity(
run: DynamicPlanRunEntity,
requestId: UUID,
intervalOrder: Int
): DynamicPlanIntervalEntity =
DynamicPlanIntervalEntity(
run = run,
requestId = requestId,
intervalId = id,
regionId = regionId,
revSign = revSign,
contourWkt = geometry,
observationParametersCount = mpl.size,
intervalOrder = intervalOrder
)
private fun SurveyContour.toEntity(
run: DynamicPlanRunEntity,
requestId: UUID,
routeOrder: Int
): DynamicPlanRouteEntity =
DynamicPlanRouteEntity(
run = run,
requestId = requestId,
satelliteId = mpl.noradId,
startTime = time,
endTime = time.plusNanos((duration * 1_000_000_000.0).roundToLong().coerceAtLeast(1L)),
duration = duration,
revolution = rev,
roll = gamma,
contourWkt = geometry,
routeOrder = routeOrder
)
private fun DynamicPlanIntervalEntity.toDto(): DynamicPlanIntervalDTO =
DynamicPlanIntervalDTO(
id = id ?: throw IllegalStateException("Interval has no id"),
runId = run?.id ?: throw IllegalStateException("Interval $id has no run id"),
requestId = requestId ?: throw IllegalStateException("Interval $id has no request id"),
intervalId = intervalId ?: throw IllegalStateException("Interval $id has no interval id"),
regionId = regionId ?: throw IllegalStateException("Interval $id has no region id"),
revSign = revSign,
contourWkt = contourWkt,
observationParametersCount = observationParametersCount
)
private fun DynamicPlanRouteEntity.toDto(): DynamicPlanRouteDTO =
DynamicPlanRouteDTO(
id = id ?: throw IllegalStateException("Route has no id"),
runId = run?.id ?: throw IllegalStateException("Route $id has no run id"),
requestId = requestId ?: throw IllegalStateException("Route $id has no request id"),
satelliteId = satelliteId,
startTime = startTime,
endTime = endTime,
duration = duration,
revolution = revolution,
roll = roll,
contourWkt = contourWkt
)
}
@@ -0,0 +1,15 @@
package space.nstart.pcp.complan.service
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.stereotype.Component
@Component
class DynamicPlanRunStartupRecovery(
private val dynamicPlanRunService: DynamicPlanRunService
) : ApplicationRunner {
override fun run(args: ApplicationArguments) {
dynamicPlanRunService.recoverInterruptedRuns()
}
}
@@ -0,0 +1,87 @@
package space.nstart.pcp.complan.service
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder
import space.nstart.pcp.complan.types.RequestItemDTO
import java.time.OffsetDateTime
import java.util.UUID
/** Получение заявок из pcp-request-service /v1 API. */
@Service
class RequestServiceClient {
@Value("\${complex-plan.request-service-url}")
private lateinit var requestServiceUrl: String
@Value("\${complex-plan.request-selection-path:/v1/requests}")
private lateinit var requestSelectionPath: String
fun getRequest(requestId: UUID): RequestItemDTO? =
try {
RestClient.create()
.get()
.uri(
UriComponentsBuilder
.fromUriString(requestServiceUrl)
.path(requestSelectionPath)
.path("/{requestId}")
.buildAndExpand(requestId)
.toUriString()
)
.retrieve()
.body(RequestResponseDto::class.java)
?.toRequestItemDto()
} catch (_: HttpClientErrorException.NotFound) {
null
}
private fun RequestResponseDto.toRequestItemDto(): RequestItemDTO =
RequestItemDTO(
id = id,
name = name,
geometry = geometry,
intervalBegin = beginDateTime.toLocalDateTime(),
intervalEnd = endDateTime.toLocalDateTime(),
resolution = resolution(),
importance = importance,
)
private fun RequestResponseDto.resolution(): Double =
optics?.resolution
?: rsa?.resolution
?: throw IllegalStateException("Request $id has no survey resolution")
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class RequestResponseDto(
val id: UUID,
val name: String,
val status: String = "",
val surveyType: String = "",
val geometry: String,
val importance: Double,
val beginDateTime: OffsetDateTime,
val endDateTime: OffsetDateTime,
val kpp: List<Int> = emptyList(),
val highPriorityTransmit: Boolean = false,
val optics: SurveyParamsDto? = null,
val rsa: SurveyParamsDto? = null,
val coverage: CoverageStateDto = CoverageStateDto(),
val createdAt: OffsetDateTime? = null,
val updatedAt: OffsetDateTime? = null,
val deletedAt: OffsetDateTime? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class SurveyParamsDto(
val resolution: Double,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CoverageStateDto(
val requiredPercent: Double? = null,
val currentPercent: Double = 0.0,
)
@@ -0,0 +1,33 @@
package space.nstart.pcp.complan.service
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
import org.springframework.web.util.UriComponentsBuilder
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
/** Получение данных КА из satellite-catalog-service. */
@Service
class SatelliteCatalogServiceClient {
@Value("\${complex-plan.satellite-catalog-service-url}")
private lateinit var satelliteCatalogServiceUrl: String
fun getSatellites(): List<SatelliteSummaryDTO> =
RestClient.create()
.get()
.uri(buildSatelliteCatalogUri("/api/satellites"))
.retrieve()
.body(SATELLITE_SUMMARY_LIST_TYPE)
.orEmpty()
private fun buildSatelliteCatalogUri(path: String): String =
UriComponentsBuilder
.fromUriString(satelliteCatalogServiceUrl)
.path(path)
.toUriString()
private companion object {
val SATELLITE_SUMMARY_LIST_TYPE = object : ParameterizedTypeReference<List<SatelliteSummaryDTO>>() {}
}
}
@@ -0,0 +1,72 @@
package space.nstart.pcp.complan.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-dynamic-plan-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-dynamic-plan-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-dynamic-plan-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-dynamic-plan-service", exception)
throw exception
}
}
private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List<Long> =
listOfNotNull(satelliteId, noradId).distinct()
}
@@ -0,0 +1,49 @@
package space.nstart.pcp.complan.service
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository
import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository
@Service
class SatelliteDeletedService(
private val runRepository: DynamicPlanRunRepository,
private val routeRepository: DynamicPlanRouteRepository
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@Transactional
fun deleteSatelliteData(satelliteId: Long): SatelliteDynamicPlanDeletionSummary {
val runsBeforeDelete = runRepository.countBySatelliteId(satelliteId)
val routesBeforeDelete = routeRepository.countBySatelliteId(satelliteId)
val runsDeleted = runRepository.deleteAllBySatelliteId(satelliteId)
val orphanRoutesDeleted = routeRepository.deleteAllBySatelliteId(satelliteId)
val summary = SatelliteDynamicPlanDeletionSummary(
satelliteId = satelliteId,
runsDeleted = runsDeleted,
routesDeletedByRunCascade = routesBeforeDelete - orphanRoutesDeleted,
orphanRoutesDeleted = orphanRoutesDeleted
)
logger.info(
"Deleted dynamic plan data for satellite: satelliteId={}, runsDeleted={}, runsBeforeDelete={}, routesDeletedByRunCascade={}, orphanRoutesDeleted={}, routesBeforeDelete={}",
summary.satelliteId,
summary.runsDeleted,
runsBeforeDelete,
summary.routesDeletedByRunCascade,
summary.orphanRoutesDeleted,
routesBeforeDelete
)
return summary
}
}
data class SatelliteDynamicPlanDeletionSummary(
val satelliteId: Long,
val runsDeleted: Int,
val routesDeletedByRunCascade: Long,
val orphanRoutesDeleted: Int
)
@@ -0,0 +1,37 @@
package space.nstart.pcp.complan.settings
import org.springframework.stereotype.Service
@Service
/** Класс хранения настроек автомата планирования */
class AplSettings() {
/** Типовые настройки работы автомата планирования */
var settings = _root_ide_package_.space.nstart.pcp.complan.settings.SettingsDTO()
}
/* Типовой набор настроек
{
"daysCalc": 1,
"kaList": [
1,
2,
3
],
"marBeginCorrectionTime": 1,
"marEndCorrectionTime": 1,
"marWidth": 30,
"marOverlap": 1,
"marMaxLength": 60,
"marMinLength": 5,
"maxRevTime": 300,
"maxDayTime": 2100,
"contourStepBuild": 1,
"filterByRegionUUID": true,
"regionList": ["831abc95-b3c2-4aec-96d7-7023e5256042"],
"filterByGamma": true,
"gammaValue": 1
}
Пример записи в БД
"{""daysCalc"":10,""kaList"":[1,2,3],""marBeginCorrectionTime"":1,""marEndCorrectionTime"":1,""marWidth"":30.0,""marOverlap"":1.0,""marMaxLength"":60.0,""marMinLength"":5.0,""maxRevTime"":300.0,""maxDayTime"":2100.0,""contourStepBuild"":1.0,""filterByRegionUUID"":true,""regionList"":[""3fa85f64-5717-4562-b3fc-111111111111""],""filterByGamma"":false,""gammaValue"":6.0,""filterByRevolution"":true,""revolutionType"":0}"
* */
@@ -0,0 +1,83 @@
package space.nstart.pcp.complan.settings
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.swagger.v3.oas.annotations.media.Schema
import java.util.UUID
@Schema(description = "Типовые настройки работы автомата планирования")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSerialize
data class SettingsDTO(
@get:JsonProperty("daysCalc")
@param:JsonProperty("daysCalc")
val daysCalc: Int = 1,
@get:JsonProperty("kaList")
@param:JsonProperty("kaList")
val kaList: Set<Int> = setOf(1,2,3),
@get:JsonProperty("marBeginCorrectionTime")
@param:JsonProperty("marBeginCorrectionTime")
val marBeginCorrectionTime: Long = 1,
@get:JsonProperty("marEndCorrectionTime")
@param:JsonProperty("marEndCorrectionTime")
val marEndCorrectionTime: Long = 1,
@get:JsonProperty("marWidth")
@param:JsonProperty("marWidth")
val marWidth : Double = 30.0,
@get:JsonProperty("marOverlap")
@param:JsonProperty("marOverlap")
val marOverlap : Double = 1.0,
@get:JsonProperty("marMaxLength")
@param:JsonProperty("marMaxLength")
val marMaxLength : Double = 60.0,
@get:JsonProperty("marMinLength")
@param:JsonProperty("marMinLength")
val marMinLength : Double = 5.0,
@get:JsonProperty("maxRevTime")
@param:JsonProperty("maxRevTime")
val maxRevTime : Double = 300.0,
@get:JsonProperty("maxDayTime")
@param:JsonProperty("maxDayTime")
val maxDayTime : Double = 2100.0,
@get:JsonProperty("contourStepBuild")
@param:JsonProperty("contourStepBuild")
val contourStepBuild : Double = 1.0,
@get:JsonProperty("filterByRegionUUID")
@param:JsonProperty("filterByRegionUUID")
val filterByRegionUUID : Boolean = false,
@get:JsonProperty("regionList")
@param:JsonProperty("regionList")
val regionList : Set<UUID> = setOf(),
@get:JsonProperty("filterByGamma")
@param:JsonProperty("filterByGamma")
val filterByGamma : Boolean = false,
@get:JsonProperty("gammaValue")
@param:JsonProperty("gammaValue")
val gammaValue : Double = 40.0,
@get:JsonProperty("filterByRevolution")
@param:JsonProperty("filterByRevolution")
val filterByRevolution : Boolean = false,
@get:JsonProperty("revolutionType")
@param:JsonProperty("revolutionType")
val revolutionType : Int = 0,
)
@@ -0,0 +1,15 @@
package space.nstart.pcp.complan.spacecraft
/**
* Ограничения работы одного космического аппарата при построении расписания включений.
*
* Сейчас модель содержит только временные лимиты, которые использует greedy-планировщик:
* суммарное время работы на витке, суммарное время работы за сутки и технологический разрыв
* между двумя включениями одного аппарата. Класс вынесен рядом со SpaceCraft, чтобы будущие
* ограничения аппарата добавлялись к доменной модели КА, а не разрастались внутри алгоритма планирования.
*/
data class SCConstraints(
val maxRevolutionDurationSeconds: Long = 300,
val maxDailyDurationSeconds: Long = 2100,
val routeGapSeconds: Long = 60
)
@@ -0,0 +1,144 @@
package space.nstart.pcp.complan.spacecraft
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.LineString
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime
/**
* Класс космического аппарата**
*/
class SpaceCraft (
val id : Long,
val constraints: SCConstraints = SCConstraints()
) {
val flightLine : MutableList<FlightLineDTO> = mutableListOf()
/**Время начала планирования аппарата*/
var timeStart: LocalDateTime = LocalDateTime.now()
/**Время конца планирования аппарата*/
var timeStop : LocalDateTime = LocalDateTime.now()
/**
* Создание линий полета восходящего и нисходящего витка
* @param geomFactory фабрика геометрии
* @param fLC координаты линии
* @return
* @return Возвращает геометрию линии полученной из массива координат
*/
fun createLineString(
geomFactory: GeometryFactory,
fLC: MutableList<Coordinate>,
): LineString {
require(fLC.size >= 2) { "Для построения трассы требуется минимум две точки" }
val coordinates = fLC.toTypedArray()
val minY = coordinates.minOf { it.y }
val maxY = coordinates.maxOf { it.y }
val startIndex1 = coordinates.indexOfFirst { it.y == minY }
val endIndex1 = coordinates.indexOfFirst { it.y == maxY }
val array = if (startIndex1 < endIndex1)
coordinates.sliceArray(startIndex1..endIndex1).toMutableList()
else
coordinates.sliceArray(endIndex1..startIndex1).toMutableList()
val geom = geomFactory.createLineString(array.toTypedArray())
return geom
}
/**
* Получение линии полета в формате LineString
* @return Возвращает часть линии полета от минимального занчения координаты Y до максимального. Параметры: **первый - НИСХОДЯЩИЙ, второй - ВОСХОДЯЩИЙ**
* TODO Переписать структуру хранения FlightLine, неудобно держать в памяти первый или второй, нужны признаки
*/
fun createFlightLine(): Pair<LineString, LineString> {
val geomFactory = GeometryFactory()
val passes = getFlightLinePasses()
val fLC1 = lineCorrection(selectPass(passes, RevolutionSign.DESC).coordinates).toMutableList()
val fLC2 = lineCorrection(selectPass(passes, RevolutionSign.ASC).coordinates).toMutableList()
return Pair(createLineString(geomFactory, fLC1), createLineString(geomFactory, fLC2))
}
private fun getFlightLinePasses(): List<FlightLinePass> {
if (flightLine.isEmpty()) {
return emptyList()
}
val passes = mutableListOf<FlightLinePass>()
var currentSign = flightLine.first().revSign
var currentCoordinates = mutableListOf<Coordinate>()
flightLine.forEach { point ->
if (point.revSign != currentSign) {
addPass(passes, currentSign, currentCoordinates)
currentSign = point.revSign
currentCoordinates = mutableListOf()
}
currentCoordinates.add(Coordinate(point.long, point.lat))
}
addPass(passes, currentSign, currentCoordinates)
return passes
}
private fun addPass(
passes: MutableList<FlightLinePass>,
revSign: RevolutionSign,
coordinates: List<Coordinate>
) {
if (coordinates.size >= 2) {
passes.add(FlightLinePass(revSign, coordinates.map { it.copy() }))
}
}
private fun selectPass(passes: List<FlightLinePass>, revSign: RevolutionSign): FlightLinePass {
val sameDirectionPasses = passes.filter { it.revSign == revSign }
return sameDirectionPasses.firstOrNull { it.isFullPass() }
?: sameDirectionPasses.maxByOrNull { it.latitudeRange }
?: throw IllegalStateException("Не найдена трасса полета для направления $revSign")
}
fun lineCorrection(tmpLine : List<Coordinate>) : List<Coordinate> {
if (tmpLine.isEmpty()) {
return emptyList()
}
val line = mutableListOf<Coordinate>()
line.add(tmpLine.first().copy())
for (i in 1..<tmpLine.count()) {
val coord = tmpLine[i].copy()
while (coord.x - line.last().x > HALF_WORLD_DEGREES) {
coord.x -= FULL_WORLD_DEGREES
}
while (coord.x - line.last().x < -HALF_WORLD_DEGREES) {
coord.x += FULL_WORLD_DEGREES
}
line.add(coord.copy())
}
return line
}
private data class FlightLinePass(
val revSign: RevolutionSign,
val coordinates: List<Coordinate>
) {
val minLat = coordinates.minOf { it.y }
val maxLat = coordinates.maxOf { it.y }
val latitudeRange = maxLat - minLat
fun isFullPass(): Boolean = minLat <= FULL_PASS_MIN_LAT && maxLat >= FULL_PASS_MAX_LAT
}
private companion object {
const val FULL_PASS_MIN_LAT = -80.0
const val FULL_PASS_MAX_LAT = 80.0
const val HALF_WORLD_DEGREES = 180.0
const val FULL_WORLD_DEGREES = 360.0
}
}
@@ -0,0 +1,64 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.swagger.v3.oas.annotations.media.Schema
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.io.WKTReader
import java.time.LocalDateTime
import java.util.*
@Schema(description = "Объект каталога для планирования")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSerialize
data class AplObjItem(
@get:JsonProperty("name")
@param:JsonProperty("name")
val name: String,
@get:JsonProperty("geometry")
@param:JsonProperty("geometry")
val geometry: Geometry,
@get:JsonProperty("id")
@param:JsonProperty("id")
val id : UUID,
@get:JsonProperty("intevals")
@param:JsonProperty("intevals")
@JsonIgnore
val intevals : MutableList<IntervalItemDTO>,
@get:JsonProperty("krenMin")
@param:JsonProperty("krenMin")
val krenMin : Double,
@get:JsonProperty("krenMax")
@param:JsonProperty("krenMax")
val krenMax : Double,
@get:JsonProperty("intervalBegin")
@param:JsonProperty("intervalBegin")
val intervalBegin : LocalDateTime? = null,
@get:JsonProperty("intervalEnd")
@param:JsonProperty("intervalEnd")
val intervalEnd : LocalDateTime? = null,
@get:JsonProperty("lastChange")
@param:JsonProperty("lastChange")
/** Если значение данного параметра null, значит задание новое */
val lastChange : LocalDateTime? = null,
) {
constructor(obj: RequestItemDTO) : this(
name = obj.name,
geometry = WKTReader().read(obj.geometry),
id = obj.id,
intevals = mutableListOf(),
krenMin = -45.0,
krenMax = 45.0
)
}
@@ -0,0 +1,7 @@
package space.nstart.pcp.complan.types
data class ComplexPlanCalculationOutput(
val result: ComplexPlanCalculationResultDTO,
val routes: List<SurveyContour>,
val intervals: List<IntervalItemDTO> = emptyList()
)
@@ -0,0 +1,39 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
import java.util.UUID
@Schema(description = "Запрос расчета модели комплексного плана для одной заявки")
@JsonIgnoreProperties(ignoreUnknown = true)
data class ComplexPlanCalculationRequestDTO(
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("calculationMode")
@param:JsonProperty("calculationMode")
val calculationMode: DynamicPlanCalculationMode = DynamicPlanCalculationMode.FULL,
@get:JsonProperty("revolutionMode")
@param:JsonProperty("revolutionMode")
val revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH,
@get:JsonProperty("filterCoveredRoutes")
@param:JsonProperty("filterCoveredRoutes")
val filterCoveredRoutes: Boolean = false
)
@@ -0,0 +1,73 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
import java.util.UUID
enum class ComplexPlanCalculationStatus {
COMPLETED,
REQUEST_NOT_FOUND,
NO_AVAILABLE_SATELLITES
}
@Schema(description = "Результат запуска расчета модели комплексного плана")
@JsonIgnoreProperties(ignoreUnknown = true)
data class ComplexPlanCalculationResultDTO(
@get:JsonProperty("status")
@param:JsonProperty("status")
val status: ComplexPlanCalculationStatus,
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("missingSatelliteIds")
@param:JsonProperty("missingSatelliteIds")
val missingSatelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("calculationMode")
@param:JsonProperty("calculationMode")
val calculationMode: DynamicPlanCalculationMode = DynamicPlanCalculationMode.FULL,
@get:JsonProperty("revolutionMode")
@param:JsonProperty("revolutionMode")
val revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH,
@get:JsonProperty("filterCoveredRoutes")
@param:JsonProperty("filterCoveredRoutes")
val filterCoveredRoutes: Boolean = false,
@get:JsonProperty("routesCount")
@param:JsonProperty("routesCount")
val routesCount: Int,
@get:JsonProperty("lastRouteStart")
@param:JsonProperty("lastRouteStart")
val lastRouteStart: LocalDateTime? = null,
@get:JsonProperty("lastRouteEnd")
@param:JsonProperty("lastRouteEnd")
val lastRouteEnd: LocalDateTime? = null,
@get:JsonProperty("coveredAreaPercent")
@param:JsonProperty("coveredAreaPercent")
val coveredAreaPercent: Double? = null,
@get:JsonProperty("durationMs")
@param:JsonProperty("durationMs")
val durationMs: Long
)
@@ -0,0 +1,37 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
@Schema(description = "Задание на расчет контура")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSerialize
data class ContourRequestDTO(
@get:JsonProperty("satelliteId")
@param:JsonProperty("satelliteId")
val satelliteId : Long,
@get:JsonProperty("time")
@param:JsonProperty("time")
val time : LocalDateTime,
@get:JsonProperty("gamma")
@param:JsonProperty("gamma")
val gamma : Double,
@get:JsonProperty("duration")
@param:JsonProperty("duration")
val duration : Double,
@get:JsonProperty("routeWidthKm")
@param:JsonProperty("routeWidthKm")
val routeWidthKm : Double,
@get:JsonProperty("step")
@param:JsonProperty("step")
val step : Double = 1.0
)
@@ -0,0 +1,6 @@
package space.nstart.pcp.complan.types
enum class DynamicPlanCalculationMode {
FULL,
GREEDY
}
@@ -0,0 +1,43 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.util.UUID
@Schema(description = "Интервал расчета динамического плана")
@JsonIgnoreProperties(ignoreUnknown = true)
data class DynamicPlanIntervalDTO(
@get:JsonProperty("id")
@param:JsonProperty("id")
val id: Long,
@get:JsonProperty("runId")
@param:JsonProperty("runId")
val runId: UUID,
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("intervalId")
@param:JsonProperty("intervalId")
val intervalId: UUID,
@get:JsonProperty("regionId")
@param:JsonProperty("regionId")
val regionId: UUID,
@get:JsonProperty("revSign")
@param:JsonProperty("revSign")
val revSign: RevolutionSign,
@get:JsonProperty("contourWkt")
@param:JsonProperty("contourWkt")
val contourWkt: String,
@get:JsonProperty("observationParametersCount")
@param:JsonProperty("observationParametersCount")
val observationParametersCount: Int
)
@@ -0,0 +1,7 @@
package space.nstart.pcp.complan.types
enum class DynamicPlanRevolutionMode {
BOTH,
ASC,
DESC
}
@@ -0,0 +1,51 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
import java.util.UUID
@Schema(description = "Маршрут расчета динамического плана")
@JsonIgnoreProperties(ignoreUnknown = true)
data class DynamicPlanRouteDTO(
@get:JsonProperty("id")
@param:JsonProperty("id")
val id: Long,
@get:JsonProperty("runId")
@param:JsonProperty("runId")
val runId: UUID,
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteId")
@param:JsonProperty("satelliteId")
val satelliteId: Long,
@get:JsonProperty("startTime")
@param:JsonProperty("startTime")
val startTime: LocalDateTime,
@get:JsonProperty("endTime")
@param:JsonProperty("endTime")
val endTime: LocalDateTime,
@get:JsonProperty("duration")
@param:JsonProperty("duration")
val duration: Double,
@get:JsonProperty("revolution")
@param:JsonProperty("revolution")
val revolution: Long,
@get:JsonProperty("roll")
@param:JsonProperty("roll")
val roll: Double,
@get:JsonProperty("contourWkt")
@param:JsonProperty("contourWkt")
val contourWkt: String
)
@@ -0,0 +1,82 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
import java.util.UUID
enum class DynamicPlanRunStatus {
PENDING,
RUNNING,
COMPLETED,
FAILED
}
@Schema(description = "Состояние запуска расчета динамического плана")
@JsonIgnoreProperties(ignoreUnknown = true)
data class DynamicPlanRunDTO(
@get:JsonProperty("runId")
@param:JsonProperty("runId")
val runId: UUID,
@get:JsonProperty("status")
@param:JsonProperty("status")
val status: DynamicPlanRunStatus,
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("calculationMode")
@param:JsonProperty("calculationMode")
val calculationMode: DynamicPlanCalculationMode,
@get:JsonProperty("revolutionMode")
@param:JsonProperty("revolutionMode")
val revolutionMode: DynamicPlanRevolutionMode,
@get:JsonProperty("filterCoveredRoutes")
@param:JsonProperty("filterCoveredRoutes")
val filterCoveredRoutes: Boolean,
@get:JsonProperty("createdAt")
@param:JsonProperty("createdAt")
val createdAt: LocalDateTime,
@get:JsonProperty("startedAt")
@param:JsonProperty("startedAt")
val startedAt: LocalDateTime?,
@get:JsonProperty("finishedAt")
@param:JsonProperty("finishedAt")
val finishedAt: LocalDateTime?,
@get:JsonProperty("durationMs")
@param:JsonProperty("durationMs")
val durationMs: Long?,
@get:JsonProperty("resultStatus")
@param:JsonProperty("resultStatus")
val resultStatus: String?,
@get:JsonProperty("routesCount")
@param:JsonProperty("routesCount")
val routesCount: Int?,
@get:JsonProperty("errorMessage")
@param:JsonProperty("errorMessage")
val errorMessage: String?
)
@@ -0,0 +1,136 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.databind.ObjectMapper
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.LineString
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.LinearRing
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.geom.Point
import java.io.File
import kotlin.collections.forEach
import kotlin.math.PI
class GeoJson {
data class GeometryGeoJson(val type: String, val coordinates: Any)
data class Feature(val name: String, val bbox: List<Double>? = null, val type: String, val geometry: GeometryGeoJson, val properties: Any)
data class FeatureCollection(val type: String, val features: List<Feature>)
private fun multipolygonToGeojsonCoord(multipolygon: MultiPolygon, radToDeg : Boolean = false): List<List<List<Array<Double>>>> {
return (0 until multipolygon.numGeometries).map{i->
val polygon = multipolygon.getGeometryN(i) as Polygon
polygonToGeojsonCoord(polygon, radToDeg)
}
}
private fun polygonToGeojsonCoord(polygon: Polygon, radToDeg : Boolean = false): List<List<Array<Double>>> {
val coordinates = mutableListOf<List<Array<Double>>>()
coordinates.add(linearRingToCoord(polygon.exteriorRing, radToDeg))
(0 until polygon.numInteriorRing).forEach { i ->
coordinates.add(linearRingToCoord(polygon.getInteriorRingN(i), radToDeg))
}
return coordinates
}
private fun linearRingToCoord(ring: LinearRing, radToDeg : Boolean = false): List<Array<Double>> {
if (radToDeg) {
return ring.coordinates.map{ coordinate ->
listOf(coordinate.x * 180 / PI, coordinate.y * 180 / PI).toTypedArray()
}
} else {
return ring.coordinates.map{ coordinate ->
listOf(coordinate.x, coordinate.y).toTypedArray()
}
}
}
fun getBBox(geometry: Geometry): List<Double>{
val bbox = geometry.envelopeInternal
return listOf(
bbox.minX,
bbox.maxY,
bbox.maxX,
bbox.minY,
)
}
/**
* Конвертация из Geometry в формат geoJSON
* @param geometry исходная геометрия
* @return Возвращает конвертированную геометрию для записи в формате geoGSON
*/
fun convertToGeoJson(geometry: Geometry): GeometryGeoJson {
return when (geometry) {
is Point -> GeometryGeoJson(type = "Point", coordinates = listOf( geometry.x, geometry.y ))
is LineString -> GeometryGeoJson(type = "LineString", coordinates = geometry.coordinates.map{arrayOf( it.x, it.y )})
is Polygon -> GeometryGeoJson(type = "Polygon", coordinates = polygonToGeojsonCoord(geometry))
is MultiPolygon -> GeometryGeoJson(type = "MultiPolygon", coordinates = multipolygonToGeojsonCoord(geometry))
else -> throw IllegalArgumentException("Unknown geometry type: $geometry")
}
}
fun convertToGeoJsonRadToDeg(geometry: Geometry): GeometryGeoJson {
return when (geometry) {
is Point -> GeometryGeoJson(type = "Point", coordinates = listOf( radToDeg(geometry.x), (geometry.y) ))
is LineString -> GeometryGeoJson(type = "LineString", coordinates = geometry.coordinates.map{arrayOf( radToDeg(it.x), radToDeg(it.y) )})
is Polygon -> GeometryGeoJson(type = "Polygon", coordinates = polygonToGeojsonCoord(geometry, radToDeg = true))
is MultiPolygon -> GeometryGeoJson(type = "MultiPolygon", coordinates = multipolygonToGeojsonCoord(geometry, radToDeg = true))
else -> throw IllegalArgumentException("Unknown geometry type: $geometry")
}
}
fun radToDeg(value : Double): Double {
return value * 180 / PI
}
companion object {
fun createFeatureCollection(
geoms: List<GeoJsonItem>,
fileName: String,
radToDeg: Boolean = false
): FeatureCollection {
val gj = GeoJson()
val features = geoms.map { geom ->
Feature(
name = fileName,
bbox = gj.getBBox(geom.geometry),
type = "Feature",
geometry = if (radToDeg) {
gj.convertToGeoJsonRadToDeg(geom.geometry)
} else {
gj.convertToGeoJson(geom.geometry)
},
properties = geom.properties
)
}
return FeatureCollection(type = "FeatureCollection", features = features)
}
/**
* Создать geoJSON.
* Файлы geoJson хранятся в папке ресурсов рядом с картой в папке geoJson.
* Названия **AprioryScans, Catalog, FactScans, Region** соответственно.
* @param geom массив элементов [GeoJsonItem], содержащий геометрию и свойства слоя при необходимости
* @param outputPath целевой путь сохранения файла
*/
fun createGeoJson(geoms: List<GeoJsonItem>, fileName: String, dirToSave: String, radToDeg: Boolean = false) {
val objectMapper = ObjectMapper()
val geoJsonString = objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(createFeatureCollection(geoms, fileName, radToDeg))
val dataDir = File(dirToSave)
if(!dataDir.exists()){
dataDir.mkdirs()
}
val path = "$fileName.json"
val file = File(dataDir, path)
file.writeText(geoJsonString)
}
}
}
@@ -0,0 +1,210 @@
package space.nstart.pcp.complan.types
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.LineString
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
data class GeoJsonItem(
val geometry: Geometry,
val properties: Map<String, Any>,
){
companion object {
// /**
// * Создание слоя интервалов с атрибутивной информацией
// * @param
// * @return
// */
// fun createIntervalsLay(ka: KaPass): List<GeoJsonItem> {
// val rez = mutableListOf<GeoJsonItem>()
// for (view in ka.views) {
// var unionGeom: Geometry? = view.matPlan.interval.geometry//.contour[0].geometry
//
// if (unionGeom == null) continue
// val resItem = GeoJsonItem(
// geometry = unionGeom,//, //.geometryToDeg(),
// properties = mapOf(
// "Аппарат" to "${ka.ka.kaId}",
// "Время нач." to view.matPlan.tMinLDT.toString(),//.toDateString(),
// "Время кон." to view.matPlan.tMaxLDT.toString(),//.toDateString(),
// "Виток" to view.matPlan.vit,
// "Регион" to "${view.matPlan.interval.obj.reqRegionId}",
// )
// )
// rez.add(resItem)
// }
//
// return rez
// }
/**
* Создание слоя интервалов с атрибутивной информацией
* @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией
*/
fun createIntervalsLay(interval: List<IntervalItemDTO>): List<GeoJsonItem> {
val rez = mutableListOf<GeoJsonItem>()
for(interval in interval) {
if(WKTReader().read(interval.geometry).getGeometryN(0).coordinates.first() != WKTReader().read(interval.geometry).getGeometryN(0).coordinates.last())
continue
val resItem = GeoJsonItem(
geometry = WKTReader().read(interval.geometry),
properties = mapOf(
// "№" to "${interval.num}",
// "Region" to "${interval.regionId}",
// "Interval" to "${interval.id}",
"mpl" to getMplStringForLay(interval.mpl)
// "mpl" to interval.mpl.map { "[kaid ${it.noradId}, rev ${it.revolutionBegin}, time ${it.timeBegin}]" },
// "mpl" to "${interval.mpl}",
)
)
rez.add(resItem)
}
return rez
}
/**
* Формирование атрибутивной информации по матрице планирования
* @return Возвращает строку с перечнем прохождений КА в минималистическом формате
* КА[[виток время][..]..]
*/
fun getMplStringForLay(mpl : List<SquareViewParamDTO>) : String {
val kaList = mutableSetOf<Long>()//.addAll(mpl.map { it.noradId })
mpl.sortedBy { it.timeBegin }
mpl.forEach {
kaList.add(it.noradId)
}
val res = StringBuilder()
val kaListTmp = kaList.sortedBy { it }
kaListTmp.forEach { kaId ->
val str = mpl.filter { it.noradId == kaId }.sortedBy { it.timeBegin }.map { "[rev ${it.revolutionBegin}, time ${it.timeBegin}, gamma ${(it.gammaMin + it.gammaMax)/2}, gammaMin ${it.gammaMin}, gammaMax ${it.gammaMax}]" }
res.append("noradId:$kaId")
res.append("[")
str.forEach { res.append(it) }
res.append("]")
}
return res.toString()
}
/**
* Создание слоя маршрутов с атрибутивной информацией
* @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией
*/
fun createSurveyLay(surveys: List<SurveyContour>): List<GeoJsonItem> {
val rez = mutableListOf<GeoJsonItem>()
for(survey in surveys) {
val geometry = WKTReader().read(survey.geometry)
if (geometry !is Polygon) { continue }
if(geometry.getGeometryN(0).coordinates.first() != geometry.getGeometryN(0).coordinates.last())
continue
val resItem = GeoJsonItem(
geometry = geometry,
properties = mapOf(
"noradId" to survey.mpl.noradId,
"rev" to survey.rev,
"gamma" to survey.gamma,
"time" to survey.time.toString(),
"duration" to survey.duration,
// "mpl" to survey.mpl.toString(),
)
)
rez.add(resItem)
}
return rez
}
/**
* Создание слоя с атрибутивной информацией
* @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией
*/
fun createAnyLay(pols: List<Polygon>): MutableList<GeoJsonItem> {
val rez = mutableListOf<GeoJsonItem>()
for (pol in pols) {
val resItem = GeoJsonItem(
geometry = pol,//,
properties = mapOf()
)
rez.add(resItem)
}
return rez
}
/**
* Создание слоя с атрибутивной информацией
* @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией
*/
fun createRequestLay(requests: List<RequestItemDTO>): List<GeoJsonItem> {
val rez = mutableListOf<GeoJsonItem>()
for (item in requests) {
val resItem = GeoJsonItem(
geometry = WKTReader().read(item.geometry),//,
properties = mapOf(
"UUID" to "${item.id}",
"Name" to "${item.name}",
"Interval_begin" to "${item.intervalBegin}",
"Interval_end" to "${item.intervalEnd}",
"Importance" to "${item.importance}",
"Priority" to "${item.priority}",
"Resulution" to "${item.resolution}",
)
)
rez.add(resItem)
}
return rez
}
/**
* Создание слоя с атрибутивной информацией
* @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией
*/
fun createAplCatalogLay(aplCatalog: List<AplObjItem>): List<GeoJsonItem> {
val rez = mutableListOf<GeoJsonItem>()
for (item in aplCatalog) {
val resItem = GeoJsonItem(
geometry = item.geometry,
properties = mapOf(
"UUID" to "${item.id}",
"Name" to "${item.name}",
// "Interval_begin" to "${item.intervalBegin}",
// "Interval_end" to "${item.intervalEnd}",
// "Importance" to "${item.importance}",
// "Priority" to "${item.priority}",
// "Resulution" to "${item.resolution}",
)
)
rez.add(resItem)
}
return rez
}
/**
* Создание слой для отображения из LineString
* @return Возвращает заполненный [GeoJsonItem] с геометрией контура и атрибутивной информацией
*/
fun createLineStringLay(lines: List<LineString>): MutableList<GeoJsonItem> {
val rez = mutableListOf<GeoJsonItem>()
for (item in lines) {
val resItem = GeoJsonItem(
geometry = item,//,
properties = mapOf()
)
rez.add(resItem)
}
return rez
}
}
}
@@ -0,0 +1,163 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDate
import java.time.LocalDateTime
import java.util.UUID
@Schema(description = "Debug-запрос расчета greedy-математики по сохраненным параметрам наблюдения")
data class GreedyMathDebugRequestDTO(
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("revolutionMode")
@param:JsonProperty("revolutionMode")
val revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH,
@get:JsonProperty("filterCoveredRoutes")
@param:JsonProperty("filterCoveredRoutes")
val filterCoveredRoutes: Boolean = false
)
@Schema(description = "Выбранное greedy-включение без геометрии маршрута")
@JsonIgnoreProperties(ignoreUnknown = true)
data class GreedyMathCandidateDTO(
@get:JsonProperty("intervalId")
@param:JsonProperty("intervalId")
val intervalId: UUID,
@get:JsonProperty("regionId")
@param:JsonProperty("regionId")
val regionId: UUID,
@get:JsonProperty("satelliteId")
@param:JsonProperty("satelliteId")
val satelliteId: Long,
@get:JsonProperty("revolution")
@param:JsonProperty("revolution")
val revolution: Long,
@get:JsonProperty("day")
@param:JsonProperty("day")
val day: LocalDate,
@get:JsonProperty("timeBegin")
@param:JsonProperty("timeBegin")
val timeBegin: LocalDateTime,
@get:JsonProperty("timeEnd")
@param:JsonProperty("timeEnd")
val timeEnd: LocalDateTime,
@get:JsonProperty("durationSeconds")
@param:JsonProperty("durationSeconds")
val durationSeconds: Long,
@get:JsonProperty("gamma")
@param:JsonProperty("gamma")
val gamma: Double
)
@Schema(description = "Debug-результат greedy-математики по сохраненным параметрам наблюдения")
@JsonIgnoreProperties(ignoreUnknown = true)
data class GreedyMathDebugResultDTO(
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("missingSatelliteIds")
@param:JsonProperty("missingSatelliteIds")
val missingSatelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("status")
@param:JsonProperty("status")
val status: ComplexPlanCalculationStatus,
@get:JsonProperty("revolutionMode")
@param:JsonProperty("revolutionMode")
val revolutionMode: DynamicPlanRevolutionMode,
@get:JsonProperty("observationParametersFilePath")
@param:JsonProperty("observationParametersFilePath")
val observationParametersFilePath: String? = null,
@get:JsonProperty("intervalsCount")
@param:JsonProperty("intervalsCount")
val intervalsCount: Int,
@get:JsonProperty("observationParametersCount")
@param:JsonProperty("observationParametersCount")
val observationParametersCount: Int,
@get:JsonProperty("selectedCandidatesCount")
@param:JsonProperty("selectedCandidatesCount")
val selectedCandidatesCount: Int,
@get:JsonProperty("failuresCount")
@param:JsonProperty("failuresCount")
val failuresCount: Int,
@get:JsonProperty("selectedCandidates")
@param:JsonProperty("selectedCandidates")
val selectedCandidates: List<GreedyMathCandidateDTO>,
@get:JsonProperty("filterCoveredRoutes")
@param:JsonProperty("filterCoveredRoutes")
val filterCoveredRoutes: Boolean,
@get:JsonProperty("routesBeforeUsefulAreaFilterCount")
@param:JsonProperty("routesBeforeUsefulAreaFilterCount")
val routesBeforeUsefulAreaFilterCount: Int,
@get:JsonProperty("routesAfterUsefulAreaFilterCount")
@param:JsonProperty("routesAfterUsefulAreaFilterCount")
val routesAfterUsefulAreaFilterCount: Int,
@get:JsonProperty("routeBuildFailuresCount")
@param:JsonProperty("routeBuildFailuresCount")
val routeBuildFailuresCount: Int,
@get:JsonProperty("greedySelectionMs")
@param:JsonProperty("greedySelectionMs")
val greedySelectionMs: Long,
@get:JsonProperty("routeBuildMs")
@param:JsonProperty("routeBuildMs")
val routeBuildMs: Long,
@get:JsonProperty("usefulAreaAnalysisMs")
@param:JsonProperty("usefulAreaAnalysisMs")
val usefulAreaAnalysisMs: Long,
@get:JsonProperty("totalMs")
@param:JsonProperty("totalMs")
val totalMs: Long
)
@@ -0,0 +1,34 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.swagger.v3.oas.annotations.media.Schema
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.util.*
@Schema(description = "Интервал")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSerialize
data class IntervalItemDTO(
@get:JsonProperty("id")
@param:JsonProperty("id")
val id : UUID = UUID.randomUUID(),
@get:JsonProperty("regionId")
@param:JsonProperty("regionId")
val regionId : UUID,
@get:JsonProperty("revSign")
@param:JsonProperty("revSign")
val revSign : RevolutionSign,
@get:JsonProperty("geometry")
@param:JsonProperty("geometry")
val geometry: String,
@get:JsonProperty("mpl")
@param:JsonProperty("mpl")
val mpl: MutableList<SquareViewParamDTO> = mutableListOf()
)
@@ -0,0 +1,25 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.swagger.v3.oas.annotations.media.Schema
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import java.util.UUID
@Schema(description = "Модель данных объекта для расчета наблюдения")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSerialize
data class ObjDTO(
@get:JsonProperty("id")
@param:JsonProperty("id")
val id: UUID,
@get:JsonProperty("position")
@param:JsonProperty("position")
val position: PositionDTO? = null,
@get:JsonProperty("contourWKT")
@param:JsonProperty("contourWKT")
val contourWKT: String? = null
)
@@ -0,0 +1,32 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
@Schema(description = "Запрос расчета параметров наблюдения для набора объектов")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSerialize
data class ObjViewRequestDTO(
@get:JsonProperty("sunAngleMin")
@param:JsonProperty("sunAngleMin")
val sunAngleMin: Double? = null,
@get:JsonProperty("objects")
@param:JsonProperty("objects")
val objects: List<ObjDTO>,
@get:JsonProperty("satellites")
@param:JsonProperty("satellites")
val satellites: Iterable<Long>? = null,
@get:JsonProperty("timeStart")
@param:JsonProperty("timeStart")
val timeStart: LocalDateTime? = null,
@get:JsonProperty("timeStop")
@param:JsonProperty("timeStop")
val timeStop: LocalDateTime? = null
)
@@ -0,0 +1,164 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
@Schema(description = "Debug-запрос построения интервалов и расчета параметров наблюдения")
@JsonIgnoreProperties(ignoreUnknown = true)
data class ObservationParametersDebugRequestDTO(
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("parallelBallisticsCalculation")
@param:JsonProperty("parallelBallisticsCalculation")
val parallelBallisticsCalculation: Boolean = false,
@get:JsonProperty("chunkSize")
@param:JsonProperty("chunkSize")
val chunkSize: Int? = null,
@get:JsonProperty("parallelism")
@param:JsonProperty("parallelism")
val parallelism: Int? = null,
@get:JsonProperty("saveResultToFile")
@param:JsonProperty("saveResultToFile")
val saveResultToFile: Boolean = false
)
@Schema(description = "Debug-результат построения интервалов и расчета параметров наблюдения")
@JsonIgnoreProperties(ignoreUnknown = true)
data class ObservationParametersDebugResultDTO(
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("missingSatelliteIds")
@param:JsonProperty("missingSatelliteIds")
val missingSatelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("status")
@param:JsonProperty("status")
val status: ComplexPlanCalculationStatus,
@get:JsonProperty("parallelBallisticsCalculation")
@param:JsonProperty("parallelBallisticsCalculation")
val parallelBallisticsCalculation: Boolean,
@get:JsonProperty("intervalsCount")
@param:JsonProperty("intervalsCount")
val intervalsCount: Int,
@get:JsonProperty("observationParametersCount")
@param:JsonProperty("observationParametersCount")
val observationParametersCount: Int,
@get:JsonProperty("chunksCount")
@param:JsonProperty("chunksCount")
val chunksCount: Int,
@get:JsonProperty("intervalsMs")
@param:JsonProperty("intervalsMs")
val intervalsMs: Long,
@get:JsonProperty("observationParametersMs")
@param:JsonProperty("observationParametersMs")
val observationParametersMs: Long,
@get:JsonProperty("totalMs")
@param:JsonProperty("totalMs")
val totalMs: Long,
@get:JsonProperty("savedToFile")
@param:JsonProperty("savedToFile")
val savedToFile: Boolean = false,
@get:JsonProperty("resultFilePath")
@param:JsonProperty("resultFilePath")
val resultFilePath: String? = null
)
@Schema(description = "Файловый артефакт debug-расчета интервалов и параметров наблюдения")
@JsonIgnoreProperties(ignoreUnknown = true)
data class ObservationParametersDebugFileDTO(
@get:JsonProperty("schemaVersion")
@param:JsonProperty("schemaVersion")
val schemaVersion: Int = 1,
@get:JsonProperty("createdAt")
@param:JsonProperty("createdAt")
val createdAt: Instant,
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("missingSatelliteIds")
@param:JsonProperty("missingSatelliteIds")
val missingSatelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("parallelBallisticsCalculation")
@param:JsonProperty("parallelBallisticsCalculation")
val parallelBallisticsCalculation: Boolean,
@get:JsonProperty("chunkSize")
@param:JsonProperty("chunkSize")
val chunkSize: Int?,
@get:JsonProperty("parallelism")
@param:JsonProperty("parallelism")
val parallelism: Int?,
@get:JsonProperty("intervalsCount")
@param:JsonProperty("intervalsCount")
val intervalsCount: Int,
@get:JsonProperty("observationParametersCount")
@param:JsonProperty("observationParametersCount")
val observationParametersCount: Int,
@get:JsonProperty("intervals")
@param:JsonProperty("intervals")
val intervals: List<IntervalItemDTO>
)
@@ -0,0 +1,73 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
import java.util.UUID
@Schema(description = "Заявка для расчета комплексного плана")
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonSerialize
data class RequestItemDTO(
@get:JsonProperty("id")
@param:JsonProperty("id")
val id : UUID = UUID.randomUUID(),
@get:JsonProperty("name")
@param:JsonProperty("name")
val name : String,
@get:JsonProperty("originalGeometry")
@param:JsonProperty("originalGeometry")
@param:JsonAlias("geometry", "remainingGeometry")
val geometry : String,
@get:JsonProperty("intervalBegin")
@param:JsonProperty("intervalBegin")
val intervalBegin : LocalDateTime,
@get:JsonProperty("intervalEnd")
@param:JsonProperty("intervalEnd")
val intervalEnd : LocalDateTime,
@get:JsonProperty("resolution")
@param:JsonProperty("resolution")
val resolution : Double,
@get:JsonProperty("priority")
@param:JsonProperty("priority")
val priority : Int? = null,
@get:JsonProperty("importance")
@param:JsonProperty("importance")
val importance : Double,
/**Исходная площадь района.
* Используется для расчета важности задания */
@get:JsonProperty("baseArea")
@param:JsonProperty("baseArea")
var baseArea : Double? = null,
/**Текущая площадь района.
* Используется для расчета важности задания */
@get:JsonProperty("currentArea")
@param:JsonProperty("currentArea")
var currentArea : Double? = null,
/**Дата время последнего наблюдения.
* Используется для расчета важности задания */
@get:JsonProperty("dateTimeLastObservation")
@param:JsonProperty("dateTimeLastObservation")
var dateTimeLastObservation : LocalDateTime? = null,
@get:JsonProperty("appType")
@param:JsonProperty("appType")
val appType : Int = 0,
@get:JsonProperty("intervals")
@param:JsonProperty("intervals")
val intervals : MutableList<IntervalItemDTO>? = null,
)
@@ -0,0 +1,13 @@
package space.nstart.pcp.complan.types
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.time.LocalDateTime
data class SurveyContour(
val mpl : SquareViewParamDTO,
val time : LocalDateTime,
val duration : Double,
val rev : Long,
val gamma : Double,
val geometry : String,
)
@@ -0,0 +1,86 @@
package space.nstart.pcp.complan.types
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime
import java.util.UUID
@Schema(description = "Debug-запрос построения маршрутов по всем параметрам видимости из файла")
data class TestCalcMarsDebugRequestDTO(
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime
)
@Schema(description = "Debug-результат построения маршрутов по всем параметрам видимости из файла")
@JsonIgnoreProperties(ignoreUnknown = true)
data class TestCalcMarsDebugResultDTO(
@get:JsonProperty("requestId")
@param:JsonProperty("requestId")
val requestId: UUID,
@get:JsonProperty("satelliteIds")
@param:JsonProperty("satelliteIds")
val satelliteIds: List<Long>,
@get:JsonProperty("missingSatelliteIds")
@param:JsonProperty("missingSatelliteIds")
val missingSatelliteIds: List<Long>,
@get:JsonProperty("calculationStart")
@param:JsonProperty("calculationStart")
val calculationStart: LocalDateTime,
@get:JsonProperty("calculationEnd")
@param:JsonProperty("calculationEnd")
val calculationEnd: LocalDateTime,
@get:JsonProperty("status")
@param:JsonProperty("status")
val status: ComplexPlanCalculationStatus,
@get:JsonProperty("observationParametersFilePath")
@param:JsonProperty("observationParametersFilePath")
val observationParametersFilePath: String? = null,
@get:JsonProperty("intervalsCount")
@param:JsonProperty("intervalsCount")
val intervalsCount: Int,
@get:JsonProperty("observationParametersCount")
@param:JsonProperty("observationParametersCount")
val observationParametersCount: Int,
@get:JsonProperty("routeBuildAttemptsCount")
@param:JsonProperty("routeBuildAttemptsCount")
val routeBuildAttemptsCount: Int,
@get:JsonProperty("routesCount")
@param:JsonProperty("routesCount")
val routesCount: Int,
@get:JsonProperty("routeBuildFailuresCount")
@param:JsonProperty("routeBuildFailuresCount")
val routeBuildFailuresCount: Int,
@get:JsonProperty("routeBuildMs")
@param:JsonProperty("routeBuildMs")
val routeBuildMs: Long,
@get:JsonProperty("totalMs")
@param:JsonProperty("totalMs")
val totalMs: Long
)
@@ -0,0 +1,19 @@
package space.nstart.pcp.complan.utils.extensions
import org.locationtech.jts.geom.CoordinateFilter
import org.locationtech.jts.geom.Geometry
import kotlin.math.PI
fun Geometry.radiansToDegrees(): Geometry {
// Создаём копию, чтобы не мутировать оригинал
val copy = this.copy() as Geometry
// Применяем фильтр, который конвертирует радианы → градусы
copy.apply(CoordinateFilter { coord ->
coord.x = coord.x * 180.0 / PI // долгота
coord.y = coord.y * 180.0 / PI // широта
})
return copy
}
@@ -0,0 +1,10 @@
package space.nstart.pcp.complan.utils.extensions
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
fun RevolutionSign.toInt() : Int {
return when (this) {
RevolutionSign.ASC -> 0
RevolutionSign.DESC -> 1
}
}
@@ -0,0 +1,78 @@
spring:
application:
name: pcp-dynamic-plan-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.100.160:38888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
# codec:
# max-in-memory-size: 16MB
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.60.68:5432/complan
username: postgres
password: password
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: false
jdbc:
batch_size: 40
order_inserts: true
order_updates: true
feign:
client:
config:
default:
connectTimeout: 5000
readTimeout: 30000
loggerLevel: basic
flyway:
enabled: true
locations: classpath:db/migration/pcp-complex-plan
baseline-on-migrate: true
ignore-migration-patterns: "*:missing"
baseline-version: 0
validate-on-migrate: true
repair-on-migrate: true
clean-disabled: true
out-of-order: true
schemas: public
logging:
level:
org.flywaydb: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.springframework.jdbc.core: TRACE
springdoc:
swagger-ui:
enabled: true
layout: BaseLayout
path: /swagger/ui
api-docs:
enabled: true
path: /v3/api-docs
complex-plan:
observation-parameters:
parallel-enabled: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_PARALLEL_ENABLED:true}
chunk-size: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_CHUNK_SIZE:20}
parallelism: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_PARALLELISM:8}
debug-output-dir: ${COMPLEX_PLAN_OBSERVATION_PARAMETERS_DEBUG_OUTPUT_DIR:${java.io.tmpdir}/pcp-dynamic-plan-service/obsParams}
geo-layers:
enabled: false
output-dir: ${java.io.tmpdir}/pcp-dynamic-plan-service/geo-layers
@@ -0,0 +1 @@
SELECT 'Database initialization complete' as status;
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS aplsettings (
id SERIAL PRIMARY KEY, --серийный ключ
settings TEXT --настройки автомата в виде JSON
);
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS aplsettings (
id SERIAL PRIMARY KEY, --серийный ключ
settings TEXT --настройки автомата в виде JSON
);
@@ -0,0 +1,41 @@
create table if not exists dynamic_plan_run
(
id uuid primary key,
status varchar(32) not null,
request_id uuid not null,
satellite_ids text not null,
calculation_start timestamp not null,
calculation_end timestamp not null,
created_at timestamp not null,
started_at timestamp,
finished_at timestamp,
duration_ms bigint,
result_status varchar(64),
routes_count integer,
result_json text,
error_message text
);
create index if not exists idx_dynamic_plan_run_status on dynamic_plan_run(status);
create index if not exists idx_dynamic_plan_run_request_id on dynamic_plan_run(request_id);
create table if not exists dynamic_plan_route
(
id bigserial primary key,
run_id uuid not null references dynamic_plan_run(id) on delete cascade,
request_id uuid not null,
satellite_id bigint not null,
start_time timestamp not null,
end_time timestamp not null,
duration double precision not null,
revolution bigint not null,
roll double precision not null,
contour_wkt text not null,
route_order integer not null
);
create index if not exists idx_dynamic_plan_route_run_id on dynamic_plan_route(run_id);
create index if not exists idx_dynamic_plan_route_request_id on dynamic_plan_route(request_id);
create index if not exists idx_dynamic_plan_route_satellite_id on dynamic_plan_route(satellite_id);
create index if not exists idx_dynamic_plan_route_start_time on dynamic_plan_route(start_time);
create index if not exists idx_dynamic_plan_route_run_start on dynamic_plan_route(run_id, start_time);
@@ -0,0 +1,2 @@
alter table dynamic_plan_run
add column if not exists calculation_mode varchar(32) not null default 'FULL';
@@ -0,0 +1,2 @@
alter table dynamic_plan_run
add column if not exists revolution_mode varchar(32) not null default 'BOTH';
@@ -0,0 +1,2 @@
alter table dynamic_plan_run
add column if not exists filter_covered_routes boolean not null default false;
@@ -0,0 +1,2 @@
alter table dynamic_plan_run
add column if not exists min_useful_route_area_km2 double precision not null default 0.0;
@@ -0,0 +1,17 @@
create table if not exists dynamic_plan_interval
(
id bigserial primary key,
run_id uuid not null references dynamic_plan_run(id) on delete cascade,
request_id uuid not null,
interval_id uuid not null,
region_id uuid not null,
rev_sign varchar(16) not null,
contour_wkt text not null,
observation_parameters_count integer not null,
interval_order integer not null
);
create index if not exists idx_dynamic_plan_interval_run_id on dynamic_plan_interval(run_id);
create index if not exists idx_dynamic_plan_interval_request_id on dynamic_plan_interval(request_id);
create index if not exists idx_dynamic_plan_interval_region_id on dynamic_plan_interval(region_id);
create index if not exists idx_dynamic_plan_interval_run_order on dynamic_plan_interval(run_id, interval_order);
@@ -0,0 +1,132 @@
package space.nstart.pcp.complan
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ComPlanControllerTest {
@Test
fun `exposes calculation run endpoints`() {
val mappedMethods = ComPlanController::class.java.declaredMethods
.filter { method ->
listOf(
GetMapping::class.java,
PostMapping::class.java,
PutMapping::class.java,
PatchMapping::class.java,
DeleteMapping::class.java,
RequestMapping::class.java
).any { annotation -> method.isAnnotationPresent(annotation) }
}
.map { it.name }
.sorted()
assertEquals(
listOf(
"calcPlan",
"debugGreedyMath",
"debugObservationParameters",
"getCalculation",
"getCalculationIntervals",
"getCalculationResult",
"getCalculationRoutes",
"getCalculations",
"testCalcMars"
),
mappedMethods
)
}
@Test
fun `calcPlan is exposed as POST endpoint`() {
val calcPlanMethod = ComPlanController::class.java.getDeclaredMethod(
"calcPlan",
space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO::class.java
)
assertNotNull(calcPlanMethod.getAnnotation(PostMapping::class.java))
assertNull(calcPlanMethod.getAnnotation(GetMapping::class.java))
}
@Test
fun `observation parameters debug endpoint is exposed as POST endpoint`() {
val debugMethod = ComPlanController::class.java.getDeclaredMethod(
"debugObservationParameters",
space.nstart.pcp.complan.types.ObservationParametersDebugRequestDTO::class.java
)
assertNotNull(debugMethod.getAnnotation(PostMapping::class.java))
assertNull(debugMethod.getAnnotation(GetMapping::class.java))
}
@Test
fun `greedy math debug endpoint is exposed as POST endpoint`() {
val debugMethod = ComPlanController::class.java.getDeclaredMethod(
"debugGreedyMath",
space.nstart.pcp.complan.types.GreedyMathDebugRequestDTO::class.java
)
assertNotNull(debugMethod.getAnnotation(PostMapping::class.java))
assertNull(debugMethod.getAnnotation(GetMapping::class.java))
}
@Test
fun `test calc mars debug endpoint is exposed as POST endpoint`() {
val debugMethod = ComPlanController::class.java.getDeclaredMethod(
"testCalcMars",
space.nstart.pcp.complan.types.TestCalcMarsDebugRequestDTO::class.java
)
assertNotNull(debugMethod.getAnnotation(PostMapping::class.java))
assertNull(debugMethod.getAnnotation(GetMapping::class.java))
}
@Test
fun `calculation status and result are exposed as GET endpoints`() {
val statusMethod = ComPlanController::class.java.getDeclaredMethod(
"getCalculation",
java.util.UUID::class.java
)
val resultMethod = ComPlanController::class.java.getDeclaredMethod(
"getCalculationResult",
java.util.UUID::class.java
)
val routesMethod = ComPlanController::class.java.getDeclaredMethod(
"getCalculationRoutes",
java.util.UUID::class.java,
Int::class.java,
Int::class.java
)
val intervalsMethod = ComPlanController::class.java.getDeclaredMethod(
"getCalculationIntervals",
java.util.UUID::class.java,
Int::class.java,
Int::class.java
)
val runsMethod = ComPlanController::class.java.getDeclaredMethod(
"getCalculations",
space.nstart.pcp.complan.types.DynamicPlanRunStatus::class.java,
Int::class.java,
Int::class.java
)
assertNotNull(statusMethod.getAnnotation(GetMapping::class.java))
assertNotNull(resultMethod.getAnnotation(GetMapping::class.java))
assertNotNull(routesMethod.getAnnotation(GetMapping::class.java))
assertNotNull(intervalsMethod.getAnnotation(GetMapping::class.java))
assertNotNull(runsMethod.getAnnotation(GetMapping::class.java))
assertNull(statusMethod.getAnnotation(PostMapping::class.java))
assertNull(resultMethod.getAnnotation(PostMapping::class.java))
assertNull(routesMethod.getAnnotation(PostMapping::class.java))
assertNull(intervalsMethod.getAnnotation(PostMapping::class.java))
assertNull(runsMethod.getAnnotation(PostMapping::class.java))
}
}
@@ -0,0 +1,457 @@
package space.nstart.pcp.complan.apl
import org.locationtech.jts.io.WKTReader
import space.nstart.pcp.complan.settings.SettingsDTO
import space.nstart.pcp.complan.spacecraft.SCConstraints
import space.nstart.pcp.complan.spacecraft.SpaceCraft
import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode
import space.nstart.pcp.complan.types.IntervalItemDTO
import space.nstart.pcp.complan.types.SurveyContour
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 java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
class BasicPlanCalculatorTest {
@Test
fun `selects only satellites present in catalog`() {
val result = selectSatellitesForCalculation(
requestedSatelliteIds = listOf(56756L, 62138L, 99999L),
catalogSatelliteIds = listOf(56756L, 62138L)
)
assertEquals(listOf(56756L, 62138L), result.existingIds)
assertEquals(listOf(99999L), result.missingIds)
}
@Test
fun `keeps requested order and removes duplicates`() {
val result = selectSatellitesForCalculation(
requestedSatelliteIds = listOf(62138L, 56756L, 62138L),
catalogSatelliteIds = listOf(56756L, 62138L)
)
assertEquals(listOf(62138L, 56756L), result.existingIds)
assertEquals(emptyList(), result.missingIds)
}
@Test
fun `returns all requested satellites as missing when catalog has none of them`() {
val result = selectSatellitesForCalculation(
requestedSatelliteIds = listOf(1L, 2L, 3L),
catalogSatelliteIds = listOf(56756L, 62138L)
)
assertEquals(emptyList(), result.existingIds)
assertEquals(listOf(1L, 2L, 3L), result.missingIds)
}
@Test
fun `route build uses all available MPL items sorted by gamma`() {
val first = mplItem(noradId = 1L, gammaMin = -12.0)
val second = mplItem(noradId = 2L, gammaMin = 4.0)
val third = mplItem(noradId = 3L, gammaMin = -8.0)
val result = selectMplItemsForRouteBuild(
mplItems = listOf(first, second, third),
settings = SettingsDTO()
)
assertEquals(listOf(second, third, first), result)
}
@Test
fun `route build keeps existing gamma and revolution filters`() {
val ascInGamma = mplItem(noradId = 1L, gammaMin = 3.0, gammaMax = 5.0, revSignBegin = RevolutionSign.ASC)
val descInGamma = mplItem(noradId = 2L, gammaMin = 2.0, gammaMax = 4.0, revSignBegin = RevolutionSign.DESC)
val ascOutOfGamma = mplItem(noradId = 3L, gammaMin = 20.0, gammaMax = 24.0, revSignBegin = RevolutionSign.ASC)
val result = selectMplItemsForRouteBuild(
mplItems = listOf(ascOutOfGamma, descInGamma, ascInGamma),
settings = SettingsDTO(
filterByGamma = true,
gammaValue = 10.0,
filterByRevolution = true,
revolutionType = 0
)
)
assertEquals(listOf(ascInGamma), result)
}
@Test
fun `greedy route selection builds one route per interval`() {
val interval = interval(
mplItem(noradId = 1L, gammaMin = 2.0),
mplItem(noradId = 2L, gammaMin = 1.0)
)
val result = selectGreedyRouteCandidates(
intervals = listOf(interval),
settings = SettingsDTO()
)
assertEquals(1, result.selectedCandidates.size)
assertEquals(interval.id, result.selectedCandidates.single().intervalId)
assertEquals(2L, result.selectedCandidates.single().satelliteId)
assertEquals(emptyList(), result.failures)
}
@Test
fun `greedy route selection prioritizes lower roll angle over earlier time`() {
val earlierHighRollInterval = interval(
mplItem(
noradId = 1L,
gammaMin = 12.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 1)
)
)
val laterLowRollInterval = interval(
mplItem(
noradId = 2L,
gammaMin = 1.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 10),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 11)
)
)
val result = selectGreedyRouteCandidates(
intervals = listOf(earlierHighRollInterval, laterLowRollInterval),
settings = SettingsDTO()
)
assertEquals(
listOf(laterLowRollInterval.id, earlierHighRollInterval.id),
result.selectedCandidates.map { it.intervalId }
)
assertEquals(emptyList(), result.failures)
}
@Test
fun `greedy route selection filters intervals by revolution mode`() {
val ascInterval = interval(
mplItem(noradId = 1L, gammaMin = 1.0, revSignBegin = RevolutionSign.ASC),
revSign = RevolutionSign.ASC
)
val descInterval = interval(
mplItem(noradId = 2L, gammaMin = 2.0, revSignBegin = RevolutionSign.DESC),
revSign = RevolutionSign.DESC
)
val result = selectGreedyRouteCandidates(
intervals = listOf(ascInterval, descInterval),
settings = SettingsDTO(),
revolutionMode = DynamicPlanRevolutionMode.DESC
)
assertEquals(listOf(descInterval.id), result.selectedCandidates.map { it.intervalId })
assertEquals(emptyList(), result.failures)
}
@Test
fun `interval build uses only requested revolution signs`() {
assertEquals(listOf(RevolutionSign.ASC), revolutionSignsForIntervals(DynamicPlanRevolutionMode.ASC))
assertEquals(listOf(RevolutionSign.DESC), revolutionSignsForIntervals(DynamicPlanRevolutionMode.DESC))
assertEquals(
listOf(RevolutionSign.DESC, RevolutionSign.ASC),
revolutionSignsForIntervals(DynamicPlanRevolutionMode.BOTH)
)
}
@Test
fun `greedy route selection avoids simultaneous routes for same satellite`() {
val firstInterval = interval(
mplItem(
noradId = 1L,
gammaMin = 1.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 1)
)
)
val secondInterval = interval(
mplItem(
noradId = 1L,
gammaMin = 1.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 1, 30),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 2)
),
mplItem(
noradId = 2L,
gammaMin = 2.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 1, 30),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 2)
)
)
val result = selectGreedyRouteCandidates(
intervals = listOf(secondInterval, firstInterval),
settings = SettingsDTO()
)
assertEquals(listOf(1L, 2L), result.selectedCandidates.map { it.satelliteId })
assertEquals(emptyList(), result.failures)
}
@Test
fun `greedy route selection reports revolution limit failures`() {
val firstInterval = interval(
mplItem(
noradId = 1L,
gammaMin = 1.0,
revolutionBegin = 10,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 4, 10)
)
)
val secondInterval = interval(
mplItem(
noradId = 1L,
gammaMin = 1.0,
revolutionBegin = 10,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 6),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 8)
)
)
val result = selectGreedyRouteCandidates(
intervals = listOf(firstInterval, secondInterval),
settings = SettingsDTO()
)
assertEquals(1, result.selectedCandidates.size)
assertEquals(GreedyIntervalFailureReason.REVOLUTION_LIMIT_EXCEEDED, result.failures.single().reason)
}
@Test
fun `greedy route selection reports daily limit failures`() {
val firstInterval = interval(
mplItem(
noradId = 1L,
gammaMin = 1.0,
revolutionBegin = 10,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 0),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 4, 50)
)
)
val secondInterval = interval(
mplItem(
noradId = 1L,
gammaMin = 1.0,
revolutionBegin = 11,
timeBegin = LocalDateTime.of(2026, 5, 1, 0, 6),
timeEnd = LocalDateTime.of(2026, 5, 1, 0, 8)
)
)
val result = selectGreedyRouteCandidates(
intervals = listOf(firstInterval, secondInterval),
settings = SettingsDTO(),
spacecraft = listOf(
SpaceCraft(
id = 1L,
constraints = SCConstraints(maxDailyDurationSeconds = 300)
)
)
)
assertEquals(1, result.selectedCandidates.size)
assertEquals(GreedyIntervalFailureReason.DAILY_LIMIT_EXCEEDED, result.failures.single().reason)
}
@Test
fun `remaining request geometry filter keeps route when it still covers uncovered request territory`() {
val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))")
val earlierRoute = route(
noradId = 1L,
time = LocalDateTime.of(2026, 5, 1, 0, 0),
geometry = "POLYGON ((-1 0, -1 1, 0.5 1, 0.5 0, -1 0))"
)
val laterRoute = route(
noradId = 2L,
time = LocalDateTime.of(2026, 5, 1, 0, 10),
geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"
)
val result = filterRoutesByRemainingRequestGeometry(listOf(earlierRoute, laterRoute), requestGeometry)
assertEquals(listOf(earlierRoute, laterRoute), result)
}
@Test
fun `remaining request geometry filter removes route after earlier route covers request territory`() {
val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))")
val earlierRoute = route(
noradId = 1L,
time = LocalDateTime.of(2026, 5, 1, 0, 0),
geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"
)
val laterRoute = route(
noradId = 2L,
time = LocalDateTime.of(2026, 5, 1, 0, 10),
geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"
)
val result = filterRoutesByRemainingRequestGeometry(listOf(earlierRoute, laterRoute), requestGeometry)
assertEquals(listOf(earlierRoute), result)
}
@Test
fun `remaining request geometry filter keeps route with uncovered territory`() {
val requestGeometry = geometry("POLYGON ((0 0, 0 2, 2 2, 2 0, 0 0))")
val earlierRoute = route(
noradId = 1L,
time = LocalDateTime.of(2026, 5, 1, 0, 0),
geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))"
)
val laterRoute = route(
noradId = 2L,
time = LocalDateTime.of(2026, 5, 1, 0, 10),
geometry = "POLYGON ((1 0, 1 1, 2 1, 2 0, 1 0))"
)
val result = filterRoutesByRemainingRequestGeometry(listOf(earlierRoute, laterRoute), requestGeometry)
assertEquals(listOf(earlierRoute, laterRoute), result)
}
@Test
fun `remaining request geometry filter keeps only routes intersecting uncovered territory`() {
val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))")
val earlyLeftRoute = route(
noradId = 1L,
time = LocalDateTime.of(2026, 5, 1, 0, 0),
geometry = "POLYGON ((0 0, 0 1, 0.5 1, 0.5 0, 0 0))"
)
val duplicateLeftRoute = route(
noradId = 2L,
time = LocalDateTime.of(2026, 5, 1, 0, 10),
geometry = "POLYGON ((0 0, 0 1, 0.5 1, 0.5 0, 0 0))"
)
val laterRightRoute = route(
noradId = 3L,
time = LocalDateTime.of(2026, 5, 1, 0, 20),
geometry = "POLYGON ((0.5 0, 0.5 1, 1 1, 1 0, 0.5 0))"
)
val result = filterRoutesByRemainingRequestGeometry(
routes = listOf(laterRightRoute, duplicateLeftRoute, earlyLeftRoute),
requestGeometry = requestGeometry
)
assertEquals(listOf(earlyLeftRoute, laterRightRoute), result)
}
@Test
fun `route coverage summary returns last route and covered area percent`() {
val requestGeometry = geometry("POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))")
val earlierRoute = route(
noradId = 1L,
time = LocalDateTime.of(2026, 5, 1, 0, 0),
geometry = "POLYGON ((0 0, 0 1, 0.5 1, 0.5 0, 0 0))"
)
val laterRoute = route(
noradId = 2L,
time = LocalDateTime.of(2026, 5, 10, 12, 0),
geometry = "POLYGON ((0.5 0, 0.5 1, 1 1, 1 0, 0.5 0))"
)
val result = summarizeRouteCoverage(listOf(earlierRoute, laterRoute), requestGeometry)
assertEquals(laterRoute.time, result.lastRouteStart)
assertEquals(laterRoute.time.plusSeconds(60), result.lastRouteEnd)
assertEquals(100.0, result.coveredAreaPercent ?: -1.0, 0.0001)
}
@Test
fun `greedy math file filter keeps only selected satellites inside requested interval`() {
val calculationStart = LocalDateTime.of(2026, 5, 1, 10, 0)
val calculationEnd = LocalDateTime.of(2026, 5, 1, 11, 0)
val validMpl = mplItem(
noradId = 101L,
gammaMin = 1.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 10, 5),
timeEnd = LocalDateTime.of(2026, 5, 1, 10, 10)
)
val otherSatelliteMpl = mplItem(
noradId = 202L,
gammaMin = 1.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 10, 5),
timeEnd = LocalDateTime.of(2026, 5, 1, 10, 10)
)
val startsBeforeIntervalMpl = mplItem(
noradId = 101L,
gammaMin = 1.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 9, 59),
timeEnd = LocalDateTime.of(2026, 5, 1, 10, 10)
)
val endsAfterIntervalMpl = mplItem(
noradId = 101L,
gammaMin = 1.0,
timeBegin = LocalDateTime.of(2026, 5, 1, 10, 50),
timeEnd = LocalDateTime.of(2026, 5, 1, 11, 1)
)
val interval = interval(validMpl, otherSatelliteMpl, startsBeforeIntervalMpl, endsAfterIntervalMpl)
val result = filterIntervalsForGreedyMathFile(
intervals = listOf(interval),
selectedSatelliteIds = listOf(101L),
calculationStart = calculationStart,
calculationEnd = calculationEnd
)
assertEquals(listOf(validMpl), result.single().mpl)
}
private fun mplItem(
noradId: Long,
gammaMin: Double,
gammaMax: Double = gammaMin,
revSignBegin: RevolutionSign = RevolutionSign.ASC,
revolutionBegin: Long = 1,
timeBegin: LocalDateTime = LocalDateTime.of(2026, 5, 1, 0, 0),
timeEnd: LocalDateTime = LocalDateTime.of(2026, 5, 1, 0, 1)
) = SquareViewParamDTO(
noradId = noradId,
objectId = noradId.toString(),
revolutionBegin = revolutionBegin,
timeBegin = timeBegin,
revSignBegin = revSignBegin,
revolutionEnd = revolutionBegin,
timeEnd = timeEnd,
revSignEnd = revSignBegin,
gammaMin = gammaMin,
gammaMax = gammaMax
)
private fun interval(
vararg mplItems: SquareViewParamDTO,
revSign: RevolutionSign = RevolutionSign.ASC
) =
IntervalItemDTO(
id = UUID.randomUUID(),
regionId = UUID.randomUUID(),
revSign = revSign,
geometry = "POLYGON EMPTY",
mpl = mplItems.toMutableList()
)
private fun route(
noradId: Long,
time: LocalDateTime,
geometry: String
) = SurveyContour(
mpl = SquareViewParamDTO(noradId = noradId),
time = time,
duration = 60.0,
rev = 1L,
gamma = 0.0,
geometry = geometry
)
private fun geometry(value: String) = WKTReader().read(value)
}
@@ -0,0 +1,43 @@
package space.nstart.pcp.complan.apl
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import java.time.LocalDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class CaptureAngleCalculatorTest {
private val calculator = CaptureAngleCalculator()
@Test
fun `calculates capture angle from route width and orbital height`() {
val angle = calculator.calculateCaptureAngle(
routeWidthKm = 30.0,
points = listOf(orbPoint(radiusMeters = 6_378_136.0 + 516_000.0))
)
assertEquals(1.665, angle!!, absoluteTolerance = 0.001)
}
@Test
fun `returns null for empty points`() {
assertNull(calculator.calculateCaptureAngle(routeWidthKm = 30.0, points = emptyList()))
}
@Test
fun `returns null for non positive route width`() {
assertNull(calculator.calculateCaptureAngle(routeWidthKm = 0.0, points = listOf(orbPoint())))
}
private fun orbPoint(radiusMeters: Double = 6_894_136.0) = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 30, 0, 0),
revolution = 1L,
x = radiusMeters,
y = 0.0,
z = 0.0,
vx = 0.0,
vy = 0.0,
vz = 0.0
)
}
@@ -0,0 +1,40 @@
package space.nstart.pcp.complan.apl
import org.junit.jupiter.api.io.TempDir
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.GeometryFactory
import space.nstart.pcp.complan.types.GeoJsonItem
import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class GeoJsonLayerWriterTest {
@Test
fun `does not create layer file when layer writing is disabled`(@TempDir tempDir: Path) {
val layerDir = tempDir.resolve("layers")
val writer = GeoJsonLayerWriter(enabled = false, outputDir = layerDir.toString())
writer.write(listOf(testGeoJsonItem()), "Requests")
assertFalse(Files.exists(layerDir))
}
@Test
fun `creates layer file in configured directory when layer writing is enabled`(@TempDir tempDir: Path) {
val layerDir = tempDir.resolve("layers")
val writer = GeoJsonLayerWriter(enabled = true, outputDir = layerDir.toString())
writer.write(listOf(testGeoJsonItem()), "Requests")
assertTrue(Files.exists(layerDir.resolve("Requests.json")))
}
private fun testGeoJsonItem(): GeoJsonItem =
GeoJsonItem(
geometry = GeometryFactory().createPoint(Coordinate(37.0, 55.0)),
properties = emptyMap<String, Any>()
)
}
@@ -0,0 +1,46 @@
package space.nstart.pcp.complan.apl
import org.locationtech.jts.io.WKTReader
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class IntervalCoverageCheckerTest {
private val reader = WKTReader()
private val checker = IntervalCoverageChecker()
@Test
fun `returns empty geometry when intervals fully cover contour`() {
val contour = reader.read("POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))")
val intervals = listOf(
reader.read("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
reader.read("POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))")
)
val uncovered = checker.findUncoveredGeometry(contour, intervals)
assertTrue(uncovered.isEmpty)
}
@Test
fun `returns uncovered part when intervals leave gap in contour`() {
val contour = reader.read("POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))")
val intervals = listOf(
reader.read("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")
)
val uncovered = checker.findUncoveredGeometry(contour, intervals)
assertEquals(1.0, uncovered.area)
}
@Test
fun `returns full contour when intervals are empty`() {
val contour = reader.read("POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))")
val uncovered = checker.findUncoveredGeometry(contour, emptyList())
assertEquals(contour.area, uncovered.area)
}
}
@@ -0,0 +1,73 @@
package space.nstart.pcp.complan.apl
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.io.TempDir
import space.nstart.pcp.complan.JacksonConfig
import space.nstart.pcp.complan.types.IntervalItemDTO
import space.nstart.pcp.complan.types.ObservationParametersDebugFileDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ObservationParametersDebugFileWriterTest {
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
@Test
fun `writes observation parameters artifact with intervals and mpl`(@TempDir tempDir: Path) {
val requestId = UUID.fromString("11111111-1111-1111-1111-111111111111")
val intervalId = UUID.fromString("22222222-2222-2222-2222-222222222222")
val writer = ObservationParametersDebugFileWriter(
objectMapper = objectMapper,
outputDir = tempDir.resolve("observation-parameters").toString()
)
val artifact = ObservationParametersDebugFileDTO(
createdAt = Instant.parse("2026-05-05T10:15:30Z"),
requestId = requestId,
satelliteIds = listOf(1001L),
missingSatelliteIds = emptyList(),
calculationStart = LocalDateTime.parse("2026-05-05T00:00:00"),
calculationEnd = LocalDateTime.parse("2026-05-06T00:00:00"),
parallelBallisticsCalculation = true,
chunkSize = 10,
parallelism = 2,
intervalsCount = 1,
observationParametersCount = 1,
intervals = listOf(
IntervalItemDTO(
id = intervalId,
regionId = UUID.fromString("33333333-3333-3333-3333-333333333333"),
revSign = RevolutionSign.ASC,
geometry = "POLYGON ((0 0, 1 0, 1 1, 0 0))",
mpl = mutableListOf(
SquareViewParamDTO(
noradId = 1001L,
objectId = intervalId.toString(),
revolutionBegin = 42L,
timeBegin = LocalDateTime.parse("2026-05-05T01:00:00"),
revolutionEnd = 42L,
timeEnd = LocalDateTime.parse("2026-05-05T01:01:00")
)
)
)
)
)
val file = writer.write(artifact)
assertTrue(Files.exists(file))
assertEquals("observation-parameters-$requestId.json", file.fileName.toString())
assertEquals(file, writer.filePathFor(requestId))
val saved = writer.readByRequestId(requestId)
assertEquals(1, saved.schemaVersion)
assertEquals(intervalId, saved.intervals.single().id)
assertEquals(1001L, saved.intervals.single().mpl.single().noradId)
}
}
@@ -0,0 +1,85 @@
package space.nstart.pcp.complan.apl
import org.junit.jupiter.api.Test
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SurveyContourBuilderTest {
private val builder = SurveyContourBuilder()
@Test
fun `build returns closed polygon contour for orbital points`() {
val contour = builder.build(
points = sampleOrbitalPoints(),
roll = 5.0,
capture = 1.5
)
assertNotNull(contour)
val geometry = WKTReader().read(contour)
assertTrue(geometry is Polygon)
assertTrue(geometry.isValid)
assertEquals(geometry.coordinates.first(), geometry.coordinates.last())
}
@Test
fun `build returns null when orbital points are not enough`() {
val contour = builder.build(
points = sampleOrbitalPoints().take(1),
roll = 5.0,
capture = 1.5
)
assertNull(contour)
}
private fun sampleOrbitalPoints() = listOf(
orbPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 0),
revolution = 42L,
x = -6603039.949,
y = -1870023.148,
z = 0.0,
vx = -401.529,
vy = 1413.431,
vz = 7547.696
),
orbPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 1),
revolution = 42L,
x = -6595000.0,
y = -1860000.0,
z = 452000.0,
vx = -420.0,
vy = 1390.0,
vz = 7540.0
)
)
private fun orbPoint(
time: LocalDateTime,
revolution: Long,
x: Double,
y: Double,
z: Double,
vx: Double,
vy: Double,
vz: Double
) = OrbPointDTO(
time = time,
revolution = revolution,
vx = vx,
vy = vy,
vz = vz,
x = x,
y = y,
z = z
)
}
@@ -0,0 +1,439 @@
package space.nstart.pcp.complan.service
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.SimpleTransactionStatus
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.complan.apl.BasicPlanCalculator
import space.nstart.pcp.complan.db.entity.DynamicPlanIntervalEntity
import space.nstart.pcp.complan.db.entity.DynamicPlanRouteEntity
import space.nstart.pcp.complan.db.entity.DynamicPlanRunEntity
import space.nstart.pcp.complan.db.repository.DynamicPlanIntervalRepository
import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository
import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository
import space.nstart.pcp.complan.types.ComplexPlanCalculationOutput
import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO
import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO
import space.nstart.pcp.complan.types.ComplexPlanCalculationStatus
import space.nstart.pcp.complan.types.DynamicPlanCalculationMode
import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode
import space.nstart.pcp.complan.types.DynamicPlanRunStatus
import space.nstart.pcp.complan.types.IntervalItemDTO
import space.nstart.pcp.complan.types.SurveyContour
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 java.util.Optional
import java.util.UUID
import java.util.concurrent.AbstractExecutorService
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class DynamicPlanRunServiceTest {
private val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111")
private val request = ComplexPlanCalculationRequestDTO(
requestId = requestId,
satelliteIds = listOf(101L, 202L),
calculationStart = LocalDateTime.of(2026, 4, 8, 10, 0),
calculationEnd = LocalDateTime.of(2026, 4, 8, 12, 0)
)
@Test
fun `startCalculation creates pending run and queues background work`() {
val fixture = fixture()
val run = fixture.service.startCalculation(request)
assertEquals(DynamicPlanRunStatus.PENDING, run.status)
assertEquals(requestId, run.requestId)
assertEquals(listOf(101L, 202L), run.satelliteIds)
assertEquals(DynamicPlanCalculationMode.FULL, run.calculationMode)
assertEquals(DynamicPlanRevolutionMode.BOTH, run.revolutionMode)
assertEquals(false, run.filterCoveredRoutes)
assertEquals(1, fixture.executor.queuedTasksCount())
assertNotNull(fixture.store[run.runId])
}
@Test
fun `queued calculation stores completed result`() {
val fixture = fixture()
val result = ComplexPlanCalculationResultDTO(
status = ComplexPlanCalculationStatus.COMPLETED,
requestId = requestId,
satelliteIds = listOf(101L, 202L),
missingSatelliteIds = emptyList(),
calculationStart = request.calculationStart,
calculationEnd = request.calculationEnd,
routesCount = 1,
durationMs = 5000
)
val route = SurveyContour(
mpl = SquareViewParamDTO(noradId = 101L),
time = LocalDateTime.of(2026, 4, 8, 10, 10),
duration = 30.0,
rev = 55L,
gamma = 11.5,
geometry = "POLYGON ((0 0, 0 1, 1 1, 0 0))"
)
val interval = IntervalItemDTO(
id = UUID.fromString("33333333-3333-4333-8333-333333333333"),
regionId = UUID.fromString("44444444-4444-4444-8444-444444444444"),
revSign = RevolutionSign.ASC,
geometry = "POLYGON ((0 0, 0 2, 2 2, 0 0))",
mpl = mutableListOf(SquareViewParamDTO(noradId = 101L))
)
doReturn(ComplexPlanCalculationOutput(result = result, routes = listOf(route), intervals = listOf(interval)))
.`when`(fixture.basePlan).calculate(request)
val run = fixture.service.startCalculation(request)
fixture.executor.runNext()
val storedRun = fixture.service.getRun(run.runId)
val storedResult = fixture.service.getResult(run.runId)
val storedRoutes = fixture.service.getRoutes(run.runId, limit = 1000, offset = 0)
val storedIntervals = fixture.service.getIntervals(run.runId, limit = 1000, offset = 0)
assertEquals(DynamicPlanRunStatus.COMPLETED, storedRun.status)
assertEquals("COMPLETED", storedRun.resultStatus)
assertEquals(1, storedRun.routesCount)
assertEquals(5000, storedRun.durationMs)
assertNull(storedRun.errorMessage)
assertEquals(result, storedResult)
assertEquals(1, storedRoutes.size)
assertEquals(101L, storedRoutes[0].satelliteId)
assertEquals("POLYGON ((0 0, 0 1, 1 1, 0 0))", storedRoutes[0].contourWkt)
assertEquals(1, storedIntervals.size)
assertEquals(interval.id, storedIntervals[0].intervalId)
assertEquals(RevolutionSign.ASC, storedIntervals[0].revSign)
assertEquals(1, storedIntervals[0].observationParametersCount)
verify(fixture.aplSettingsLoader).loadLatestSettings()
verify(fixture.basePlan).calculate(request)
}
@Test
fun `queued calculation keeps selected calculation mode`() {
val fixture = fixture()
val greedyRequest = request.copy(
calculationMode = DynamicPlanCalculationMode.GREEDY,
revolutionMode = DynamicPlanRevolutionMode.DESC,
filterCoveredRoutes = true
)
val result = ComplexPlanCalculationResultDTO(
status = ComplexPlanCalculationStatus.COMPLETED,
requestId = requestId,
satelliteIds = listOf(101L, 202L),
missingSatelliteIds = emptyList(),
calculationStart = request.calculationStart,
calculationEnd = request.calculationEnd,
calculationMode = DynamicPlanCalculationMode.GREEDY,
revolutionMode = DynamicPlanRevolutionMode.DESC,
filterCoveredRoutes = true,
routesCount = 0,
durationMs = 1000
)
doReturn(ComplexPlanCalculationOutput(result = result, routes = emptyList())).`when`(fixture.basePlan).calculate(greedyRequest)
val run = fixture.service.startCalculation(greedyRequest)
fixture.executor.runNext()
val storedRun = fixture.service.getRun(run.runId)
val storedResult = fixture.service.getResult(run.runId)
assertEquals(DynamicPlanCalculationMode.GREEDY, storedRun.calculationMode)
assertEquals(DynamicPlanRevolutionMode.DESC, storedRun.revolutionMode)
assertEquals(true, storedRun.filterCoveredRoutes)
assertEquals(DynamicPlanCalculationMode.GREEDY, storedResult.calculationMode)
assertEquals(DynamicPlanRevolutionMode.DESC, storedResult.revolutionMode)
assertEquals(true, storedResult.filterCoveredRoutes)
verify(fixture.basePlan).calculate(greedyRequest)
}
@Test
fun `getResult reads legacy result json without calculation mode`() {
val fixture = fixture()
val runId = UUID.fromString("55555555-5555-4555-8555-555555555555")
fixture.store[runId] = runEntity(
id = runId,
status = DynamicPlanRunStatus.COMPLETED
).apply {
resultJson = """
{
"status": "COMPLETED",
"requestId": "$requestId",
"satelliteIds": [101, 202],
"missingSatelliteIds": [],
"calculationStart": "2026-04-08T10:00:00",
"calculationEnd": "2026-04-08T12:00:00",
"routesCount": 1,
"durationMs": 5000
}
""".trimIndent()
}
val result = fixture.service.getResult(runId)
assertEquals(DynamicPlanCalculationMode.FULL, result.calculationMode)
assertEquals(DynamicPlanRevolutionMode.BOTH, result.revolutionMode)
assertEquals(false, result.filterCoveredRoutes)
assertEquals(1, result.routesCount)
}
@Test
fun `getResult rejects unfinished run`() {
val fixture = fixture()
val run = fixture.service.startCalculation(request)
assertFailsWith<ResponseStatusException> {
fixture.service.getResult(run.runId)
}
}
@Test
fun `listRuns returns latest runs and supports status filter`() {
val fixture = fixture()
val olderRun = runEntity(
id = UUID.fromString("22222222-2222-4222-8222-222222222222"),
status = DynamicPlanRunStatus.COMPLETED,
createdAt = LocalDateTime.of(2026, 4, 8, 9, 0)
)
val newerRun = runEntity(
id = UUID.fromString("33333333-3333-4333-8333-333333333333"),
status = DynamicPlanRunStatus.FAILED,
createdAt = LocalDateTime.of(2026, 4, 8, 10, 0)
)
fixture.store[olderRun.id!!] = olderRun
fixture.store[newerRun.id!!] = newerRun
val allRuns = fixture.service.listRuns(status = null, limit = 10, offset = 0)
val failedRuns = fixture.service.listRuns(status = DynamicPlanRunStatus.FAILED, limit = 10, offset = 0)
assertEquals(listOf(newerRun.id, olderRun.id), allRuns.map { run -> run.runId })
assertEquals(listOf(newerRun.id), failedRuns.map { run -> run.runId })
}
@Test
fun `recoverInterruptedRuns marks pending and running runs as failed`() {
val fixture = fixture()
val pendingRun = runEntity(
id = UUID.fromString("22222222-2222-4222-8222-222222222222"),
status = DynamicPlanRunStatus.PENDING
)
val runningRun = runEntity(
id = UUID.fromString("33333333-3333-4333-8333-333333333333"),
status = DynamicPlanRunStatus.RUNNING
)
val completedRun = runEntity(
id = UUID.fromString("44444444-4444-4444-8444-444444444444"),
status = DynamicPlanRunStatus.COMPLETED
)
fixture.store[pendingRun.id!!] = pendingRun
fixture.store[runningRun.id!!] = runningRun
fixture.store[completedRun.id!!] = completedRun
fixture.service.recoverInterruptedRuns()
assertEquals(DynamicPlanRunStatus.FAILED, pendingRun.status)
assertEquals(DynamicPlanRunStatus.FAILED, runningRun.status)
assertEquals(DynamicPlanRunStatus.COMPLETED, completedRun.status)
assertEquals("Calculation was interrupted by service restart", pendingRun.errorMessage)
assertEquals("Calculation was interrupted by service restart", runningRun.errorMessage)
assertNotNull(pendingRun.finishedAt)
assertNotNull(runningRun.finishedAt)
assertNull(completedRun.finishedAt)
}
private fun fixture(): Fixture {
val store = linkedMapOf<UUID, DynamicPlanRunEntity>()
val routeStore = mutableListOf<DynamicPlanRouteEntity>()
val intervalStore = mutableListOf<DynamicPlanIntervalEntity>()
val repository = mock(DynamicPlanRunRepository::class.java)
val routeRepository = mock(DynamicPlanRouteRepository::class.java)
val intervalRepository = mock(DynamicPlanIntervalRepository::class.java)
org.mockito.Mockito.`when`(
repository.save(ArgumentMatchers.any(DynamicPlanRunEntity::class.java) ?: DynamicPlanRunEntity(id = UUID.randomUUID()))
).thenAnswer { invocation ->
val entity = invocation.arguments[0] as DynamicPlanRunEntity
store[entity.id!!] = entity
entity
}
org.mockito.Mockito.`when`(
repository.findById(ArgumentMatchers.any(UUID::class.java) ?: UUID.randomUUID())
).thenAnswer { invocation ->
Optional.ofNullable(store[invocation.arguments[0] as UUID])
}
org.mockito.Mockito.`when`(
repository.saveAll(ArgumentMatchers.anyIterable<DynamicPlanRunEntity>() ?: emptyList())
).thenAnswer { invocation ->
val entities = invocation.arguments[0] as Iterable<DynamicPlanRunEntity>
entities.map { entity ->
store[entity.id!!] = entity
entity
}
}
org.mockito.Mockito.`when`(
repository.findRuns(ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())
).thenAnswer { invocation ->
val limit = invocation.arguments[0] as Int
val offset = invocation.arguments[1] as Int
store.values
.sortedByDescending { run -> run.createdAt }
.drop(offset)
.take(limit)
}
org.mockito.Mockito.`when`(
repository.findRunsByStatus(
ArgumentMatchers.anyString(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt()
)
).thenAnswer { invocation ->
val status = DynamicPlanRunStatus.valueOf(invocation.arguments[0] as String)
val limit = invocation.arguments[1] as Int
val offset = invocation.arguments[2] as Int
store.values
.filter { run -> run.status == status }
.sortedByDescending { run -> run.createdAt }
.drop(offset)
.take(limit)
}
org.mockito.Mockito.`when`(
repository.findAllByStatusIn(ArgumentMatchers.anyCollection<DynamicPlanRunStatus>() ?: emptyList())
).thenAnswer { invocation ->
val statuses = invocation.arguments[0] as Collection<DynamicPlanRunStatus>
store.values.filter { run -> run.status in statuses }
}
org.mockito.Mockito.`when`(
routeRepository.saveAll(ArgumentMatchers.anyList<DynamicPlanRouteEntity>() ?: emptyList())
).thenAnswer { invocation ->
val entities = invocation.arguments[0] as Iterable<DynamicPlanRouteEntity>
entities.map { entity ->
entity.id = (routeStore.size + 1).toLong()
routeStore.add(entity)
entity
}
}
org.mockito.Mockito.`when`(
intervalRepository.saveAll(ArgumentMatchers.anyList<DynamicPlanIntervalEntity>() ?: emptyList())
).thenAnswer { invocation ->
val entities = invocation.arguments[0] as Iterable<DynamicPlanIntervalEntity>
entities.map { entity ->
entity.id = (intervalStore.size + 1).toLong()
intervalStore.add(entity)
entity
}
}
org.mockito.Mockito.`when`(
routeRepository.findRoutesByRunId(
ArgumentMatchers.any(UUID::class.java) ?: UUID.randomUUID(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt()
)
)
.thenAnswer { invocation ->
val runId = invocation.arguments[0] as UUID
routeStore.filter { route -> route.run?.id == runId }.sortedWith(compareBy({ it.startTime }, { it.id }))
}
org.mockito.Mockito.`when`(
intervalRepository.findIntervalsByRunId(
ArgumentMatchers.any(UUID::class.java) ?: UUID.randomUUID(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt()
)
)
.thenAnswer { invocation ->
val runId = invocation.arguments[0] as UUID
intervalStore.filter { interval -> interval.run?.id == runId }.sortedWith(compareBy({ it.intervalOrder }, { it.id }))
}
val basePlan = mock(BasicPlanCalculator::class.java)
val aplSettingsLoader = mock(AplSettingsLoader::class.java)
val executor = QueueingExecutorService()
val service = DynamicPlanRunService(
runRepository = repository,
routeRepository = routeRepository,
intervalRepository = intervalRepository,
basePlan = basePlan,
aplSettingsLoader = aplSettingsLoader,
objectMapper = ObjectMapper().findAndRegisterModules(),
transactionTemplate = TransactionTemplate(NoopTransactionManager()),
dynamicPlanExecutor = executor
)
return Fixture(service, basePlan, aplSettingsLoader, executor, store)
}
private fun runEntity(
id: UUID,
status: DynamicPlanRunStatus,
createdAt: LocalDateTime = LocalDateTime.of(2026, 4, 8, 10, 0)
): DynamicPlanRunEntity =
DynamicPlanRunEntity(
id = id,
status = status,
requestId = requestId,
satelliteIds = """[101,202]""",
calculationStart = request.calculationStart,
calculationEnd = request.calculationEnd,
createdAt = createdAt
)
private data class Fixture(
val service: DynamicPlanRunService,
val basePlan: BasicPlanCalculator,
val aplSettingsLoader: AplSettingsLoader,
val executor: QueueingExecutorService,
val store: MutableMap<UUID, DynamicPlanRunEntity>
)
private class QueueingExecutorService : AbstractExecutorService() {
private val tasks = ArrayDeque<Runnable>()
private var shutdown = false
override fun execute(command: Runnable) {
tasks.add(command)
}
fun queuedTasksCount(): Int = tasks.size
fun runNext() {
tasks.removeFirst().run()
}
override fun shutdown() {
shutdown = true
}
override fun shutdownNow(): MutableList<Runnable> {
shutdown = true
val queued = tasks.toMutableList()
tasks.clear()
return queued
}
override fun isShutdown(): Boolean = shutdown
override fun isTerminated(): Boolean = shutdown && tasks.isEmpty()
override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean = isTerminated
}
private class NoopTransactionManager : PlatformTransactionManager {
override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus()
override fun commit(status: TransactionStatus) = Unit
override fun rollback(status: TransactionStatus) = Unit
}
}
@@ -0,0 +1,111 @@
package space.nstart.pcp.complan.service
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.test.util.ReflectionTestUtils
import java.net.InetSocketAddress
import java.net.URI
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
class RequestServiceClientTest {
private var server: HttpServer? = null
@AfterEach
fun tearDown() {
server?.stop(0)
}
@Test
fun `getRequest calls v1 request endpoint and maps response to request item`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000001")
val requestedUris = mutableListOf<URI>()
server = serverWithRequestResponse(requestedUris, requestId)
val request = requestServiceClient().getRequest(requestId)!!
assertEquals("/v1/requests/$requestId", requestedUris.single().path)
assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") })
assertEquals(requestId, request.id)
assertEquals("Dynamic plan request", request.name)
assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", request.geometry)
assertEquals(LocalDateTime.of(2026, 1, 2, 3, 4, 5), request.intervalBegin)
assertEquals(LocalDateTime.of(2026, 1, 3, 4, 5, 6), request.intervalEnd)
assertEquals(2.5, request.resolution)
assertEquals(7.5, request.importance)
}
@Test
fun `getRequest returns null on not found like legacy client`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000404")
val requestedUris = mutableListOf<URI>()
server = HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/v1/requests") { exchange ->
requestedUris += exchange.requestURI
val responseBody = """{"code":"REQUEST_NOT_FOUND"}"""
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(404, responseBody.toByteArray().size.toLong())
exchange.responseBody.use { it.write(responseBody.toByteArray()) }
}
start()
}
val request = requestServiceClient().getRequest(requestId)
assertNull(request)
assertEquals("/v1/requests/$requestId", requestedUris.single().path)
assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") })
}
private fun serverWithRequestResponse(
requestedUris: MutableList<URI>,
requestId: UUID,
): HttpServer =
HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/v1/requests") { exchange ->
requestedUris += exchange.requestURI
val responseBody = """
{
"id": "$requestId",
"name": "Dynamic plan request",
"status": "ACTIVE",
"surveyType": "OPTICS",
"geometry": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
"importance": 7.5,
"beginDateTime": "2026-01-02T03:04:05Z",
"endDateTime": "2026-01-03T04:05:06Z",
"kpp": [1],
"highPriorityTransmit": false,
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 2.5,
"sunAngleMin": 10.0,
"sunAngleMax": 90.0,
"clouds": 100.0
},
"rsa": null,
"coverage": {"currentPercent": 0.0},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"deletedAt": null
}
""".trimIndent()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(200, responseBody.toByteArray().size.toLong())
exchange.responseBody.use { it.write(responseBody.toByteArray()) }
}
start()
}
private fun requestServiceClient(): RequestServiceClient {
val client = RequestServiceClient()
ReflectionTestUtils.setField(client, "requestServiceUrl", "http://localhost:${server!!.address.port}")
ReflectionTestUtils.setField(client, "requestSelectionPath", "/v1/requests")
return client
}
}
@@ -0,0 +1,33 @@
package space.nstart.pcp.complan.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import space.nstart.pcp.complan.db.repository.DynamicPlanRouteRepository
import space.nstart.pcp.complan.db.repository.DynamicPlanRunRepository
class SatelliteDeletedServiceTest {
@Test
fun `deleteSatelliteData removes dynamic plan runs and satellite routes`() {
val runRepository = mock(DynamicPlanRunRepository::class.java)
val routeRepository = mock(DynamicPlanRouteRepository::class.java)
val service = SatelliteDeletedService(runRepository, routeRepository)
`when`(runRepository.countBySatelliteId(56756L)).thenReturn(2L)
`when`(routeRepository.countBySatelliteId(56756L)).thenReturn(5L)
`when`(runRepository.deleteAllBySatelliteId(56756L)).thenReturn(2)
`when`(routeRepository.deleteAllBySatelliteId(56756L)).thenReturn(1)
val summary = service.deleteSatelliteData(56756L)
assertEquals(56756L, summary.satelliteId)
assertEquals(2, summary.runsDeleted)
assertEquals(4L, summary.routesDeletedByRunCascade)
assertEquals(1, summary.orphanRoutesDeleted)
verify(runRepository).deleteAllBySatelliteId(56756L)
verify(routeRepository).deleteAllBySatelliteId(56756L)
}
}
@@ -0,0 +1,75 @@
package space.nstart.pcp.complan.spacecraft
import org.locationtech.jts.geom.Coordinate
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.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SpaceCraftTest {
@Test
fun `spacecraft has default planning constraints`() {
val spacecraft = SpaceCraft(56756)
assertEquals(SCConstraints(), spacecraft.constraints)
}
@Test
fun `create flight line selects full passes split by revolution`() {
val spacecraft = SpaceCraft(56756)
spacecraft.flightLine.addAll(
listOf(
point(RevolutionSign.ASC, 1, -70.0, 150.0),
point(RevolutionSign.ASC, 1, 82.0, 20.0),
point(RevolutionSign.DESC, 1, 82.0, 20.0),
point(RevolutionSign.DESC, 1, 30.0, 5.0),
point(RevolutionSign.DESC, 2, -82.0, 350.0),
point(RevolutionSign.ASC, 2, -82.0, 350.0),
point(RevolutionSign.ASC, 2, 30.0, 20.0),
point(RevolutionSign.ASC, 2, 82.0, 50.0),
)
)
val (desc, asc) = spacecraft.createFlightLine()
assertTrue(desc.coordinates.minOf { it.y } <= -80.0)
assertTrue(desc.coordinates.maxOf { it.y } >= 80.0)
assertTrue(asc.coordinates.minOf { it.y } <= -80.0)
assertTrue(asc.coordinates.maxOf { it.y } >= 80.0)
}
@Test
fun `line correction unwraps longitude jumps in both directions`() {
val spacecraft = SpaceCraft(56756)
val corrected = spacecraft.lineCorrection(
listOf(
Coordinate(350.0, 0.0),
Coordinate(10.0, 1.0),
Coordinate(340.0, 2.0),
)
)
assertTrue(corrected[1].x > 350.0)
assertTrue(corrected[2].x < corrected[1].x)
assertTrue(kotlin.math.abs(corrected[1].x - corrected[0].x) < 180.0)
assertTrue(kotlin.math.abs(corrected[2].x - corrected[1].x) < 180.0)
}
private fun point(
revSign: RevolutionSign,
revolution: Long,
lat: Double,
long: Double
): FlightLineDTO =
FlightLineDTO(
time = LocalDateTime.of(2026, 4, 30, 0, 0),
revolution = revolution,
lat = lat,
long = long,
revSign = revSign
)
}
@@ -0,0 +1,38 @@
package space.nstart.pcp.complan.types
import org.junit.jupiter.api.Test
import space.nstart.pcp.complan.JacksonConfig
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertNull
class RequestItemDTOTest {
private val objectMapper = JacksonConfig().objectMapper()
@Test
fun `request item supports nullable priority and intervals from request service`() {
val requestId = UUID.fromString("39ef7800-d20c-4aae-8041-00208c4551b2")
val body = """
{
"id": "$requestId",
"name": "Гибралтар",
"geometry": "POLYGON ((-5.909782 35.691644, -4.797418 35.691644, -4.797418 36.589956, -5.909782 36.589956, -5.909782 35.691644))",
"intervalBegin": "2000-01-01T00:00:00",
"intervalEnd": "2100-01-01T00:00:00",
"resolution": 1.0,
"priority": null,
"importance": 0.5,
"appType": 0,
"intervals": null
}
""".trimIndent()
val result = objectMapper.readValue(body, RequestItemDTO::class.java)
assertEquals(requestId, result.id)
assertEquals("Гибралтар", result.name)
assertNull(result.priority)
assertNull(result.intervals)
}
}