diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/DropRouteDTOs.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/DropRouteDTOs.kt new file mode 100644 index 0000000..ff21636 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/DropRouteDTOs.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +/** + * Запрос на ручное добавление сброса в план по выбранной зоне радиовидимости (ЗРВ). + * + * Оператор выбирает ЗРВ станции и набор уже запланированных маршрутов съёмки, время конца + * которых строго меньше начала зоны (т.е. съёмка успевает завершиться до сеанса сброса). + * Сброс сохраняется как [space.nstart.pcp.pcp_mission_planing_service]-режим DROP под основным + * планом миссии (planNumber = 1) с теми же связями съёмка↔сброс, что и при штатном расчёте. + */ +@Schema(description = "Запрос на ручное добавление сброса по выбранной зоне радиовидимости") +data class DropRouteSaveRequestDTO( + @field:Schema(description = "Идентификатор КА (NORAD)") + val satelliteId: Long = 0, + + @field:Schema(description = "Идентификатор/номер станции приёма (из ЗРВ)") + val station: String = "", + + @field:Schema(description = "Номер витка зоны радиовидимости") + val revolution: Long = 0, + + @field:Schema(description = "Начало зоны радиовидимости (локальное время без зоны)") + val zoneStart: LocalDateTime = LocalDateTime.now(), + + @field:Schema(description = "Конец зоны радиовидимости (локальное время без зоны)") + val zoneStop: LocalDateTime = LocalDateTime.now(), + + @field:Schema(description = "Идентификаторы режимов съёмки (survey_modes), которые сбрасываются в этой зоне") + val surveyModeIds: List = emptyList(), + + @field:Schema( + description = "Начало интервала плана (локальное время без зоны). Используется только при " + + "сохранении, если миссии/плана с таким id ещё нет — чтобы создать их с корректным окном.", + nullable = true + ) + val planStart: LocalDateTime? = null, + + @field:Schema( + description = "Конец интервала плана (локальное время без зоны). См. planStart.", + nullable = true + ) + val planEnd: LocalDateTime? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt new file mode 100644 index 0000000..29afdfd --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt @@ -0,0 +1,77 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.DecimalMin +import java.time.LocalDateTime + +/** + * Способ построения временного окна маршрута относительно момента видимости. + */ +enum class SurveyRouteAnchor { + /** Окно центрируется на моменте видимости: [time - duration/2, time + duration/2]. */ + CENTERED, + + /** Момент видимости — начало съёмки: [time, time + duration]. */ + FORWARD +} + +@Schema(description = "Запрос на построение одного маршрута съёмки по параметрам видимости точки") +data class SurveyRouteBuildRequestDTO( + @field:Schema(description = "Идентификатор КА (тот же, что используется в obj-view/mpl-point — NORAD)") + val satelliteId: Long = 0, + + @field:Schema(description = "Момент видимости (локальное время без зоны)") + val time: LocalDateTime = LocalDateTime.now(), + + @field:DecimalMin(value = "1", message = "Длительность съёмки должна быть не меньше 1 секунды") + @field:Schema(description = "Длительность съёмки, секунды") + val durationSeconds: Double = 60.0, + + @field:Schema(description = "Крен (gamma), градусы") + val roll: Double = 0.0, + + @field:Schema(description = "Угол захвата (полуширина полосы), градусы") + val captureAngle: Double = 1.5, + + @field:Schema(description = "Номер витка (0 — определить по орбитальным точкам)") + val revolution: Long = 0, + + @field:Schema(description = "Способ построения окна относительно момента видимости") + val anchor: SurveyRouteAnchor = SurveyRouteAnchor.CENTERED, + + @field:Schema( + description = "Начало интервала плана (локальное время без зоны). Используется только при " + + "сохранении маршрута, если миссии/плана с таким id ещё нет — чтобы создать их с " + + "корректным окном (для расчёта пересечений и наложений). Игнорируется в /build.", + nullable = true + ) + val planStart: LocalDateTime? = null, + + @field:Schema( + description = "Конец интервала плана (локальное время без зоны). См. planStart.", + nullable = true + ) + val planEnd: LocalDateTime? = null +) + +@Schema(description = "Построенный маршрут съёмки") +data class SurveyRouteBuildResponseDTO( + val satelliteId: Long, + @field:Schema(description = "Начало съёмки (локальное время без зоны)") + val timeStart: LocalDateTime, + @field:Schema(description = "Длительность съёмки, секунды") + val duration: Double, + val revolution: Long, + @field:Schema(description = "Крен (gamma), градусы") + val roll: Double, + @field:Schema(description = "Угол захвата, градусы") + val captureAngle: Double, + @field:Schema(description = "Широта центра контура, градусы") + val lat: Double, + @field:Schema(description = "Долгота центра контура, градусы") + val longitude: Double, + @field:Schema(description = "Контур полосы съёмки, WKT POLYGON (lon lat, ...)") + val contourWkt: String, + @field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (null — маршрут не сохранялся)") + val surveyModeId: Long? = null +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index 419f7cb..241e79c 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -20,8 +20,10 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService import java.time.LocalDateTime import java.util.UUID @@ -34,6 +36,54 @@ class MissionController( private val missionPlaningService: MissionPlaningService ) { + @Operation( + summary = "Построить один маршрут съёмки", + description = "Строит контур полосы съёмки по параметрам видимости точки (момент, крен, угол захвата). " + + "Орбитальные точки берутся из pcp-ballistics-service, контур — тем же алгоритмом, что и при расчёте плана съёмки. " + + "Маршрут не сохраняется в БД, только возвращается клиенту (live-превью)." + ) + @PostMapping("/survey-route/build") + fun buildSurveyRoute(@Valid @RequestBody body: SurveyRouteBuildRequestDTO) = + missionPlaningService.buildSurveyRoute(body) + + @Operation( + summary = "Построить и сохранить маршрут съёмки по выбранному плану", + description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " + + "и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " + + "planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " + + "Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Маршрут построен и сохранён") + ] + ) + @PostMapping("/{plan_id}/survey-route") + fun saveSurveyRoute( + @Parameter(description = "Идентификатор выбранного плана (= mission_id)") + @PathVariable("plan_id") planId: UUID, + @Valid @RequestBody body: SurveyRouteBuildRequestDTO + ) = missionPlaningService.saveSurveyRoute(planId, body) + + @Operation( + summary = "Добавить сброс вручную по выбранной зоне радиовидимости", + description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " + + "и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " + + "planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " + + "Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Сброс добавлен") + ] + ) + @PostMapping("/{plan_id}/drop-route") + fun saveDropRoute( + @Parameter(description = "Идентификатор выбранного плана (= mission_id)") + @PathVariable("plan_id") planId: UUID, + @Valid @RequestBody body: DropRouteSaveRequestDTO + ) = missionPlaningService.saveDropRoute(planId, body) + @Operation(summary = "Получить список миссий") @GetMapping fun all() = missionPlaningService.all() diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt index a5bd595..e75ccfc 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt @@ -24,6 +24,20 @@ interface ModeRepository : JpaRepository { @Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true) fun deleteSurveyModesByPlanId(@Param("planId") planId: Long): Int + /** + * Удаляет только пересчитываемые маршруты съёмки плана, сохраняя ручные + * (source = 'MANUAL'). Используется при пересчёте плана (этап 5, loadSlots), + * чтобы не затирать добавленные оператором съёмки. + */ + @Modifying + @Transactional + @Query( + "DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY' " + + "AND id NOT IN (SELECT id FROM survey_modes WHERE source = 'MANUAL')", + nativeQuery = true + ) + fun deleteRecalculableSurveyModesByPlanId(@Param("planId") planId: Long): Int + @Modifying @Transactional @Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index c27c6a2..051c96e 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -20,11 +20,15 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity @@ -349,7 +353,12 @@ class MissionPlaningService( survs.count { it.bookedSlotIds.isNotEmpty() } ) - modeRepository.deleteSurveyModesByPlanId(planId) + val removedRecalculable = modeRepository.deleteRecalculableSurveyModesByPlanId(planId) + logger.info( + "Пересчёт плана {}: удалено {} пересчитываемых маршрутов съёмки, ручные (source=MANUAL) сохранены", + planId, + removedRecalculable + ) if (survs.isEmpty()) { val finishedAt = LocalDateTime.now() @@ -499,6 +508,305 @@ class MissionPlaningService( ) + /** + * Построение одного маршрута съёмки по параметрам видимости точки + * (используется вкладкой «Редактор» в pcp-tgu-ops-ui для построения включений съёмок). + * + * Stateless: орбитальные точки берутся из pcp-ballistics-service (extract-time), контур + * полосы строится тем же алгоритмом [contour], что и при штатном расчёте маршрутов + * (этап 5, [loadSlots] → snapshot этапа 4). Маршрут только возвращается клиенту для + * отображения на картооснове (live-превью при выборе момента видимости), без персистенции. + * Сохранение построенного маршрута выполняет [saveSurveyRoute]. + */ + fun buildSurveyRoute(req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO = + buildContour(req).toResponse(surveyModeId = null) + + /** + * Построение и сохранение одного маршрута съёмки в БД по идентификатору выбранного плана + * в сервисе pcp-tgu-service. + * + * Идентификатор плана (`planId` из PlannedPlanEntity в pcp-tgu-service) совпадает с + * `MissionEntity.missionId` в этом сервисе (см. BPMN createSatelliteMission: переменная + * `satellitePlanId` → `MissionRequestDTO.missionId`). Маршрут сохраняется как + * [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии + * (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана. + * + * Если миссии/плана с таким идентификатором ещё нет (план не рассчитывался), они + * создаются на лету по параметрам запроса: `missionId = planId`, `satelliteId` из запроса + * (NORAD), интервал — окно построенного маршрута. При последующем штатном расчёте + * [loadSlots] интервал миссии будет уточнён. + * + * Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только + * пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]). + */ + @Transactional + fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO { + val built = buildContour(req) + + val mission = missionRepository.findById(planId).orElseGet { + // Интервал плана берём из запроса (фронт знает окно выбранного плана). Если не передан — + // фолбэк на окно самого маршрута; при штатном расчёте [loadSlots] интервал будет уточнён. + val routeStop = built.timeStart.plusNanos((built.durationSeconds * 1_000_000_000L).toLong()) + val missionStart = req.planStart ?: built.timeStart + val missionStop = req.planEnd ?: routeStop + logger.info( + "Миссия/план {} не найдены — создаём под ручной маршрут: satelliteId={}, interval=[{} - {}] (источник интервала: {})", + planId, + req.satelliteId, + missionStart, + missionStop, + if (req.planStart != null && req.planEnd != null) "план" else "окно маршрута" + ) + missionRepository.save( + MissionEntity( + missionId = planId, + satelliteId = req.satelliteId, + missionStart = missionStart, + missionStop = missionStop, + status = "NEW" + ) + ) + } + + val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) + .orElseGet { + planRepository.save( + PlanEntity( + mission = mission, + planStart = mission.missionStart, + planStop = mission.missionStop, + planNumber = 1 + ) + ) + } + + val saved = surveyModeRepository.save( + SurveyModeEntity( + plan = plan, + timeStart = built.timeStart.atOffset(ZoneOffset.UTC), + revolution = built.revolution, + lat = built.lat, + long = built.longitude, + duration = built.durationSeconds, + contourWkt = built.contourWkt, + roll = built.roll, + status = SurveyModeStatus.PLANNED, + source = SurveyModeSource.MANUAL + ) + ) + + logger.info( + "Сохранён ручной маршрут съёмки (survey_mode id={}) для плана/миссии {}: revolution={}, timeStart={}, duration={}", + saved.id, + planId, + built.revolution, + built.timeStart, + built.durationSeconds + ) + + return built.toResponse(surveyModeId = saved.id) + } + + /** + * Ручное добавление сброса в план по выбранной оператором зоне радиовидимости (ЗРВ). + * + * Оператор на вкладке «Редактор» выбирает ЗРВ станции (из pcp-ballistics) и набор уже + * запланированных маршрутов съёмки, время конца которых строго меньше начала зоны. Сброс + * сохраняется как [DropModeEntity] под основным планом миссии (planNumber = 1) со связями + * съёмка↔сброс ([DropModeSurveyEntity]) — той же моделью, что и при штатном расчёте + * ([calculateDrops]). + * + * Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute). + * Если миссии/плана с таким id ещё нет — создаются на лету по параметрам запроса (окно плана + * из `planStart/planEnd`, фолбэк — окно зоны). + * + * Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все + * сбросы плана ([ModeRepository.deleteDropModesByPlanId]). + */ + @Transactional + fun saveDropRoute(planId: UUID, req: DropRouteSaveRequestDTO): DropModeResponseDTO { + if (req.surveyModeIds.isEmpty()) { + throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса") + } + + val mission = missionRepository.findById(planId).orElseGet { + val missionStart = req.planStart ?: req.zoneStart + val missionStop = req.planEnd ?: req.zoneStop + logger.info( + "Миссия/план {} не найдены — создаём под ручной сброс: satelliteId={}, interval=[{} - {}] (источник интервала: {})", + planId, + req.satelliteId, + missionStart, + missionStop, + if (req.planStart != null && req.planEnd != null) "план" else "окно зоны" + ) + missionRepository.save( + MissionEntity( + missionId = planId, + satelliteId = req.satelliteId, + missionStart = missionStart, + missionStop = missionStop, + status = "NEW" + ) + ) + } + + val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) + .orElseGet { + planRepository.save( + PlanEntity( + mission = mission, + planStart = mission.missionStart, + planStop = mission.missionStop, + planNumber = 1 + ) + ) + } + + // Грузим выбранные съёмки и валидируем: принадлежат этому плану и заканчиваются строго + // до начала зоны (съёмка успевает завершиться к сеансу сброса). + val surveys = surveyModeRepository.findAllById(req.surveyModeIds) + val foundIds = surveys.mapNotNull { it.id }.toSet() + val missing = req.surveyModeIds.filterNot { it in foundIds } + if (missing.isNotEmpty()) { + throw CustomErrorException("Маршруты съёмки не найдены: $missing") + } + surveys.forEach { survey -> + if (survey.plan?.id != plan.id) { + throw CustomErrorException("Маршрут съёмки ${survey.id} не относится к плану $planId") + } + val start = survey.timeStart + ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала") + val surveyEnd = start.toLocalDateTime() + .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) + if (!surveyEnd.isBefore(req.zoneStart)) { + throw CustomErrorException( + "Маршрут съёмки ${survey.id} заканчивается ($surveyEnd) не раньше начала зоны (${req.zoneStart}) — сброс невозможен" + ) + } + } + + // Длительность сброса — сумма длительностей сброса по каждой съёмке (как в calculateDrops). + val usedDuration = surveys.sumOf { calculateDropDuration(it).coerceAtLeast(0.0) } + val zoneDuration = Duration.between(req.zoneStart, req.zoneStop).toMillis().toDouble() / 1000.0 + if (zoneDuration > 0.0 && usedDuration > zoneDuration) { + throw CustomErrorException( + "Суммарная длительность сброса ($usedDuration с) превышает длительность зоны ($zoneDuration с)" + ) + } + + val dropMode = dropModeRepository.save( + DropModeEntity( + plan = plan, + timeStart = req.zoneStart.atOffset(ZoneOffset.UTC), + revolution = req.revolution, + station = req.station, + duration = usedDuration + ) + ) + val links = surveys.map { survey -> DropModeSurveyEntity(dropMode = dropMode, surveyMode = survey) } + dropModeSurveyRepository.saveAll(links) + + logger.info( + "Сохранён ручной сброс (drop_mode id={}) для плана/миссии {}: станция={}, виток={}, зона={}, съёмок={}, длительность={}", + dropMode.id, planId, req.station, req.revolution, req.zoneStart, surveys.size, usedDuration + ) + + return DropModeResponseDTO( + id = dropMode.id ?: 0, + planId = plan.id ?: 0, + timeStart = dropMode.timeStart, + revolution = dropMode.revolution, + station = dropMode.station, + duration = dropMode.duration, + surveys = surveys.mapNotNull { it.id } + ) + } + + /** + * Строит контур полосы съёмки по параметрам видимости точки. + * Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute]. + */ + private fun buildContour(req: SurveyRouteBuildRequestDTO): BuiltSurveyRoute { + if (req.durationSeconds <= 0.0) { + throw CustomErrorException("Длительность съёмки должна быть положительной") + } + + val halfNanos = (req.durationSeconds / 2.0 * 1_000_000_000L).toLong() + val fullNanos = (req.durationSeconds * 1_000_000_000L).toLong() + val (tn, tk) = when (req.anchor) { + SurveyRouteAnchor.CENTERED -> req.time.minusNanos(halfNanos) to req.time.plusNanos(halfNanos) + SurveyRouteAnchor.FORWARD -> req.time to req.time.plusNanos(fullNanos) + } + + val orbPoints = ballisticsService.exactTime( + req.satelliteId, + ExactTimePositionRequestDTO(time = tn, timeStop = tk, stepMs = 5000) + ).toList() + + if (orbPoints.isEmpty()) { + throw CustomErrorException( + "Нет орбитальных точек для КА ${req.satelliteId} на интервале [$tn - $tk]" + ) + } + + val stepper = RungeStepper( + orbPoints.map { p -> + OrbitalPoint( + t = fromDateTime(p.time), + vit = p.revolution.toInt(), + r = Vector3D(p.x, p.y, p.z), + v = Vector3D(p.vx, p.vy, p.vz) + ) + }.toMutableList(), + EarthType.PZ90d02 + ) + + val wkt = contour(stepper, tn, tk, req.roll, req.captureAngle) + val (lat, lon) = extractCenterLatLon(wkt) + val revolution = if (req.revolution != 0L) req.revolution else orbPoints.first().revolution + + return BuiltSurveyRoute( + satelliteId = req.satelliteId, + timeStart = tn, + durationSeconds = req.durationSeconds, + revolution = revolution, + roll = req.roll, + captureAngle = req.captureAngle, + lat = lat, + longitude = lon, + contourWkt = wkt + ) + } + + /** Результат расчёта контура одного маршрута съёмки (до сохранения в БД). */ + private data class BuiltSurveyRoute( + val satelliteId: Long, + val timeStart: LocalDateTime, + val durationSeconds: Double, + val revolution: Long, + val roll: Double, + val captureAngle: Double, + val lat: Double, + val longitude: Double, + val contourWkt: String + ) + + private fun BuiltSurveyRoute.toResponse(surveyModeId: Long?): SurveyRouteBuildResponseDTO = + SurveyRouteBuildResponseDTO( + satelliteId = satelliteId, + timeStart = timeStart, + duration = durationSeconds, + revolution = revolution, + roll = roll, + captureAngle = captureAngle, + lat = lat, + longitude = longitude, + contourWkt = contourWkt, + surveyModeId = surveyModeId + ) + + private fun contour( stepper: AbstractStepper, tn: LocalDateTime,