Прототип страницы расчета ПУУД

This commit is contained in:
emelianov
2026-06-15 11:58:49 +03:00
parent 3b1a13e532
commit 07ba650e03
11 changed files with 550 additions and 97 deletions
@@ -1,6 +1,5 @@
package space.nstart.pcp.pcp_request_service.angular
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -8,6 +7,10 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.angularmotion.AngularMotionMode
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
@RestController
@@ -17,20 +20,112 @@ class AngularMotionController(
) {
@GetMapping("/satellites")
fun availableSatellites(
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
time: LocalDateTime?,
): List<AngularMotionSatelliteDTO> = angularMotionService.availableSatellites(time)
@RequestParam(required = false) time: String?,
): List<AngularMotionSatelliteDTO> = angularMotionService.availableSatellites(parseTime(time, required = false))
@GetMapping("/satellites/{satelliteId}/flight-line")
fun flightLine(
@PathVariable satelliteId: Long,
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
time: LocalDateTime,
): List<AngularMotionFlightLinePointDTO> = angularMotionService.flightLine(satelliteId, time)
@RequestParam time: String,
): List<AngularMotionFlightLinePointDTO> = angularMotionService.flightLine(
satelliteId,
parseTime(time, required = true) ?: throw CustomValidationException("Не задано время для построения трассы"),
)
@PostMapping("/calculate")
fun calculate(@RequestBody request: AngularMotionCalculationRequestDTO): AngularMotionCalculationResultDTO =
angularMotionService.calculate(request)
fun calculate(@RequestBody request: Map<String, Any?>): AngularMotionCalculationResultDTO =
angularMotionService.calculate(AngularMotionRequestMapper.calculationRequest(request))
private fun parseTime(value: String?, required: Boolean): LocalDateTime? {
val raw = value?.trim().orEmpty()
if (raw.isEmpty()) {
if (required) throw CustomValidationException("Не задан параметр time")
return null
}
val decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8)
val normalized = if (decoded.length == 16) "$decoded:00" else decoded
return runCatching { LocalDateTime.parse(normalized) }
.getOrElse {
throw CustomValidationException(
"Некорректный формат времени '$decoded'. Ожидается yyyy-MM-dd'T'HH:mm или yyyy-MM-dd'T'HH:mm:ss",
)
}
}
}
internal object AngularMotionRequestMapper {
fun calculationRequest(payload: Map<String, Any?>): AngularMotionCalculationRequestDTO =
AngularMotionCalculationRequestDTO(
satelliteId = payload.requiredLong("satelliteId"),
approximateTime = parseLocalDateTime(payload.requiredString("approximateTime"), "approximateTime"),
mode = payload.optionalEnum("mode") ?: AngularMotionMode.CONST_ORIENT,
latitudeDeg = payload.requiredDouble("latitudeDeg"),
longitudeDeg = payload.requiredDouble("longitudeDeg"),
heightM = payload.optionalDouble("heightM") ?: 0.0,
durationSec = payload.requiredDouble("durationSec"),
leadAngleDeg = payload.optionalDouble("leadAngleDeg") ?: 0.0,
azimuthDeg = payload.optionalDouble("azimuthDeg") ?: 0.0,
sdi = payload.optionalDouble("sdi"),
pointInCenter = payload.optionalBoolean("pointInCenter") ?: false,
focusMm = payload.optionalDouble("focusMm"),
stepPuudSec = payload.optionalDouble("stepPuudSec"),
stepSdiSec = payload.optionalDouble("stepSdiSec"),
)
private fun Map<String, Any?>.requiredString(field: String): String {
val value = this[field]?.toString()?.trim().orEmpty()
if (value.isEmpty()) {
throw CustomValidationException("Не задан параметр $field")
}
return value
}
private fun Map<String, Any?>.requiredLong(field: String): Long =
optionalLong(field) ?: throw CustomValidationException("Не задан параметр $field")
private fun Map<String, Any?>.requiredDouble(field: String): Double =
optionalDouble(field) ?: throw CustomValidationException("Не задан параметр $field")
private fun Map<String, Any?>.optionalLong(field: String): Long? {
val value = this[field] ?: return null
return when (value) {
is Number -> value.toLong()
is String -> value.trim().takeIf { it.isNotEmpty() }?.toLongOrNull()
else -> null
} ?: throw CustomValidationException("Параметр $field должен быть целым числом")
}
private fun Map<String, Any?>.optionalDouble(field: String): Double? {
val value = this[field] ?: return null
return when (value) {
is Number -> value.toDouble()
is String -> value.trim().takeIf { it.isNotEmpty() }?.toDoubleOrNull()
else -> null
}?.takeIf { it.isFinite() } ?: throw CustomValidationException("Параметр $field должен быть числом")
}
private fun Map<String, Any?>.optionalBoolean(field: String): Boolean? {
val value = this[field] ?: return null
return when (value) {
is Boolean -> value
is String -> value.trim().takeIf { it.isNotEmpty() }?.toBooleanStrictOrNull()
else -> null
} ?: throw CustomValidationException("Параметр $field должен быть true или false")
}
private fun Map<String, Any?>.optionalEnum(field: String): AngularMotionMode? {
val value = this[field]?.toString()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
return runCatching { AngularMotionMode.valueOf(value) }
.getOrElse { throw CustomValidationException("Некорректный режим ПУУД '$value'") }
}
private fun parseLocalDateTime(value: String, field: String): LocalDateTime {
val normalized = if (value.length == 16) "$value:00" else value
return runCatching { LocalDateTime.parse(normalized) }
.getOrElse {
throw CustomValidationException(
"Некорректный формат времени $field='$value'. Ожидается yyyy-MM-dd'T'HH:mm или yyyy-MM-dd'T'HH:mm:ss",
)
}
}
}
@@ -35,7 +35,11 @@ class AngularMotionService(
)
}
.toMap()
return satelliteService.getOrbitAvailability(time)
val availability = time
?.let { satelliteService.getOrbitAvailabilityForDate(it) }
?: satelliteService.getOrbitAvailability(null)
return availability
.map { availability ->
val satellite = satellitesByBallisticsId[availability.satelliteId]
AngularMotionSatelliteDTO(
@@ -46,4 +46,22 @@ interface PDCMRepository : JpaRepository<PDCMEntity, Long>{
"""
)
fun findSatelliteOrbitAvailabilityAtTime(@Param("time") time: LocalDateTime): List<SatelliteOrbitAvailabilityDTO>
@Query(
"""
select new space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO(
p.satelliteId,
min(p.time),
max(p.time)
)
from PDCMEntity p
where p.time >= :dayStart and p.time < :dayEnd
group by p.satelliteId
order by p.satelliteId
"""
)
fun findSatelliteOrbitAvailabilityForDate(
@Param("dayStart") dayStart: LocalDateTime,
@Param("dayEnd") dayEnd: LocalDateTime
): List<SatelliteOrbitAvailabilityDTO>
}
@@ -773,6 +773,11 @@ class SatelliteService {
pdcmRepository.findSatelliteOrbitAvailabilityAtTime(time)
}
fun getOrbitAvailabilityForDate(time: LocalDateTime): List<SatelliteOrbitAvailabilityDTO> {
val dayStart = time.toLocalDate().atStartOfDay()
return pdcmRepository.findSatelliteOrbitAvailabilityForDate(dayStart, dayStart.plusDays(1))
}
fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) =
flightLineRepository.findBySatelliteIdAndTimeBetween(id,
tn?: LocalDateTime.now(),
@@ -924,4 +929,3 @@ data class SatelliteBallisticsDeletionSummary(
@@ -0,0 +1,70 @@
package space.nstart.pcp.pcp_request_service.angular
import org.junit.jupiter.api.assertThrows
import space.nstart.pcp.angularmotion.AngularMotionMode
import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException
import java.time.LocalDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class AngularMotionRequestMapperTest {
@Test
fun `maps calculation payload sent by UI`() {
val request = AngularMotionRequestMapper.calculationRequest(
mapOf(
"satelliteId" to 123L,
"approximateTime" to "2026-06-15T10:30:00",
"mode" to "CONST_ORIENT",
"latitudeDeg" to 55.75,
"longitudeDeg" to 37.61,
"heightM" to 0,
"durationSec" to 60,
"leadAngleDeg" to 0,
"azimuthDeg" to 0,
"focusMm" to 5500,
"stepPuudSec" to 0.125,
"stepSdiSec" to 20,
)
)
assertEquals(123L, request.satelliteId)
assertEquals(LocalDateTime.of(2026, 6, 15, 10, 30), request.approximateTime)
assertEquals(AngularMotionMode.CONST_ORIENT, request.mode)
assertEquals(55.75, request.latitudeDeg)
assertEquals(37.61, request.longitudeDeg)
assertFalse(request.pointInCenter)
}
@Test
fun `maps datetime without seconds`() {
val request = AngularMotionRequestMapper.calculationRequest(
mapOf(
"satelliteId" to 123L,
"approximateTime" to "2026-06-15T10:30",
"latitudeDeg" to 55.75,
"longitudeDeg" to 37.61,
"durationSec" to 60,
)
)
assertEquals(LocalDateTime.of(2026, 6, 15, 10, 30), request.approximateTime)
assertEquals(AngularMotionMode.CONST_ORIENT, request.mode)
}
@Test
fun `rejects unknown calculation mode with validation error`() {
assertThrows<CustomValidationException> {
AngularMotionRequestMapper.calculationRequest(
mapOf(
"satelliteId" to 123L,
"approximateTime" to "2026-06-15T10:30:00",
"mode" to "UNKNOWN",
"latitudeDeg" to 55.75,
"longitudeDeg" to 37.61,
"durationSec" to 60,
)
)
}
}
}
@@ -82,6 +82,25 @@ class PDCMRepositoryTest {
assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual.single().timeStop)
}
@Test
fun `find satellite orbit availability for date returns satellites with points on requested date`() {
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0))
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 12, 0))
persistPoint(22L, LocalDateTime.of(2026, 4, 14, 23, 30))
persistPoint(33L, LocalDateTime.of(2026, 4, 15, 9, 30))
val actual = repository.findSatelliteOrbitAvailabilityForDate(
LocalDateTime.of(2026, 4, 14, 0, 0),
LocalDateTime.of(2026, 4, 15, 0, 0)
)
assertEquals(listOf(11L, 22L), actual.map { it.satelliteId })
assertEquals(LocalDateTime.of(2026, 4, 14, 10, 0), actual[0].timeStart)
assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual[0].timeStop)
assertEquals(LocalDateTime.of(2026, 4, 14, 23, 30), actual[1].timeStart)
assertEquals(LocalDateTime.of(2026, 4, 14, 23, 30), actual[1].timeStop)
}
@Test
fun `satellite initial conditions repository returns last calculated condition`() {
val oldCondition = satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 1L))