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,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