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,54 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
version = "1.0.0"
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
dependencies {
implementation(project(":libs:ballistics-lib"))
implementation(project(":libs:pcp-types-lib"))
implementation("${property("dep.spring.actuator")}")
implementation("org.springframework.boot:spring-boot-starter-logging")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.locationtech.jts:jts-core:1.19.0")
testImplementation("junit:junit")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
}
}
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_coverage_scheme_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@ConfigurationPropertiesScan
class PcpCoverageSchemeServiceApplication
fun main(args: Array<String>) {
runApplication<PcpCoverageSchemeServiceApplication>(*args)
}
@@ -0,0 +1,131 @@
package space.nstart.pcp.pcp_coverage_scheme_service.client
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomErrorException
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.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.time.Duration
import java.time.LocalDateTime
@Service
class BallisticsClient(
webClientBuilderProvider: ObjectProvider<WebClient.Builder>,
private val properties: CoverageSchemeProperties
) : CoverageBallisticsClient {
private val logger = LoggerFactory.getLogger(this::class.java)
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
private fun client(): WebClient =
webClientBuilder
.baseUrl(properties.ballisticsServiceUrl)
.clientConnector(
ReactorClientHttpConnector(
HttpClient.create().responseTimeout(Duration.ofSeconds(properties.webClientResponseTimeoutSeconds))
)
)
.codecs { codecs -> codecs.defaultCodecs().maxInMemorySize(properties.webClientInMemorySizeMb * 1024 * 1024) }
.build()
override fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO> =
client()
.post()
.uri("/api/obj-view/mpl-square")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.retrieve()
.bodyToFlux(SquareViewParamDTO::class.java)
.collectList()
.block()
?: emptyList()
override fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO> =
client()
.post()
.uri("/api/obj-view/mpl-point")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.retrieve()
.bodyToFlux(PointViewParamDTO::class.java)
.collectList()
.doOnError { error ->
logger.warn(
"Failed to fetch mpl point from ballistics-service: satellites={}, interval=[{} - {}], error={}",
req.satellites,
req.timeStart,
req.timeStop,
error.message
)
}
.onErrorMap { error ->
CustomErrorException("Не удалось получить mpl-point из баллистики: ${error.message}")
}
.block()
?: emptyList()
override fun flightLine(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime): List<FlightLineDTO> =
client()
.get()
.uri { uriBuilder ->
uriBuilder
.path("/api/satellites/{satelliteId}/flight-line")
.queryParam("time_start", timeStart)
.queryParam("time_stop", timeStop)
.build(satelliteId)
}
.retrieve()
.bodyToFlux(FlightLineDTO::class.java)
.collectList()
.doOnError { error ->
logger.warn(
"Failed to fetch flight line from ballistics-service: satelliteId={}, interval=[{} - {}], error={}",
satelliteId,
timeStart,
timeStop,
error.message
)
}
.onErrorMap { error ->
CustomErrorException("Не удалось получить трассу полета для КА $satelliteId: ${error.message}")
}
.block()
?: emptyList()
override fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List<OrbPointDTO> =
client()
.post()
.uri("/api/satellites/{satelliteId}/extract-time", satelliteId)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToFlux(OrbPointDTO::class.java)
.collectList()
.doOnError { error ->
logger.warn(
"Failed to fetch orbital points from ballistics-service: satelliteId={}, interval=[{} - {}], stepMs={}, error={}",
satelliteId,
request.time,
request.timeStop,
request.stepMs,
error.message
)
}
.onErrorMap { error ->
CustomErrorException("Не удалось получить точки орбиты для КА $satelliteId: ${error.message}")
}
.block()
?: emptyList()
}
@@ -0,0 +1,60 @@
package space.nstart.pcp.pcp_coverage_scheme_service.client
import org.springframework.beans.factory.ObjectProvider
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import java.time.Duration
@Service
class ComplexMissionService(
webClientBuilderProvider: ObjectProvider<WebClient.Builder>,
private val properties: CoverageSchemeProperties
) : CoverageSatelliteClient {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
private fun client(): WebClient =
webClientBuilder
.baseUrl(properties.satelliteCatalogServiceUrl)
.clientConnector(
ReactorClientHttpConnector(
HttpClient.create().responseTimeout(Duration.ofSeconds(properties.webClientResponseTimeoutSeconds))
)
)
.codecs { codecs -> codecs.defaultCodecs().maxInMemorySize(properties.webClientInMemorySizeMb * 1024 * 1024) }
.build()
override fun satellite(satelliteId: Long): SatelliteInfoDTO =
client()
.get()
.uri("/api/satellites/{satelliteId}", satelliteId)
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.map { satellite ->
SatelliteInfoDTO(
noradId = satellite.id,
name = satellite.name,
red = satellite.visualization.red,
green = satellite.visualization.green,
blue = satellite.visualization.blue,
captureAngle = satellite.observationProfile?.captureAngle ?: 1.5,
scanTLE = satellite.scanTle,
maxSurveyDurationSeconds = satellite.observationProfile?.durationMaxSeconds,
mmiSeconds = satellite.observationProfile?.mmiSeconds,
code = satellite.code,
typeCode = satellite.typeCode,
active = satellite.active
)
}
.onErrorMap { error ->
CustomErrorException("Не удалось получить параметры КА $satelliteId: ${error.message}")
}
.block()
?: throw CustomErrorException("Не удалось получить параметры КА $satelliteId: пустой ответ satellite-catalog-service")
}
@@ -0,0 +1,16 @@
package space.nstart.pcp.pcp_coverage_scheme_service.client
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import java.time.LocalDateTime
interface CoverageBallisticsClient {
fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO>
fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO>
fun flightLine(satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime): List<FlightLineDTO>
fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List<OrbPointDTO>
}
@@ -0,0 +1,7 @@
package space.nstart.pcp.pcp_coverage_scheme_service.client
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
interface CoverageSatelliteClient {
fun satellite(satelliteId: Long): SatelliteInfoDTO
}
@@ -0,0 +1,23 @@
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("coverage-scheme")
data class CoverageSchemeProperties(
val ballisticsServiceUrl: String = "http://localhost:7003",
val complexMissionServiceUrl: String = "http://localhost:7002",
val satelliteCatalogServiceUrl: String = complexMissionServiceUrl,
val defaultRollStepDegrees: Double = 5.0,
val defaultMinimumTechnologyOverlap: Double = 0.05,
val defaultAllowPartialCoverage: Boolean = true,
val boundaryTouchBuffer: Double = 0.05,
val continuityGapTolerance: Double = 0.000001,
val defaultOrientationToleranceDegrees: Double = 15.0,
val maxRollSamplesPerWindow: Int = 7,
val flightLinePaddingSeconds: Long = 120,
val webClientResponseTimeoutSeconds: Long = 30,
val webClientInMemorySizeMb: Int = 20,
val fallbackSwathWidthDegrees: Double = 1.0,
val fallbackShiftPerRollDegree: Double = 0.02,
val defaultMinimumTimeBetweenModesSeconds: Long = 0
)
@@ -0,0 +1,3 @@
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
class CustomErrorException(message: String) : RuntimeException(message)
@@ -0,0 +1,3 @@
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
class CustomValidationException(message: String) : RuntimeException(message)
@@ -0,0 +1,25 @@
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(CustomValidationException::class)
fun handleValidation(ex: CustomValidationException): ResponseEntity<Map<String, String>> =
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (ex.message ?: "validation error")))
@ExceptionHandler(CustomErrorException::class)
fun handleCustomError(ex: CustomErrorException): ResponseEntity<Map<String, String>> =
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (ex.message ?: "error")))
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValid(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
val message = ex.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "validation error"
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to message))
}
}
@@ -0,0 +1,23 @@
package space.nstart.pcp.pcp_coverage_scheme_service.configuration
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Базовая OpenAPI-конфигурация для REST API сервиса схем покрытия.
*/
@Configuration
class OpenApiConfig {
@Bean
fun coverageSchemeOpenApi(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("PCP Coverage Scheme Service API")
.description("REST API сервиса расчета схем беспрерывного покрытия PCP.")
.version("v1"),
)
}
}
@@ -0,0 +1,25 @@
package space.nstart.pcp.pcp_coverage_scheme_service.controller
import jakarta.validation.Valid
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_coverage_scheme_service.service.CoverageSchemeCalculationService
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
@RestController
@RequestMapping("/api/coverage-schemes")
@Tag(name = "Coverage Schemes", description = "Расчет схем беспрерывного покрытия полигона контурами режимов съемки")
class CoverageSchemeController(
private val coverageSchemeCalculationService: CoverageSchemeCalculationService
) {
@PostMapping("/calculate")
@Operation(summary = "Рассчитать схему беспрерывного покрытия полигона")
fun calculate(
@Valid @RequestBody request: CoverageSchemeCalculateRequestDTO
) = coverageSchemeCalculationService.calculate(request)
}
@@ -0,0 +1,24 @@
package space.nstart.pcp.pcp_coverage_scheme_service.model
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.Point
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime
data class CoverageCandidateMode(
val window: CoverageObservationWindow,
val rollDegrees: Double,
val contour: Geometry,
val targetCoverage: Geometry,
val anchor: Point,
val centerShift: Double,
val generationDirectionDegrees: Double,
val startBoundary: String,
val sequenceSeed: Int
) {
val timeStart: LocalDateTime get() = window.timeStart
val timeEnd: LocalDateTime get() = window.timeEnd
val branch: RevolutionSign get() = window.branch
}
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_coverage_scheme_service.model
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime
data class CoverageObservationWindow(
val satelliteId: Long,
val timeStart: LocalDateTime,
val timeEnd: LocalDateTime,
val branch: RevolutionSign,
val rollMinDegrees: Double,
val rollMaxDegrees: Double
)
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_coverage_scheme_service.model
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.Point
data class CoverageWindowGeometry(
val centerLine: List<Coordinate>,
val leftBoundary: List<Coordinate>,
val rightBoundary: List<Coordinate>,
val anchor: Point,
val directionDegrees: Double,
val averageSwathWidth: Double
)
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_coverage_scheme_service.model
import org.locationtech.jts.geom.Geometry
data class SelectedCoverageMode(
val candidate: CoverageCandidateMode,
val sequence: Int,
val newCoverage: Geometry,
val overlapWithCovered: Geometry,
val continuityOverlapArea: Double,
val score: Double,
val selectionReason: String
)
@@ -0,0 +1,558 @@
package space.nstart.pcp.pcp_coverage_scheme_service.service
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.Geometry
import org.locationtech.jts.geom.GeometryFactory
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import java.time.Duration
import java.time.LocalDateTime
import kotlin.math.PI
import kotlin.math.max
import kotlin.math.min
@Service
class CoverageSchemeBuilderService(
private val ballisticsClient: CoverageBallisticsClient,
private val satelliteClient: CoverageSatelliteClient,
private val geometrySupport: GeometrySupport
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val geometryFactory = GeometryFactory()
fun buildCoverageScheme(
observations: List<CoverageObservationDTO>,
rollStepDegrees: Double? = null,
longitudeModel: GeometrySupport.LongitudeModel? = null
): List<SurveySlotDTO> {
val satelliteParametersBySatellite = HashMap<Long, SatelliteSurveyParameters>()
val selectedBranch = selectDominantBranch(observations)
val selectedObservations = observations.filter { it.observation.revSignBegin == selectedBranch }
logger.info(
"Selected observation branch for coverage scheme: branch={}, ascCount={}, descCount={}, selectedCount={}",
selectedBranch,
observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC },
observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC },
selectedObservations.size
)
val preparedObservations = selectedObservations.flatMap { observation ->
prepareObservation(observation, satelliteParametersBySatellite, longitudeModel)
}.sortedWith(
compareBy<PreparedObservation>(
{ it.leftLongitude },
{ if (selectedBranch == RevolutionSign.DESC) it.observation.observation.gammaMax else 0.0},
{ it.segmentTimeBegin },
{ it.segmentTimeEnd },
{ it.observation.observation.noradId }
)
)
if (preparedObservations.isEmpty()) {
return emptyList()
}
val selectedContours = ArrayList<SelectedContour>(preparedObservations.size)
var previousContour: SelectedContour? = null
var requiredRollSign: Int? = null
var coveredTargetArea: Geometry = geometryFactory.createPolygon()
preparedObservations.forEach { observation ->
val selectedContour = selectContour(observation, previousContour, requiredRollSign) ?: return@forEach
if (!shouldAddContour(selectedContour, coveredTargetArea)) {
return@forEach
}
selectedContours += selectedContour
previousContour = selectedContour
coveredTargetArea = if (coveredTargetArea.isEmpty) {
selectedContour.targetCoverage
} else {
geometrySupport.safeUnion(coveredTargetArea, selectedContour.targetCoverage)
}
if (requiredRollSign == null) {
requiredRollSign = rollSign(selectedContour.roll)
}
}
return selectedContours.map { selectedContour ->
val observation = selectedContour.observation.observation.observation
val viewPoint = viewParams(
PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit),
selectedContour.observation.orbitalPoints.first().toOrbitalPoint(),
selectedContour.roll
)
SurveySlotDTO(
satelliteId = observation.noradId,
tn = selectedContour.observation.segmentTimeBegin,
tk = selectedContour.observation.segmentTimeEnd,
roll = selectedContour.roll,
contour = geometrySupport.toWkt(selectedContour.contour),
revolution = observation.revolutionBegin,
revolutionSign = observation.revSignBegin,
latitude = viewPoint?.lat?.toDegrees() ?: 0.0,
longitude = normalizeLongitude(
viewPoint?.long?.toDegrees() ?: 0.0,
selectedContour.observation.longitudeModel
)
)
}
}
private fun prepareObservation(
observation: CoverageObservationDTO,
satelliteParametersBySatellite: MutableMap<Long, SatelliteSurveyParameters>,
longitudeModel: GeometrySupport.LongitudeModel?
): List<PreparedObservation> {
val resolvedLongitudeModel = longitudeModel
?: observation.intersectionContourWkts
.firstOrNull { it.isNotBlank() }
?.let(geometrySupport::resolveLongitudeModel)
?: GeometrySupport.LongitudeModel.POSITIVE_360
val intersectionContour = observation.intersectionContourWkts
.filter { it.isNotBlank() }
.map { geometrySupport.parseAndNormalizeTarget(it, resolvedLongitudeModel) }
.reduceOrNull(geometrySupport::safeUnion)
if (intersectionContour == null || intersectionContour.isEmpty) {
logger.debug(
"Skip coverage scheme slot because target intersection contour is empty: satelliteId={}, interval=[{} - {}]",
observation.observation.noradId,
observation.observation.timeBegin,
observation.observation.timeEnd
)
return emptyList()
}
val satelliteParameters = satelliteParametersBySatellite.getOrPut(observation.observation.noradId) {
satelliteClient.satellite(observation.observation.noradId).toSurveyParameters()
}
return splitObservationIntervals(observation, satelliteParameters)
.mapNotNull { segment ->
val orbitalPoints = ballisticsClient.exactTime(
observation.observation.noradId,
ExactTimePositionRequestDTO(
time = segment.timeBegin,
timeStop = segment.timeEnd,
stepMs = contourStepMs(segment.timeBegin, segment.timeEnd)
)
).sortedBy { it.time }
if (orbitalPoints.isEmpty()) {
logger.debug(
"Skip coverage scheme slot because orbital points are empty: satelliteId={}, interval=[{} - {}]",
observation.observation.noradId,
segment.timeBegin,
segment.timeEnd
)
return@mapNotNull null
}
PreparedObservation(
observation = observation,
intersectionContour = intersectionContour,
orbitalPoints = orbitalPoints,
capture = satelliteParameters.captureAngle,
leftLongitude = intersectionContour.envelopeInternal.minX,
longitudeModel = resolvedLongitudeModel,
segmentTimeBegin = segment.timeBegin,
segmentTimeEnd = segment.timeEnd
)
}
}
private fun selectContour(
observation: PreparedObservation,
previousContour: SelectedContour?,
requiredRollSign: Int?
): SelectedContour? {
val branch = observation.observation.observation.revSignBegin
val defaultRoll = resolveCompatibleRoll(
candidate = defaultRoll(branch, observation),
branch = branch,
observation = observation,
requiredRollSign = requiredRollSign
) ?: run {
logger.warn(
"Skip coverage scheme slot because interval does not support required roll sign: satelliteId={}, interval=[{} - {}], requiredSign={}",
observation.observation.observation.noradId,
observation.observation.observation.timeBegin,
observation.observation.observation.timeEnd,
requiredRollSign
)
return null
}
val defaultContour = buildContour(observation, defaultRoll) ?: return null
if (previousContour == null) {
return defaultContour
}
if (!intersects(observation.intersectionContour, previousContour.contour)) {
return defaultContour
}
val rollFromIntersection = resolveIntersectingRoll(observation, previousContour)
?.let { candidate ->
resolveCompatibleRoll(
candidate = candidate,
branch = branch,
observation = observation,
requiredRollSign = requiredRollSign
)
}
?: return defaultContour
return buildContour(observation, rollFromIntersection) ?: defaultContour
}
private fun buildContour(observation: PreparedObservation, roll: Double): SelectedContour? {
val contour = contour(observation.orbitalPoints, roll, observation.capture, observation.intersectionContour) ?: return null
if (contour.isEmpty) {
return null
}
val targetCoverage = geometrySupport.safeIntersection(contour, observation.intersectionContour)
return SelectedContour(
observation = observation,
roll = roll,
contour = contour,
targetCoverage = targetCoverage,
overlapAnchor = overlapAnchorPoint(
branch = observation.observation.observation.revSignBegin,
points = observation.orbitalPoints,
roll = roll,
capture = observation.capture,
longitudeModel = observation.longitudeModel
) ?: contour.coordinates.firstOrNull()
)
}
private fun contour(points: List<OrbPointDTO>, roll: Double, capture: Double, target: Geometry): Geometry? {
return try {
val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
val rightCoordinates = ArrayList<Coordinate>(points.size)
val leftCoordinates = ArrayList<Coordinate>(points.size)
points.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 alignedRightCoordinates = geometrySupport.alignTrackCoordinatesToTarget(rightCoordinates, target)
val alignedLeftCoordinates = geometrySupport.alignTrackCoordinatesToTarget(leftCoordinates, target)
val shellCoordinates = ArrayList<Coordinate>(rightCoordinates.size + leftCoordinates.size + 1)
shellCoordinates.addAll(alignedRightCoordinates)
for (index in alignedLeftCoordinates.indices.reversed()) {
shellCoordinates += alignedLeftCoordinates[index]
}
if (shellCoordinates.isNotEmpty()) {
shellCoordinates += Coordinate(shellCoordinates.first().x, shellCoordinates.first().y, 0.0)
}
if (shellCoordinates.size < 4) {
return null
}
val shell = geometryFactory.createLinearRing(shellCoordinates.toTypedArray())
geometryFactory.createPolygon(shell).buffer(0.0)
} catch (error: Exception) {
logger.warn("Failed to build coverage scheme contour", error)
null
}
}
private fun viewParams(
pointOnEarthCalculator: PointOnEarthCalculator,
point: OrbitalPoint,
gamma: Double
): THBLPoint? =
pointOnEarthCalculator.pointOnEarth(point, Orientation(0.0, gamma * PI / 180.0, 0.0))
private fun selectDominantBranch(observations: List<CoverageObservationDTO>) =
if (observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC } >=
observations.count { it.observation.revSignBegin == space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC }
) {
space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.ASC
} else {
space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign.DESC
}
private fun intersects(first: Geometry, second: Geometry): Boolean =
!geometrySupport.safeIntersection(first, second).isEmpty
private fun defaultRoll(branch: RevolutionSign, observation: PreparedObservation): Double =
when (branch) {
RevolutionSign.ASC -> max(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
RevolutionSign.DESC -> min(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
}
private fun resolveCompatibleRoll(
candidate: Double,
branch: RevolutionSign,
observation: PreparedObservation,
requiredRollSign: Int?
): Double? {
if (requiredRollSign == null || rollSign(candidate) == requiredRollSign) {
return candidate
}
val gammaMin = min(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
val gammaMax = max(observation.observation.observation.gammaMin, observation.observation.observation.gammaMax)
return when (requiredRollSign) {
1 -> when {
gammaMin > 0.0 && branch == RevolutionSign.DESC -> gammaMin
gammaMax > 0.0 -> gammaMax
else -> null
}
-1 -> when {
gammaMax < 0.0 && branch == RevolutionSign.ASC -> gammaMax
gammaMin < 0.0 -> gammaMin
else -> null
}
else -> if (gammaMin <= 0.0 && gammaMax >= 0.0) 0.0 else null
}
}
private fun resolveIntersectingRoll(
observation: PreparedObservation,
previousContour: SelectedContour
): Double? {
val previousContourCoordinates = previousContour.contour.coordinates
if (previousContourCoordinates.size < 4) {
logger.warn(
"Unable to resolve intersection roll because previous contour geometry is too short: previousSatelliteId={}, currentSatelliteId={}, points={}",
previousContour.observation.observation.observation.noradId,
observation.observation.observation.noradId,
previousContourCoordinates.size
)
return null
}
val rightmostStartPoint = listOf(
previousContourCoordinates.first(),
previousContourCoordinates[previousContourCoordinates.size - 2]
).maxByOrNull { it.x } ?: return null
val mplPoints = ballisticsClient.mplPoint(
ObjViewRequestDTO(
satellites = listOf(observation.observation.observation.noradId),
timeStart = observation.segmentTimeBegin.minusSeconds(200),
timeStop = observation.segmentTimeEnd.plusSeconds(200),
obj = ObjDTO(
id = "coverage-scheme-overlap",
position = PositionDTO(
lat = rightmostStartPoint.y,
long = normalizeLongitude(rightmostStartPoint.x, observation.longitudeModel),
height = 0.0
)
)
)
)
if (mplPoints.size != 1) {
logger.warn(
"Intersection roll lookup returned unexpected mplPoint count, fallback to no-intersection behavior: currentSatelliteId={}, interval=[{} - {}], mplPointCount={}",
observation.observation.observation.noradId,
observation.segmentTimeBegin,
observation.segmentTimeEnd,
mplPoints.size
)
return null
}
val mplGamma = mplPoints.single().gamma
val currentPoint = observation.orbitalPoints.firstOrNull()?.toOrbitalPoint() ?: return null
val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
val captureHalf = observation.capture
val gammaCandidates = listOf(mplGamma - captureHalf, mplGamma + captureHalf)
val longitudeReference = rightmostStartPoint.x
return gammaCandidates
.mapNotNull { gamma ->
pointOnEarthCalculator.pointOnEarth(
currentPoint,
Orientation(0.0, gamma * PI / 180.0, 0.0)
)?.let { pointOnEarth ->
var adjustedLongitude = pointOnEarth.long.toDegrees()
while (adjustedLongitude - longitudeReference > 180.0) adjustedLongitude -= 360.0
while (adjustedLongitude - longitudeReference < -180.0) adjustedLongitude += 360.0
gamma to adjustedLongitude
}
}
.maxByOrNull { (_, longitude) -> longitude }
?.first
}
private fun shouldAddContour(candidate: SelectedContour, coveredTargetArea: Geometry): Boolean {
if (candidate.targetCoverage.isEmpty || candidate.targetCoverage.area <= 0.0) {
logger.info(
"Skip coverage scheme slot because candidate does not intersect required target area: satelliteId={}, interval=[{} - {}]",
candidate.observation.observation.observation.noradId,
candidate.observation.segmentTimeBegin,
candidate.observation.segmentTimeEnd
)
return false
}
if (coveredTargetArea.isEmpty) {
return true
}
val overlapArea = geometrySupport.safeIntersection(candidate.targetCoverage, coveredTargetArea).area
val overlapRatio = overlapArea / candidate.targetCoverage.area
if (overlapRatio > 0.9) {
logger.info(
"Skip coverage scheme slot because candidate overlaps existing coverage too much: satelliteId={}, interval=[{} - {}], overlapRatio={}",
candidate.observation.observation.observation.noradId,
candidate.observation.segmentTimeBegin,
candidate.observation.segmentTimeEnd,
overlapRatio
)
return false
}
return true
}
private fun overlapAnchorPoint(
branch: RevolutionSign,
points: List<OrbPointDTO>,
roll: Double,
capture: Double,
longitudeModel: GeometrySupport.LongitudeModel
): Coordinate? {
val firstPoint = points.firstOrNull()?.toOrbitalPoint() ?: return null
val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
val boundaryPoint = when (branch) {
RevolutionSign.ASC -> viewParams(pointOnEarthCalculator, firstPoint, roll - capture)
RevolutionSign.DESC -> viewParams(pointOnEarthCalculator, firstPoint, roll + capture)
} ?: return null
return Coordinate(
normalizeLongitude(boundaryPoint.long.toDegrees(), longitudeModel),
boundaryPoint.lat.toDegrees(),
0.0
)
}
private fun splitObservationIntervals(
observation: CoverageObservationDTO,
satelliteParameters: SatelliteSurveyParameters
): List<ObservationSegment> {
val maxDurationSeconds = satelliteParameters.maxSurveyDurationSeconds.coerceAtLeast(1L)
val mmiSeconds = satelliteParameters.mmiSeconds.coerceAtLeast(0L)
val segments = mutableListOf<ObservationSegment>()
var segmentStart = observation.observation.timeBegin
while (segmentStart.isBefore(observation.observation.timeEnd)) {
val segmentEnd = minOf(
segmentStart.plusSeconds(maxDurationSeconds),
observation.observation.timeEnd
)
if (!segmentEnd.isAfter(segmentStart)) {
break
}
segments += ObservationSegment(segmentStart, segmentEnd)
if (!segmentEnd.isBefore(observation.observation.timeEnd)) {
break
}
segmentStart = segmentEnd.plusSeconds(mmiSeconds)
}
return segments
}
private fun contourStepMs(timeBegin: LocalDateTime, timeEnd: LocalDateTime): Long {
val durationMs = max(1L, Duration.between(timeBegin, timeEnd).toMillis())
return max(1L, durationMs / 5L)
}
private fun rollSign(roll: Double): Int = when {
roll > 0.0 -> 1
roll < 0.0 -> -1
else -> 0
}
private fun normalizeLongitude(longitude: Double, longitudeModel: GeometrySupport.LongitudeModel): Double =
when (longitudeModel) {
GeometrySupport.LongitudeModel.STANDARD_180 -> {
var normalized = longitude
while (normalized > 180.0) normalized -= 360.0
while (normalized <= -180.0) normalized += 360.0
normalized
}
GeometrySupport.LongitudeModel.POSITIVE_360 -> {
var normalized = longitude
while (normalized < 0.0) normalized += 360.0
while (normalized >= 360.0) normalized -= 360.0
normalized
}
}
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 data class PreparedObservation(
val observation: CoverageObservationDTO,
val intersectionContour: Geometry,
val orbitalPoints: List<OrbPointDTO>,
val capture: Double,
val leftLongitude: Double,
val longitudeModel: GeometrySupport.LongitudeModel,
val segmentTimeBegin: LocalDateTime,
val segmentTimeEnd: LocalDateTime
)
private data class SelectedContour(
val observation: PreparedObservation,
val roll: Double,
val contour: Geometry,
val targetCoverage: Geometry,
val overlapAnchor: Coordinate?
)
private data class SatelliteSurveyParameters(
val captureAngle: Double,
val maxSurveyDurationSeconds: Long,
val mmiSeconds: Long
)
private data class ObservationSegment(
val timeBegin: LocalDateTime,
val timeEnd: LocalDateTime
)
private fun SatelliteInfoDTO.toSurveyParameters() = SatelliteSurveyParameters(
captureAngle = captureAngle,
maxSurveyDurationSeconds = maxSurveyDurationSeconds ?: 300L,
mmiSeconds = mmiSeconds ?: 10L
)
}
@@ -0,0 +1,266 @@
package space.nstart.pcp.pcp_coverage_scheme_service.service
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryCollection
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO
@Service
class CoverageSchemeCalculationService(
private val ballisticsClient: CoverageBallisticsClient,
private val coverageSchemeBuilderService: CoverageSchemeBuilderService,
private val geometrySupport: GeometrySupport,
private val properties: CoverageSchemeProperties
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val geometryFactory = GeometryFactory()
fun calculate(request: CoverageSchemeCalculateRequestDTO): CoverageSchemeResponseDTO {
validateRequest(request)
val longitudeModel = geometrySupport.resolveLongitudeModel(request.polygonWkt)
val target = geometrySupport.parseAndNormalizeTarget(request.polygonWkt, longitudeModel)
val views = ballisticsClient.mplSquare(
ObjViewRequestDTO(
satellites = request.satelliteIds,
timeStart = request.timeStart,
timeStop = request.timeEnd,
obj = ObjDTO(
id = "coverage-scheme-target",
contourWKT = request.polygonWkt
),
sunAngleMin = request.sunAngle
)
)
.sortedWith(
compareBy<SquareViewParamDTO>(
{ it.noradId },
{ it.timeBegin },
{ it.timeEnd },
{ it.revolutionBegin },
{ it.revolutionEnd },
{ it.gammaMin },
{ it.gammaMax }
)
)
logger.info("Получено ${views.size} моментов наблюдений")
val observations = views
.map { observation ->
CoverageObservationDTO(
observation = observation,
intersectionContourWkts = buildIntersectionContourWkts(target, observation, longitudeModel)
)
}
val coverageScheme = coverageSchemeBuilderService.buildCoverageScheme(
observations = observations,
rollStepDegrees = request.rollStepDegrees,
longitudeModel = longitudeModel
)
logger.info(
"Coverage scheme calculated: observations={}, slots={}, satellites={}",
observations.size,
coverageScheme.size,
request.satelliteIds.size
)
return CoverageSchemeResponseDTO(
targetPolygonWkt = geometrySupport.toWkt(target),
observations = observations,
coverageScheme = coverageScheme
)
}
private fun buildIntersectionContourWkts(
target: Geometry,
observation: SquareViewParamDTO,
longitudeModel: GeometrySupport.LongitudeModel
): List<String> {
val paddedStart = observation.timeBegin.minusSeconds(properties.flightLinePaddingSeconds)
val paddedEnd = observation.timeEnd.plusSeconds(properties.flightLinePaddingSeconds)
val flightLine = ballisticsClient.flightLine(observation.noradId, paddedStart, paddedEnd)
.map { normalizeFlightLine(it, longitudeModel) }
.sortedBy { it.time }
if (flightLine.size < 2) {
logger.debug(
"Skip strip intersection because flight line is too short: satelliteId={}, interval=[{} - {}], points={}",
observation.noradId,
observation.timeBegin,
observation.timeEnd,
flightLine.size
)
return emptyList()
}
val strip = buildStripGeometry(target, flightLine)
if (strip.isEmpty) {
return emptyList()
}
val intersection = geometrySupport.safeIntersection(strip, target)
return extractPolygons(intersection)
.map(geometrySupport::toWkt)
.filter { it.isNotBlank() }
}
private fun normalizeFlightLine(
point: FlightLineDTO,
longitudeModel: GeometrySupport.LongitudeModel
) = FlightLineDTO(
time = point.time,
revolution = point.revolution,
lat = point.lat,
long = normalizeLongitude(point.long, longitudeModel),
latLeft = point.latLeft,
longLeft = normalizeLongitude(point.longLeft, longitudeModel),
latInnerLeft = point.latInnerLeft,
longInnerLeft = normalizeLongitude(point.longInnerLeft, longitudeModel),
latInnerRight = point.latInnerRight,
longInnerRight = normalizeLongitude(point.longInnerRight, longitudeModel),
latRight = point.latRight,
longRight = normalizeLongitude(point.longRight, longitudeModel),
revSign = point.revSign
)
private fun normalizeLongitude(
longitude: Double,
longitudeModel: GeometrySupport.LongitudeModel
): Double = when (longitudeModel) {
GeometrySupport.LongitudeModel.STANDARD_180 -> normalizeToStandardBand(longitude)
GeometrySupport.LongitudeModel.POSITIVE_360 -> normalizeToPositiveBand(longitude)
}
private fun normalizeToStandardBand(longitude: Double): Double {
var normalized = longitude % 360.0
if (normalized <= -180.0) {
normalized += 360.0
}
if (normalized > 180.0) {
normalized -= 360.0
}
return normalized
}
private fun normalizeToPositiveBand(longitude: Double): Double {
var normalized = longitude % 360.0
if (normalized < 0.0) {
normalized += 360.0
}
return normalized
}
private fun buildStripGeometry(target: Geometry, flightLine: List<FlightLineDTO>): Geometry {
val outerLeftBoundary = geometrySupport.alignTrackCoordinatesToTarget(
flightLine.map { Coordinate(it.longLeft, it.latLeft, 0.0) },
target
)
val outerRightBoundary = geometrySupport.alignTrackCoordinatesToTarget(
flightLine.map { Coordinate(it.longRight, it.latRight, 0.0) },
target
)
if (outerLeftBoundary.size < 2 || outerRightBoundary.size < 2) {
return geometryFactory.createPolygon()
}
val innerLeftBoundary = geometrySupport.alignTrackCoordinatesToTarget(
flightLine.map { Coordinate(it.longInnerLeft, it.latInnerLeft, 0.0) },
target
)
val innerRightBoundary = geometrySupport.alignTrackCoordinatesToTarget(
flightLine.map { Coordinate(it.longInnerRight, it.latInnerRight, 0.0) },
target
)
val geometries = listOfNotNull(
buildRibbonPolygon(outerLeftBoundary, innerLeftBoundary, flightLine),
buildRibbonPolygon(innerRightBoundary, outerRightBoundary, flightLine)
)
if (geometries.isEmpty()) {
return geometryFactory.createPolygon()
}
return geometries.drop(1).fold<Polygon, Geometry>(geometries.first()) { merged, geometry ->
geometrySupport.safeUnion(merged, geometry)
}
}
private fun buildRibbonPolygon(
leftBoundary: List<Coordinate>,
rightBoundary: List<Coordinate>,
flightLine: List<FlightLineDTO>
): Polygon? {
val shell = toRingCoordinates(leftBoundary, rightBoundary) ?: return null
return runCatching {
geometryFactory.createPolygon(shell.toTypedArray())
}.getOrElse { error ->
logger.warn(
"Failed to build strip segment polygon: firstPointTime={}, points={}, error={}",
flightLine.firstOrNull()?.time,
flightLine.size,
error.message
)
null
}
}
private fun toRingCoordinates(leftBoundary: List<Coordinate>, rightBoundary: List<Coordinate>): List<Coordinate>? {
if (leftBoundary.size < 2 || rightBoundary.size < 2) {
return null
}
val ring = ArrayList<Coordinate>(leftBoundary.size + rightBoundary.size + 1)
leftBoundary.forEach { ring += Coordinate(it) }
rightBoundary.asReversed().forEach { ring += Coordinate(it) }
if (ring.size < 4) {
return null
}
ring += Coordinate(ring.first())
val distinctVertices = ring
.dropLast(1)
.map { it.x to it.y }
.distinct()
return ring.takeIf { distinctVertices.size >= 3 }
}
private fun extractPolygons(geometry: Geometry): List<Polygon> {
if (geometry.isEmpty) return emptyList()
return when (geometry) {
is Polygon -> listOf(geometry)
is MultiPolygon -> (0 until geometry.numGeometries)
.map { geometry.getGeometryN(it) as Polygon }
is GeometryCollection -> (0 until geometry.numGeometries)
.flatMap { extractPolygons(geometry.getGeometryN(it)) }
else -> emptyList()
}
}
private fun validateRequest(request: CoverageSchemeCalculateRequestDTO) {
val minimumTechnologyOverlap = request.minimumTechnologyOverlap
if (request.satelliteIds.isEmpty()) {
throw CustomValidationException("satelliteIds must not be empty")
}
if (!request.timeStart.isBefore(request.timeEnd)) {
throw CustomValidationException("timeStart must be before timeEnd")
}
if (minimumTechnologyOverlap != null && minimumTechnologyOverlap < 0.0) {
throw CustomValidationException("minimumTechnologyOverlap must be non-negative")
}
}
}
@@ -0,0 +1,372 @@
package space.nstart.pcp.pcp_coverage_scheme_service.service
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.CoordinateSequenceFilter
import org.locationtech.jts.geom.Envelope
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryCollection
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.LineString
import org.locationtech.jts.geom.LinearRing
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Point
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import org.locationtech.jts.io.WKTWriter
import org.locationtech.jts.operation.buffer.BufferOp
import org.locationtech.jts.operation.buffer.BufferParameters
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CustomValidationException
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
@Component
class GeometrySupport {
enum class LongitudeModel {
STANDARD_180,
POSITIVE_360
}
data class StripOrientation(
val axisX: Double,
val axisY: Double,
val normalX: Double,
val normalY: Double
)
private val geometryFactory = GeometryFactory()
private val wktReader = WKTReader(geometryFactory)
private val wktWriter = WKTWriter()
fun resolveLongitudeModel(wkt: String): LongitudeModel {
if (wkt.isBlank()) {
throw CustomValidationException("polygonWkt must not be blank")
}
val geometry = try {
wktReader.read(wkt)
} catch (ex: Exception) {
throw CustomValidationException("Некорректный WKT полигона: ${ex.message}")
}
return resolveLongitudeModel(geometry)
}
fun parseAndNormalizeTarget(wkt: String, longitudeModel: LongitudeModel? = null): Geometry {
if (wkt.isBlank()) {
throw CustomValidationException("polygonWkt must not be blank")
}
val geometry = try {
wktReader.read(wkt)
} catch (ex: Exception) {
throw CustomValidationException("Некорректный WKT полигона: ${ex.message}")
}
val normalized = normalizeToLongitudeModel(geometry, longitudeModel ?: resolveLongitudeModel(geometry))
if (normalized !is Polygon && normalized !is MultiPolygon) {
throw CustomValidationException("Поддерживаются только Polygon или MultiPolygon")
}
return ensureValid(normalized)
}
fun toWkt(geometry: Geometry): String =
if (geometry.isEmpty) "" else wktWriter.write(ensureValid(geometry))
fun safeIntersection(left: Geometry, right: Geometry): Geometry =
safeOverlay(left, right) { first, second -> first.intersection(second) }
fun safeDifference(left: Geometry, right: Geometry): Geometry =
safeOverlay(left, right) { first, second -> first.difference(second) }
fun safeUnion(left: Geometry, right: Geometry): Geometry =
safeOverlay(left, right) { first, second -> first.union(second) }
fun centroid(geometry: Geometry): Point =
if (geometry.isEmpty) point(0.0, 0.0) else geometry.centroid
fun point(x: Double, y: Double): Point =
geometryFactory.createPoint(Coordinate(x, y))
fun buildStripOrientation(directionDegrees: Double): StripOrientation {
val radians = Math.toRadians(directionDegrees)
val axisX = cos(radians)
val axisY = sin(radians)
return StripOrientation(
axisX = axisX,
axisY = axisY,
normalX = -axisY,
normalY = axisX
)
}
fun projectionRange(geometry: Geometry, orientation: StripOrientation): Pair<Double, Double> {
if (geometry.isEmpty) return 0.0 to 0.0
val values = geometry.coordinates.map { project(it.x, it.y, orientation.axisX, orientation.axisY) }
return (values.minOrNull() ?: 0.0) to (values.maxOrNull() ?: 0.0)
}
fun projectPoint(point: Point, orientation: StripOrientation): Double =
project(point.x, point.y, orientation.axisX, orientation.axisY)
fun createCorridor(centerLine: List<Coordinate>, width: Double, centerShift: Double = 0.0): Polygon {
val shifted = shiftCoordinates(centerLine, buildStripOrientation(directionDegrees(centerLine)), centerShift)
val line = when {
shifted.isEmpty() -> geometryFactory.createLineString(arrayOf(Coordinate(0.0, 0.0), Coordinate(0.0, 0.0)))
shifted.size == 1 -> geometryFactory.createLineString(arrayOf(shifted.first(), shifted.first()))
else -> geometryFactory.createLineString(shifted.toTypedArray())
}
val buffered = BufferOp.bufferOp(
line,
max(width / 2.0, 0.000001),
BufferParameters(8, BufferParameters.CAP_FLAT, BufferParameters.JOIN_ROUND, BufferParameters.DEFAULT_MITRE_LIMIT)
)
return ensureValid(buffered) as Polygon
}
fun shiftPoint(point: Point, orientation: StripOrientation, distance: Double): Point =
this.point(
point.x + orientation.normalX * distance,
point.y + orientation.normalY * distance
)
fun directionDegrees(coordinates: List<Coordinate>): Double {
if (coordinates.size < 2) return 0.0
val normalized = normalizeTrackCoordinates(coordinates)
val first = normalized.first()
val last = normalized.last()
return Math.toDegrees(atan2(last.y - first.y, last.x - first.x))
}
fun normalizeTrackCoordinates(coordinates: List<Coordinate>): List<Coordinate> {
if (coordinates.isEmpty()) return emptyList()
val normalized = mutableListOf(Coordinate(coordinates.first()))
var previousX = coordinates.first().x
coordinates.drop(1).forEach { coordinate ->
var adjustedX = coordinate.x
while (adjustedX - previousX > 180.0) adjustedX -= 360.0
while (adjustedX - previousX < -180.0) adjustedX += 360.0
normalized += Coordinate(adjustedX, coordinate.y, coordinate.z)
previousX = adjustedX
}
return normalized
}
fun alignTrackCoordinatesToTarget(coordinates: List<Coordinate>, target: Geometry): List<Coordinate> {
val normalized = normalizeTrackCoordinates(coordinates)
if (normalized.isEmpty()) return normalized
val referenceX = target.envelopeInternal.centre().x
val averageX = normalized.map { it.x }.average()
val shiftMultiplier = kotlin.math.round((referenceX - averageX) / 360.0)
val shift = shiftMultiplier * 360.0
if (shift == 0.0) return normalized
return normalized.map { coordinate ->
Coordinate(coordinate.x + shift, coordinate.y, coordinate.z)
}
}
fun distance(first: Coordinate, second: Coordinate): Double {
val dx = second.x - first.x
val dy = second.y - first.y
return kotlin.math.sqrt(dx * dx + dy * dy)
}
fun boundaryTouchArea(coverage: Geometry, target: Geometry, orientation: StripOrientation, buffer: Double): Double {
val bands = boundaryBands(target, buffer)
return bands.maxOf { (_, band) -> safeIntersection(coverage, band).area }
}
fun frontierGeometry(target: Geometry, covered: Geometry): Geometry {
if (covered.isEmpty) return ensureValid(target.boundary.buffer(0.0001))
val uncovered = safeDifference(target, covered)
if (uncovered.isEmpty) return geometryFactory.createPolygon()
return ensureValid(safeIntersection(covered.buffer(0.0001), uncovered.buffer(0.0001)))
}
fun uncoveredFrontierGeometry(target: Geometry, covered: Geometry): Geometry {
val uncovered = safeDifference(target, covered)
if (uncovered.isEmpty) return geometryFactory.createPolygon()
return ensureValid(safeIntersection(uncovered, frontierGeometry(target, covered).buffer(0.0001)))
}
fun startBoundaryLabel(coverage: Geometry, target: Geometry, orientation: StripOrientation): String {
val bands = boundaryBands(target, 0.05)
return bands.maxByOrNull { (_, band) -> safeIntersection(coverage, band).area }?.first ?: "INTERIOR"
}
fun shiftCoordinates(centerLine: List<Coordinate>, orientation: StripOrientation, distance: Double): List<Coordinate> =
centerLine.map { coordinate ->
Coordinate(
coordinate.x + orientation.normalX * distance,
coordinate.y + orientation.normalY * distance,
coordinate.z
)
}
private fun boundaryBands(target: Geometry, buffer: Double): List<Pair<String, Geometry>> {
val envelope = target.envelopeInternal
val dx = max(buffer, (envelope.width * 0.02).coerceAtLeast(0.0001))
val dy = max(buffer, (envelope.height * 0.02).coerceAtLeast(0.0001))
return listOf(
"WEST" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.minX + dx, envelope.minY - dy, envelope.maxY + dy)),
"EAST" to geometryFactory.toGeometry(Envelope(envelope.maxX - dx, envelope.maxX + dx, envelope.minY - dy, envelope.maxY + dy)),
"SOUTH" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.maxX + dx, envelope.minY - dy, envelope.minY + dy)),
"NORTH" to geometryFactory.toGeometry(Envelope(envelope.minX - dx, envelope.maxX + dx, envelope.maxY - dy, envelope.maxY + dy))
)
}
private fun ensureValid(geometry: Geometry): Geometry =
if (geometry.isValid) geometry else geometry.buffer(0.0)
private fun safeOverlay(left: Geometry, right: Geometry, operation: (Geometry, Geometry) -> Geometry): Geometry =
try {
ensureValid(operation(ensureValid(left), ensureValid(right)))
} catch (_: Exception) {
ensureValid(operation(left.buffer(0.0), right.buffer(0.0)))
}
private fun project(x: Double, y: Double, axisX: Double, axisY: Double): Double = x * axisX + y * axisY
private fun resolveLongitudeModel(geometry: Geometry): LongitudeModel {
val longitudes = geometry.coordinates.map { normalizeToStandardLongitude(it.x) }
val negativeMin = longitudes.filter { it < 0.0 }.minOrNull()
val positiveMax = longitudes.filter { it >= 0.0 }.maxOrNull()
val crossesZeroMeridian = negativeMin != null && positiveMax != null && positiveMax - negativeMin < 180.0
return if (crossesZeroMeridian) LongitudeModel.STANDARD_180 else LongitudeModel.POSITIVE_360
}
private fun normalizeToLongitudeModel(geometry: Geometry, longitudeModel: LongitudeModel): Geometry {
val normalized = when (longitudeModel) {
LongitudeModel.STANDARD_180 -> shiftLongitudesToStandardBand(geometry.copy())
LongitudeModel.POSITIVE_360 -> shiftNegativeLongitudesTo360(geometry.copy())
}
return when (normalized) {
is Polygon -> normalizePolygon(normalized, longitudeModel)
is MultiPolygon -> normalizeMultiPolygon(normalized, longitudeModel)
else -> normalized
}.let { ensureValid(it) }
}
private fun shiftNegativeLongitudesTo360(geometry: Geometry): Geometry {
geometry.apply(object : CoordinateSequenceFilter {
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
val lon = seq.getX(i)
if (lon < 0.0) {
seq.setOrdinate(i, 0, lon + 360.0)
}
}
override fun isDone(): Boolean = false
override fun isGeometryChanged(): Boolean = true
})
geometry.geometryChanged()
return geometry
}
private fun shiftLongitudesToStandardBand(geometry: Geometry): Geometry {
geometry.apply(object : CoordinateSequenceFilter {
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
seq.setOrdinate(i, 0, normalizeToStandardLongitude(seq.getX(i)))
}
override fun isDone(): Boolean = false
override fun isGeometryChanged(): Boolean = true
})
geometry.geometryChanged()
return geometry
}
private fun normalizePolygon(polygon: Polygon, longitudeModel: LongitudeModel): Geometry {
val shell = unwrapRing(polygon.exteriorRing)
val holes = Array(polygon.numInteriorRing) { index -> unwrapRing(polygon.getInteriorRingN(index)) }
val normalized = ensureValid(polygon.factory.createPolygon(shell, holes))
return when (longitudeModel) {
LongitudeModel.STANDARD_180 -> shiftIntoStandardBand(normalized)
LongitudeModel.POSITIVE_360 -> shiftIntoPositiveBand(normalized)
}
}
private fun normalizeMultiPolygon(multiPolygon: MultiPolygon, longitudeModel: LongitudeModel): Geometry {
val polygons = mutableListOf<Polygon>()
for (index in 0 until multiPolygon.numGeometries) {
collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon, longitudeModel), polygons)
}
return when (polygons.size) {
0 -> geometryFactory.createPolygon()
1 -> polygons.first()
else -> geometryFactory.createMultiPolygon(polygons.toTypedArray())
}
}
private fun collectPolygons(geometry: Geometry, polygons: MutableList<Polygon>) {
when (geometry) {
is Polygon -> polygons += geometry
is MultiPolygon -> {
for (index in 0 until geometry.numGeometries) {
polygons += geometry.getGeometryN(index) as Polygon
}
}
is GeometryCollection -> {
for (index in 0 until geometry.numGeometries) {
collectPolygons(geometry.getGeometryN(index), polygons)
}
}
}
}
private fun shiftIntoPositiveBand(geometry: Geometry): Geometry {
val minX = geometry.coordinates.minOfOrNull { it.x } ?: return geometry
if (minX >= 0.0) return geometry
val shift = 360.0 * (floor(-minX / 360.0) + 1.0)
return shiftLongitude(geometry, shift)
}
private fun shiftIntoStandardBand(geometry: Geometry): Geometry {
val centerX = geometry.envelopeInternal.centre().x
val shift = -360.0 * floor((centerX + 180.0) / 360.0)
return shiftLongitude(geometry, shift)
}
private fun shiftLongitude(geometry: Geometry, delta: Double): Geometry {
if (delta == 0.0) return geometry
val shifted = geometry.copy()
shifted.apply(object : CoordinateSequenceFilter {
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
seq.setOrdinate(i, 0, seq.getX(i) + delta)
}
override fun isDone(): Boolean = false
override fun isGeometryChanged(): Boolean = true
})
shifted.geometryChanged()
return shifted
}
private fun unwrapRing(ring: LineString): LinearRing {
val source = ring.coordinates
if (source.isEmpty()) return ring.factory.createLinearRing()
val unwrapped = ArrayList<Coordinate>(source.size)
var previousX = source.first().x
unwrapped.add(Coordinate(source.first().x, source.first().y, source.first().z))
for (index in 1 until source.size - 1) {
val current = source[index]
var adjustedX = current.x
while (adjustedX - previousX > 180.0) adjustedX -= 360.0
while (adjustedX - previousX < -180.0) adjustedX += 360.0
unwrapped += Coordinate(adjustedX, current.y, current.z)
previousX = adjustedX
}
val first = unwrapped.first()
unwrapped += Coordinate(first.x, first.y, first.z)
return ring.factory.createLinearRing(unwrapped.toTypedArray())
}
private fun normalizeToStandardLongitude(longitude: Double): Double {
var normalized = longitude
while (normalized > 180.0) normalized -= 360.0
while (normalized <= -180.0) normalized += 360.0
return normalized
}
}
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-coverage-scheme-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -0,0 +1,97 @@
package space.nstart.pcp.pcp_coverage_scheme_service.controller
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.pcp_coverage_scheme_service.service.CoverageSchemeCalculationService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import kotlin.test.assertEquals
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.boot.admin.client.enabled=false",
"spring.cloud.config.enabled=false",
"spring.cloud.config.import-check.enabled=false",
"spring.config.import="
]
)
class CoverageSchemeControllerTest {
@LocalServerPort
private var port: Int = 0
@MockitoBean
private lateinit var coverageSchemeCalculationService: CoverageSchemeCalculationService
private val objectMapper = ObjectMapper()
@Test
fun `calculate endpoint returns observations with contours`() {
doReturn(
CoverageSchemeResponseDTO(
targetPolygonWkt = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))",
observations = listOf(
CoverageObservationDTO(
observation = SquareViewParamDTO(
noradId = 11L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5),
revSignBegin = RevolutionSign.ASC,
revSignEnd = RevolutionSign.ASC,
gammaMin = 0.0,
gammaMax = 10.0
),
intersectionContourWkts = listOf("POLYGON ((0.5 0, 1.5 0, 1.5 2, 0.5 2, 0.5 0))")
)
),
coverageScheme = listOf(
SurveySlotDTO(
satelliteId = 11L,
tn = LocalDateTime.of(2026, 4, 14, 10, 0),
tk = LocalDateTime.of(2026, 4, 14, 10, 5),
roll = 5.0,
contour = "POLYGON ((0.5 0, 1.5 0, 1.5 2, 0.5 2, 0.5 0))",
revolutionSign = RevolutionSign.ASC
)
)
)
)
.`when`(coverageSchemeCalculationService)
.calculate(org.mockito.ArgumentMatchers.any(CoverageSchemeCalculateRequestDTO::class.java) ?: CoverageSchemeCalculateRequestDTO())
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/coverage-schemes/calculate")
.bodyValue(
CoverageSchemeCalculateRequestDTO(
polygonWkt = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))",
satelliteIds = listOf(11L),
timeStart = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 11, 0)
)
)
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(1, actual["observations"].size())
assertEquals(1, actual["coverageScheme"].size())
assertEquals(11L, actual["observations"][0]["observation"]["noradId"].asLong())
assertEquals(
"POLYGON ((0.5 0, 1.5 0, 1.5 2, 0.5 2, 0.5 0))",
actual["observations"][0]["intersectionContourWkts"][0].textValue()
)
}
}
@@ -0,0 +1,328 @@
package space.nstart.pcp.pcp_coverage_scheme_service.service
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
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.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CoverageSchemeCalculationServiceTest {
private val geometrySupport = GeometrySupport()
@Test
fun `calculate returns all mplSquare observations with strip intersection contours`() {
val observation = SquareViewParamDTO(
noradId = 11L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5),
revSignBegin = RevolutionSign.ASC,
revSignEnd = RevolutionSign.ASC,
gammaMin = 0.0,
gammaMax = 0.0
)
val fakeClient = FakeBallisticsClient(
observations = listOf(observation),
flightLinesBySatellite = mapOf(
11L to listOf(
flightPoint(LocalDateTime.of(2026, 4, 14, 10, 0), lat = 0.0, lon = 196.0, leftLon = 195.5, rightLon = 196.5),
flightPoint(LocalDateTime.of(2026, 4, 14, 10, 5), lat = 2.0, lon = 196.0, leftLon = 195.5, rightLon = 196.5)
)
),
orbitalPointsBySatellite = mapOf(11L to sampleOrbitalPoints())
)
val service = buildService(fakeClient)
val response = service.calculate(
CoverageSchemeCalculateRequestDTO(
polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))",
satelliteIds = listOf(11L),
timeStart = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10)
)
)
assertEquals(1, response.observations.size)
assertEquals(observation.noradId, response.observations.first().observation.noradId)
assertEquals(observation.timeBegin, response.observations.first().observation.timeBegin)
assertEquals(observation.timeEnd, response.observations.first().observation.timeEnd)
assertEquals(1, response.observations.first().intersectionContourWkts.size)
assertTrue(response.observations.first().intersectionContourWkts.first().contains("195.5 0"))
assertTrue(response.observations.first().intersectionContourWkts.first().contains("196.5 2"))
assertEquals(1, response.coverageScheme.size)
assertEquals(0.0, response.coverageScheme.first().roll)
assertEquals(observation.timeBegin, response.coverageScheme.first().tn)
assertEquals(observation.timeEnd, response.coverageScheme.first().tk)
assertTrue(response.coverageScheme.first().contour.startsWith("POLYGON"))
}
@Test
fun `calculate keeps observation when flight line does not produce polygon`() {
val observation = SquareViewParamDTO(
noradId = 11L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5)
)
val service = buildService(
FakeBallisticsClient(
observations = listOf(observation),
flightLinesBySatellite = mapOf(
11L to listOf(
flightPoint(LocalDateTime.of(2026, 4, 14, 10, 0), lat = 0.0, lon = 196.0, leftLon = 195.5, rightLon = 196.5)
)
),
orbitalPointsBySatellite = emptyMap()
)
)
val response = service.calculate(
CoverageSchemeCalculateRequestDTO(
polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))",
satelliteIds = listOf(11L),
timeStart = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10)
)
)
assertEquals(1, response.observations.size)
assertTrue(response.observations.first().intersectionContourWkts.isEmpty())
assertTrue(response.coverageScheme.isEmpty())
}
@Test
fun `calculate preserves inner exclusion zone inside strip contour`() {
val observation = SquareViewParamDTO(
noradId = 11L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5)
)
val service = buildService(
FakeBallisticsClient(
observations = listOf(observation),
flightLinesBySatellite = mapOf(
11L to listOf(
flightPoint(
LocalDateTime.of(2026, 4, 14, 10, 0),
lat = 0.0,
lon = 196.0,
leftLon = 195.5,
rightLon = 196.5,
innerLeftLon = 195.9,
innerRightLon = 196.1
),
flightPoint(
LocalDateTime.of(2026, 4, 14, 10, 5),
lat = 2.0,
lon = 196.0,
leftLon = 195.5,
rightLon = 196.5,
innerLeftLon = 195.9,
innerRightLon = 196.1
)
)
),
orbitalPointsBySatellite = mapOf(11L to sampleOrbitalPoints())
)
)
val response = service.calculate(
CoverageSchemeCalculateRequestDTO(
polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))",
satelliteIds = listOf(11L),
timeStart = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10)
)
)
val contours = response.observations.first().intersectionContourWkts
assertEquals(2, contours.size)
assertTrue(contours.any { it.contains("195.5 0") && it.contains("195.9 2") })
assertTrue(contours.any { it.contains("196.1 0") && it.contains("196.5 2") })
assertEquals(1, response.coverageScheme.size)
}
@Test
fun `calculate normalizes ballistics flight line to standard band for zero crossing target`() {
val observation = SquareViewParamDTO(
noradId = 11L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5),
gammaMin = 0.0,
gammaMax = 0.0
)
val service = buildService(
FakeBallisticsClient(
observations = listOf(observation),
flightLinesBySatellite = mapOf(
11L to listOf(
flightPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 0),
lat = 0.0,
lon = 0.0,
leftLon = 359.5,
rightLon = 0.5
),
flightPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 5),
lat = 2.0,
lon = 0.0,
leftLon = 359.5,
rightLon = 0.5
)
)
),
orbitalPointsBySatellite = mapOf(11L to sampleOrbitalPoints())
)
)
val response = service.calculate(
CoverageSchemeCalculateRequestDTO(
polygonWkt = "POLYGON ((-1 0, 1 0, 1 2, -1 2, -1 0))",
satelliteIds = listOf(11L),
timeStart = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10)
)
)
assertEquals(1, response.observations.size)
assertEquals(1, response.observations.first().intersectionContourWkts.size)
assertTrue(response.observations.first().intersectionContourWkts.first().contains("-0.5 0"))
assertTrue(response.observations.first().intersectionContourWkts.first().contains("0.5 2"))
}
@Test
fun `calculate returns observations in stable order even if mplSquare order changes`() {
val first = SquareViewParamDTO(
noradId = 11L,
revolutionBegin = 3L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5),
gammaMin = 1.0,
gammaMax = 3.0
)
val second = SquareViewParamDTO(
noradId = 22L,
revolutionBegin = 2L,
timeBegin = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 1),
gammaMin = -1.0,
gammaMax = 2.0
)
val service = buildService(
FakeBallisticsClient(
observations = listOf(first, second),
flightLinesBySatellite = emptyMap(),
orbitalPointsBySatellite = emptyMap()
)
)
val response = service.calculate(
CoverageSchemeCalculateRequestDTO(
polygonWkt = "POLYGON ((195 0, 197 0, 197 2, 195 2, 195 0))",
satelliteIds = listOf(22L, 11L),
timeStart = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10)
)
)
assertEquals(listOf(11L, 22L), response.observations.map { it.observation.noradId })
assertEquals(
listOf(
LocalDateTime.of(2026, 4, 14, 10, 0),
LocalDateTime.of(2026, 4, 14, 9, 55)
),
response.observations.map { it.observation.timeBegin }
)
}
private class FakeBallisticsClient(
private val observations: List<SquareViewParamDTO>,
private val flightLinesBySatellite: Map<Long, List<FlightLineDTO>>,
private val orbitalPointsBySatellite: Map<Long, List<OrbPointDTO>>
) : CoverageBallisticsClient {
override fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO> = observations
override fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO> = emptyList()
override fun flightLine(
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
): List<FlightLineDTO> = flightLinesBySatellite[satelliteId].orEmpty()
.filter { point -> !point.time.isBefore(timeStart) && !point.time.isAfter(timeStop) }
override fun exactTime(
satelliteId: Long,
request: ExactTimePositionRequestDTO
): List<OrbPointDTO> = orbitalPointsBySatellite[satelliteId].orEmpty()
.filter { point -> !point.time.isBefore(request.time) && !point.time.isAfter(request.timeStop ?: request.time) }
}
private fun flightPoint(
time: LocalDateTime,
lat: Double,
lon: Double,
leftLon: Double,
rightLon: Double,
innerLeftLon: Double = lon,
innerRightLon: Double = lon
) = FlightLineDTO(
time = time,
lat = lat,
long = lon,
latLeft = lat,
longLeft = leftLon,
latInnerLeft = lat,
longInnerLeft = innerLeftLon,
latInnerRight = lat,
longInnerRight = innerRightLon,
latRight = lat,
longRight = rightLon
)
private fun buildService(fakeClient: FakeBallisticsClient) = CoverageSchemeCalculationService(
ballisticsClient = fakeClient,
coverageSchemeBuilderService = CoverageSchemeBuilderService(fakeClient, FakeSatelliteClient(), geometrySupport),
geometrySupport = geometrySupport,
properties = CoverageSchemeProperties(flightLinePaddingSeconds = 0)
)
private class FakeSatelliteClient : CoverageSatelliteClient {
override fun satellite(satelliteId: Long): SatelliteInfoDTO =
SatelliteInfoDTO(noradId = satelliteId, captureAngle = 1.5)
}
private fun sampleOrbitalPoints() = listOf(
OrbPointDTO(
time = LocalDateTime.of(2026, 4, 14, 10, 0),
revolution = 1L,
vx = -401.529,
vy = 1413.431,
vz = 7547.696,
x = -6603039.949,
y = -1870023.148,
z = 0.0
),
OrbPointDTO(
time = LocalDateTime.of(2026, 4, 14, 10, 1),
revolution = 1L,
vx = -420.0,
vy = 1390.0,
vz = 7540.0,
x = -6595000.0,
y = -1860000.0,
z = 452000.0
)
)
}
@@ -0,0 +1,652 @@
package space.nstart.pcp.pcp_coverage_scheme_service.service
import ballistics.flightLine.PointOnEarthCalculator
import ballistics.types.EarthType
import ballistics.types.Orientation
import ballistics.types.OrbitalPoint
import ballistics.types.WorkCSType
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import org.junit.jupiter.api.Test
import org.locationtech.jts.geom.Geometry
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient
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.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import java.time.LocalDateTime
import kotlin.math.PI
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CoverageSchemeBuilderServiceTest {
private val geometrySupport = GeometrySupport()
@Test
fun `buildCoverageScheme uses dominant branch and sorts by left boundary`() {
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, LocalDateTime.of(2026, 4, 14, 10, 0)) to reverseOrbitalPoints(),
requestKey(22L, LocalDateTime.of(2026, 4, 14, 9, 55)) to reverseOrbitalPoints(),
requestKey(33L, LocalDateTime.of(2026, 4, 14, 10, 2)) to sampleOrbitalPoints()
)
),
satelliteClient = FakeSatelliteClient(
mapOf(
11L to 1.5,
22L to 1.5,
33L to 1.5
)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5),
branch = RevolutionSign.ASC,
gammaMin = -2.0,
gammaMax = 6.0,
contour = "POLYGON ((195.5 2.0, 196.5 2.0, 196.5 4.2, 195.5 4.2, 195.5 2.0))"
),
observation(
satelliteId = 22L,
timeBegin = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 1),
branch = RevolutionSign.ASC,
gammaMin = 1.0,
gammaMax = 5.0,
contour = "POLYGON ((195.5 -0.2, 196.5 -0.2, 196.5 2.1, 195.5 2.1, 195.5 -0.2))"
),
observation(
satelliteId = 33L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 2),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 7),
branch = RevolutionSign.DESC,
gammaMin = 3.0,
gammaMax = 7.0,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
)
)
)
assertEquals(2, result.size)
assertEquals(listOf(22L, 11L), result.map { it.satelliteId })
assertEquals(5.0, result.first().roll)
assertEquals(6.0, result.last().roll)
assertTrue(result.all { it.revolutionSign == RevolutionSign.ASC })
}
@Test
fun `buildCoverageScheme moves overlapping next contour to the right while preserving continuity`() {
val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
val mplPointRequests = mutableListOf<ObjViewRequestDTO>()
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, requestTime) to reverseOrbitalPoints(),
requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
),
mplPointsBySatellite = mapOf(
11L to listOf(
PointViewParamDTO(
noradId = 11L,
time = requestTime,
gamma = 6.0
)
),
22L to listOf(
PointViewParamDTO(
noradId = 22L,
time = requestTime.plusMinutes(10),
gamma = 6.0
)
)
),
capturedMplPointRequests = mplPointRequests
),
satelliteClient = FakeSatelliteClient(
mapOf(
11L to 1.5,
22L to 1.5
)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = requestTime,
timeEnd = requestTime.plusMinutes(5),
branch = RevolutionSign.ASC,
gammaMin = 4.0,
gammaMax = 4.0,
contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
),
observation(
satelliteId = 22L,
timeBegin = requestTime.plusMinutes(10),
timeEnd = requestTime.plusMinutes(15),
branch = RevolutionSign.ASC,
gammaMin = 0.0,
gammaMax = 8.0,
contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
)
),
rollStepDegrees = 1.0
)
assertEquals(2, result.size)
assertEquals(4.0, result.first().roll)
assertEquals(7.5, result.last().roll)
assertEquals(1, mplPointRequests.size)
val firstContour = geometrySupport.parseAndNormalizeTarget(result.first().contour)
val secondContour = geometrySupport.parseAndNormalizeTarget(result.last().contour)
assertTrue(secondContour.area > 0.0)
assertTrue(firstContour.area > 0.0)
assertPositionEquals(
expectedRightmostStartPosition(firstContour),
mplPointRequests.single().obj.position
)
assertTrue(requireNotNull(mplPointRequests.single().obj.position).long in 0.0..360.0)
}
@Test
fun `buildCoverageScheme uses minimum roll and right-side anchor for desc branch`() {
val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
val mplPointRequests = mutableListOf<ObjViewRequestDTO>()
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, requestTime) to reverseOrbitalPoints(),
requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
),
mplPointsBySatellite = mapOf(
22L to listOf(
PointViewParamDTO(
noradId = 22L,
time = requestTime.plusMinutes(10),
gamma = -6.0
)
)
),
capturedMplPointRequests = mplPointRequests
),
satelliteClient = FakeSatelliteClient(
mapOf(
11L to 1.5,
22L to 1.5
)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = requestTime,
timeEnd = requestTime.plusMinutes(5),
branch = RevolutionSign.DESC,
gammaMin = -7.0,
gammaMax = -2.0,
contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
),
observation(
satelliteId = 22L,
timeBegin = requestTime.plusMinutes(10),
timeEnd = requestTime.plusMinutes(15),
branch = RevolutionSign.DESC,
gammaMin = -8.0,
gammaMax = 0.0,
contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
)
)
)
assertEquals(2, result.size)
assertEquals(-7.0, result.first().roll)
assertEquals(-4.5, result.last().roll)
assertEquals(1, mplPointRequests.size)
assertPositionEquals(
expectedRightmostStartPosition(geometrySupport.parseAndNormalizeTarget(result.first().contour)),
mplPointRequests.single().obj.position
)
assertTrue(requireNotNull(mplPointRequests.single().obj.position).long in 0.0..360.0)
}
@Test
fun `buildCoverageScheme skips subsequent contour when required sign is unavailable`() {
val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, requestTime) to reverseOrbitalPoints(),
requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
)
),
satelliteClient = FakeSatelliteClient(
mapOf(
11L to 1.5,
22L to 1.5
)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = requestTime,
timeEnd = requestTime.plusMinutes(5),
branch = RevolutionSign.ASC,
gammaMin = 4.0,
gammaMax = 8.0,
contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
),
observation(
satelliteId = 22L,
timeBegin = requestTime.plusMinutes(10),
timeEnd = requestTime.plusMinutes(15),
branch = RevolutionSign.ASC,
gammaMin = -8.0,
gammaMax = -2.0,
contour = "POLYGON ((196.5 -0.1, 197.3 -0.1, 197.3 4.0, 196.5 4.0, 196.5 -0.1))"
)
)
)
assertEquals(1, result.size)
assertEquals(8.0, result.single().roll)
}
@Test
fun `buildCoverageScheme splits long observation by satellite max duration and mmi`() {
val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0)
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, start) to reverseOrbitalPoints(),
requestKey(11L, start.plusSeconds(70)) to sampleOrbitalPoints(),
requestKey(11L, start.plusSeconds(140)) to shiftedReverseOrbitalPoints()
)
),
satelliteClient = FakeSatelliteClient(
captureAnglesBySatellite = mapOf(11L to 1.5),
maxSurveyDurationSecondsBySatellite = mapOf(11L to 60L),
mmiSecondsBySatellite = mapOf(11L to 10L)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = start,
timeEnd = start.plusSeconds(180),
branch = RevolutionSign.ASC,
gammaMin = 1.0,
gammaMax = 5.0,
contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
)
)
)
assertEquals(3, result.size)
assertEquals(start, result[0].tn)
assertEquals(start.plusSeconds(60), result[0].tk)
assertEquals(start.plusSeconds(70), result[1].tn)
assertEquals(start.plusSeconds(130), result[1].tk)
assertEquals(start.plusSeconds(140), result[2].tn)
assertEquals(start.plusSeconds(180), result[2].tk)
}
@Test
fun `buildCoverageScheme skips candidate when contour does not intersect required area`() {
val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0)
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, start) to sampleOrbitalPoints()
)
),
satelliteClient = FakeSatelliteClient(
captureAnglesBySatellite = mapOf(11L to 1.5)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = start,
timeEnd = start.plusMinutes(5),
branch = RevolutionSign.ASC,
gammaMin = 1.0,
gammaMax = 5.0,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
)
)
)
assertTrue(result.isEmpty())
}
@Test
fun `buildCoverageScheme skips candidate when overlap with existing coverage is above ninety percent`() {
val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0)
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, start) to reverseOrbitalPoints(),
requestKey(22L, start.plusMinutes(10)) to reverseOrbitalPoints()
)
),
satelliteClient = FakeSatelliteClient(
captureAnglesBySatellite = mapOf(
11L to 1.5,
22L to 1.5
)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = start,
timeEnd = start.plusMinutes(5),
branch = RevolutionSign.ASC,
gammaMin = 4.0,
gammaMax = 4.0,
contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
),
observation(
satelliteId = 22L,
timeBegin = start.plusMinutes(10),
timeEnd = start.plusMinutes(15),
branch = RevolutionSign.ASC,
gammaMin = 4.0,
gammaMax = 4.0,
contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
)
)
)
assertEquals(1, result.size)
assertEquals(11L, result.single().satelliteId)
}
@Test
fun `buildCoverageScheme falls back to minimum roll when mplPoint returns multiple records`() {
val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
val service = CoverageSchemeBuilderService(
ballisticsClient = FakeBallisticsClient(
orbitalPointsByRequest = mapOf(
requestKey(11L, requestTime) to reverseOrbitalPoints(),
requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
),
mplPointsBySatellite = mapOf(
22L to listOf(
PointViewParamDTO(noradId = 22L, time = requestTime.plusMinutes(10), gamma = 4.0),
PointViewParamDTO(noradId = 22L, time = requestTime.plusMinutes(11), gamma = 5.0)
)
)
),
satelliteClient = FakeSatelliteClient(
mapOf(
11L to 1.5,
22L to 1.5
)
),
geometrySupport = geometrySupport
)
val result = service.buildCoverageScheme(
observations = listOf(
observation(
satelliteId = 11L,
timeBegin = requestTime,
timeEnd = requestTime.plusMinutes(5),
branch = RevolutionSign.ASC,
gammaMin = 4.0,
gammaMax = 4.0,
contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
),
observation(
satelliteId = 22L,
timeBegin = requestTime.plusMinutes(10),
timeEnd = requestTime.plusMinutes(15),
branch = RevolutionSign.ASC,
gammaMin = 0.0,
gammaMax = 8.0,
contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
)
)
)
assertEquals(2, result.size)
assertEquals(8.0, result.last().roll)
}
private class FakeBallisticsClient(
private val orbitalPointsByRequest: Map<Pair<Long, LocalDateTime>, List<OrbPointDTO>>,
private val mplPointsBySatellite: Map<Long, List<PointViewParamDTO>> = emptyMap(),
private val capturedMplPointRequests: MutableList<ObjViewRequestDTO> = mutableListOf()
) : CoverageBallisticsClient {
override fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO> = emptyList()
override fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO> {
capturedMplPointRequests += req
return req.satellites.singleOrNull()?.let { mplPointsBySatellite[it].orEmpty() } ?: emptyList()
}
override fun flightLine(
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
): List<FlightLineDTO> = emptyList()
override fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List<OrbPointDTO> =
orbitalPointsByRequest[satelliteId to request.time].orEmpty()
}
private class FakeSatelliteClient(
private val captureAnglesBySatellite: Map<Long, Double>,
private val maxSurveyDurationSecondsBySatellite: Map<Long, Long> = emptyMap(),
private val mmiSecondsBySatellite: Map<Long, Long> = emptyMap()
) : CoverageSatelliteClient {
override fun satellite(satelliteId: Long): SatelliteInfoDTO =
SatelliteInfoDTO(
noradId = satelliteId,
captureAngle = captureAnglesBySatellite.getValue(satelliteId),
maxSurveyDurationSeconds = maxSurveyDurationSecondsBySatellite[satelliteId] ?: 300L,
mmiSeconds = mmiSecondsBySatellite[satelliteId] ?: 10L
)
}
private fun observation(
satelliteId: Long,
timeBegin: LocalDateTime,
timeEnd: LocalDateTime,
branch: RevolutionSign,
gammaMin: Double,
gammaMax: Double,
contour: String
) = CoverageObservationDTO(
observation = SquareViewParamDTO(
noradId = satelliteId,
revolutionBegin = satelliteId,
timeBegin = timeBegin,
timeEnd = timeEnd,
gammaMin = gammaMin,
gammaMax = gammaMax,
revSignBegin = branch,
revSignEnd = branch
),
intersectionContourWkts = listOf(contour)
)
private fun requestKey(satelliteId: Long, time: LocalDateTime) = satelliteId to time
private fun expectedBoundaryPosition(
branch: RevolutionSign,
orbitalPoints: List<OrbPointDTO>,
roll: Double,
capture: Double
): PositionDTO {
val firstPoint = orbitalPoints.first().toOrbitalPoint()
val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
val gamma = when (branch) {
RevolutionSign.ASC -> roll - capture
RevolutionSign.DESC -> roll + capture
}
val pointOnEarth = calculator.pointOnEarth(firstPoint, Orientation(0.0, gamma * PI / 180.0, 0.0))!!
return PositionDTO(
lat = pointOnEarth.lat * 180.0 / PI,
long = pointOnEarth.long * 180.0 / PI,
height = 0.0
)
}
private fun expectedRightmostStartPosition(contour: Geometry): PositionDTO {
val coordinates = contour.coordinates
val rightmostStartPoint = listOf(
coordinates.first(),
coordinates[coordinates.size - 2]
).maxByOrNull { it.x }!!
return PositionDTO(
lat = rightmostStartPoint.y,
long = rightmostStartPoint.x,
height = 0.0
)
}
private fun assertPositionEquals(expected: PositionDTO, actual: PositionDTO?) {
fun normalizeToStandardLongitude(longitude: Double): Double {
var normalized = longitude
while (normalized > 180.0) normalized -= 360.0
while (normalized <= -180.0) normalized += 360.0
return normalized
}
requireNotNull(actual)
assertEquals(expected.lat, actual.lat, 1e-9)
assertEquals(normalizeToStandardLongitude(expected.long), normalizeToStandardLongitude(actual.long), 1e-9)
}
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 reverseOrbitalPoints() = listOf(
orbPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 0),
revolution = 42L,
x = -6595000.0,
y = -1860000.0,
z = 452000.0,
vx = 420.0,
vy = -1390.0,
vz = -7540.0
),
orbPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 1),
revolution = 42L,
x = -6603039.949,
y = -1870023.148,
z = 0.0,
vx = 401.529,
vy = -1413.431,
vz = -7547.696
)
)
private fun shiftedReverseOrbitalPoints() = listOf(
orbPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 0),
revolution = 42L,
x = -6495000.0,
y = -1760000.0,
z = 452000.0,
vx = 420.0,
vy = -1390.0,
vz = -7540.0
),
orbPoint(
time = LocalDateTime.of(2026, 4, 14, 10, 1),
revolution = 42L,
x = -6503039.949,
y = -1770023.148,
z = 0.0,
vx = 401.529,
vy = -1413.431,
vz = -7547.696
)
)
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
)
private fun OrbPointDTO.toOrbitalPoint() = OrbitalPoint(
t = fromDateTime(time),
vit = revolution.toInt(),
r = Vector3D(x, y, z),
v = Vector3D(vx, vy, vz)
)
}
@@ -0,0 +1,111 @@
package space.nstart.pcp.pcp_coverage_scheme_service.service
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient
import space.nstart.pcp.pcp_coverage_scheme_service.configuration.CoverageSchemeProperties
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.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import java.time.LocalDateTime
import kotlin.test.assertEquals
class CoverageSchemeMultiContourTest {
private val service = CoverageSchemeCalculationService(
ballisticsClient = FakeBallisticsClient(),
coverageSchemeBuilderService = CoverageSchemeBuilderService(FakeBallisticsClient(), FakeSatelliteClient(), GeometrySupport()),
geometrySupport = GeometrySupport(),
properties = CoverageSchemeProperties(flightLinePaddingSeconds = 0)
)
@Test
fun `calculate splits multipolygon intersection into separate contours`() {
val response = service.calculate(
CoverageSchemeCalculateRequestDTO(
polygonWkt = "MULTIPOLYGON (((195 0, 196 0, 196 2, 195 2, 195 0)), ((197 0, 198 0, 198 2, 197 2, 197 0)))",
satelliteIds = listOf(11L),
timeStart = LocalDateTime.of(2026, 4, 14, 9, 55),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 10)
)
)
assertEquals(1, response.observations.size)
assertEquals(2, response.observations.first().intersectionContourWkts.size)
assertEquals(1, response.coverageScheme.size)
}
private class FakeBallisticsClient : CoverageBallisticsClient {
override fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO> =
listOf(
SquareViewParamDTO(
noradId = 11L,
timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5)
)
)
override fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO> = emptyList()
override fun flightLine(
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
): List<FlightLineDTO> = listOf(
FlightLineDTO(
time = LocalDateTime.of(2026, 4, 14, 10, 0),
lat = 0.0,
long = 196.5,
latLeft = 0.0,
longLeft = 195.5,
latRight = 0.0,
longRight = 197.5
),
FlightLineDTO(
time = LocalDateTime.of(2026, 4, 14, 10, 5),
lat = 2.0,
long = 196.5,
latLeft = 2.0,
longLeft = 195.5,
latRight = 2.0,
longRight = 197.5
)
)
override fun exactTime(
satelliteId: Long,
request: ExactTimePositionRequestDTO
): List<OrbPointDTO> = listOf(
OrbPointDTO(
time = LocalDateTime.of(2026, 4, 14, 10, 0),
revolution = 1L,
vx = -401.529,
vy = 1413.431,
vz = 7547.696,
x = -6603039.949,
y = -1870023.148,
z = 0.0
),
OrbPointDTO(
time = LocalDateTime.of(2026, 4, 14, 10, 1),
revolution = 1L,
vx = -420.0,
vy = 1390.0,
vz = 7540.0,
x = -6595000.0,
y = -1860000.0,
z = 452000.0
)
)
}
private class FakeSatelliteClient : CoverageSatelliteClient {
override fun satellite(satelliteId: Long): SatelliteInfoDTO =
SatelliteInfoDTO(noradId = satelliteId, captureAngle = 1.5)
}
}
@@ -0,0 +1,38 @@
package space.nstart.pcp.pcp_coverage_scheme_service.service
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GeometrySupportTest {
private val geometrySupport = GeometrySupport()
@Test
fun `resolveLongitudeModel uses standard band when polygon crosses zero meridian`() {
val model = geometrySupport.resolveLongitudeModel(
"POLYGON ((-1 0, 1 0, 1 1, -1 1, -1 0))"
)
assertEquals(GeometrySupport.LongitudeModel.STANDARD_180, model)
}
@Test
fun `resolveLongitudeModel uses positive band when polygon crosses one hundred eighty meridian`() {
val model = geometrySupport.resolveLongitudeModel(
"POLYGON ((179 0, -179 0, -179 1, 179 1, 179 0))"
)
assertEquals(GeometrySupport.LongitudeModel.POSITIVE_360, model)
}
@Test
fun `parseAndNormalizeTarget keeps zero crossing polygon in standard band`() {
val geometry = geometrySupport.parseAndNormalizeTarget(
"POLYGON ((-1 0, 1 0, 1 1, -1 1, -1 0))",
GeometrySupport.LongitudeModel.STANDARD_180
)
assertTrue(geometry.coordinates.all { it.x in -180.0..180.0 })
}
}
@@ -0,0 +1,12 @@
spring:
config:
import: ""
cloud:
config:
enabled: false
import-check:
enabled: false
boot:
admin:
client:
enabled: false