Merge branch 'dev' into route-processing
Conflict resolution: - build.gradle.kts: dev version (keep nstart.cloud repo + jacoco/asm mavenCentral) - settings.gradle.kts: project name from route-processing (observatio-terrae), rest from dev - config addresses (application-local, pcp-mission-planing-service, pcp-dynamic-plan application.yaml): dev versions - menu.html: merged both branches (TGU planning + bookings items) - CatalogControllerTest.kt: merged both mocks (TguPlanningService + BallisticsService) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+88
@@ -0,0 +1,88 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.springframework.format.annotation.DateTimeFormat
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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.BookedSlotDetailsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
|
||||
import space.nstart.pcp.slots_service.dto.bookings.BookedSlotViewDTO
|
||||
import space.nstart.pcp.slots_service.dto.bookings.BookingFilterOptionsDTO
|
||||
import space.nstart.pcp.slots_service.dto.bookings.BookingRequestOptionDTO
|
||||
import space.nstart.pcp.slots_service.service.EarthService
|
||||
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/bookings")
|
||||
class BookingsController(
|
||||
private val slotService: SlotService,
|
||||
private val satelliteCatalogService: SatelliteCatalogService,
|
||||
private val earthService: EarthService,
|
||||
) {
|
||||
|
||||
@GetMapping("/options")
|
||||
fun options(): BookingFilterOptionsDTO {
|
||||
val satellites = satelliteCatalogService.allSatellites()
|
||||
val requests = runCatching {
|
||||
earthService.reqs()
|
||||
.collectList()
|
||||
.block()
|
||||
.orEmpty()
|
||||
.mapNotNull { request ->
|
||||
val id = request.requestId?.toString() ?: return@mapNotNull null
|
||||
BookingRequestOptionDTO(id = id, name = request.name)
|
||||
}
|
||||
.sortedWith(compareBy<BookingRequestOptionDTO> { it.name.ifBlank { it.id } }.thenBy { it.id })
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
return BookingFilterOptionsDTO(
|
||||
satellites = satellites,
|
||||
requests = requests,
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
fun bookedSlots(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStart: LocalDateTime?,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStop: LocalDateTime?,
|
||||
@RequestParam(required = false) requestId: String?,
|
||||
@RequestParam(required = false) satelliteIds: List<Long>?,
|
||||
): List<BookedSlotViewDTO> {
|
||||
val satellitesById = satelliteCatalogService.allSatellites().associateBy { it.id }
|
||||
return slotService.searchBooked(
|
||||
timeStart = timeStart,
|
||||
timeStop = timeStop,
|
||||
requestId = requestId,
|
||||
satelliteIds = satelliteIds,
|
||||
)
|
||||
.map { booking -> booking.toView(satellitesById[booking.satelliteId]) }
|
||||
.sortedWith(
|
||||
compareBy<BookedSlotViewDTO> { it.timeStart ?: LocalDateTime.MAX }
|
||||
.thenBy { it.satelliteId }
|
||||
.thenBy { it.slotNum }
|
||||
.thenBy { it.cycle }
|
||||
.thenBy { it.bookedSlotId }
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookedSlotDetailsDTO.toView(satellite: SatelliteSummaryDTO?): BookedSlotViewDTO = BookedSlotViewDTO(
|
||||
bookedSlotId = bookedSlotId,
|
||||
satelliteId = satelliteId,
|
||||
satelliteCode = satellite?.code,
|
||||
satelliteName = satellite?.name,
|
||||
satelliteTypeCode = satellite?.typeCode,
|
||||
slotNum = slotNum,
|
||||
cycle = cycle,
|
||||
timeStart = timeStart,
|
||||
timeStop = timeStop,
|
||||
durationSeconds = durationSeconds,
|
||||
roll = roll,
|
||||
revolution = revolution,
|
||||
revolutionSign = revolutionSign,
|
||||
status = status,
|
||||
requestIds = requestIds,
|
||||
)
|
||||
}
|
||||
+9
@@ -18,6 +18,15 @@ class CatalogPageController {
|
||||
@GetMapping("/satellites/pdcm")
|
||||
fun satellitesPdcmPage(): String = "satellites-pdcm"
|
||||
|
||||
@GetMapping("/tle-comparison")
|
||||
fun tleComparisonPage(): String = "tle-comparison"
|
||||
|
||||
@GetMapping("/current-plans")
|
||||
fun currentPlansPage(): String = "current-plans"
|
||||
|
||||
@GetMapping("/bookings")
|
||||
fun bookingsPage(): String = "bookings"
|
||||
|
||||
@GetMapping("/tle-analysis")
|
||||
fun tleAnalysisPage(): String = "tle-analysis"
|
||||
}
|
||||
|
||||
+61
-1
@@ -1,6 +1,7 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@@ -11,7 +12,9 @@ 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 org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
|
||||
@@ -20,10 +23,14 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileD
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO
|
||||
import space.nstart.pcp.slots_service.service.GroupStateService
|
||||
import space.nstart.pcp.slots_service.service.BallisticsService
|
||||
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
|
||||
import space.nstart.pcp.slots_service.service.SatellitePdcmService
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.format.ResolverStyle
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/catalog")
|
||||
@@ -31,8 +38,11 @@ class CatalogRestController(
|
||||
private val satelliteCatalogService: SatelliteCatalogService,
|
||||
private val slotService: SlotService,
|
||||
private val groupStateService: GroupStateService,
|
||||
private val satellitePdcmService: SatellitePdcmService
|
||||
private val satellitePdcmService: SatellitePdcmService,
|
||||
private val ballisticsService: BallisticsService
|
||||
) {
|
||||
private val sendInitialConditionsTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm:ss.SSS").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
@GetMapping("/satellites")
|
||||
fun allSatellites() = satelliteCatalogService.allSatellites()
|
||||
@@ -56,6 +66,56 @@ class CatalogRestController(
|
||||
@GetMapping("/satellites/pdcm/{satelliteId}")
|
||||
fun satellitePdcm(@PathVariable satelliteId: Long) = satellitePdcmService.satelliteDetails(satelliteId)
|
||||
|
||||
@PostMapping("/satellites/pdcm/{satelliteId}/send-ic")
|
||||
fun sendSatelliteInitialConditions(
|
||||
@PathVariable satelliteId: Long,
|
||||
@RequestParam(required = false) time: String?
|
||||
): ResponseEntity<Void> {
|
||||
val requestedTime = parseSendInitialConditionsTime(time)
|
||||
slotService.sendSatelliteInitialConditions(satelliteId, requestedTime)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@PostMapping("/satellites/pdcm/{satelliteId}/initial-conditions")
|
||||
fun setSatelliteInitialConditions(
|
||||
@PathVariable satelliteId: Long,
|
||||
@RequestBody request: SatelliteICDTO
|
||||
): ResponseEntity<Void> {
|
||||
ballisticsService.receiveRv(
|
||||
SatelliteICDTO(
|
||||
satelliteId = satelliteId,
|
||||
ic = request.ic,
|
||||
movementModel = request.movementModel
|
||||
)
|
||||
)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@GetMapping("/satellites/pdcm/{satelliteId}/initial-conditions")
|
||||
fun currentPdcmInitialConditions(@PathVariable satelliteId: Long): ResponseEntity<Any> {
|
||||
val response = ballisticsService.currentInitialConditions(satelliteId)
|
||||
return if (response == null) {
|
||||
ResponseEntity.noContent().build()
|
||||
} else {
|
||||
ResponseEntity.ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSendInitialConditionsTime(time: String?): LocalDateTime {
|
||||
val normalizedTime = time?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: return LocalDateTime.now()
|
||||
|
||||
return try {
|
||||
LocalDateTime.parse(normalizedTime, sendInitialConditionsTimeFormatter)
|
||||
} catch (exception: DateTimeParseException) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Параметр time должен быть в формате dd.mm.yyyy hh:MM:ss.zzz",
|
||||
exception
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/satellites")
|
||||
fun createSatellite(@Valid @RequestBody request: SatelliteCreateDTO) =
|
||||
satelliteCatalogService.createSatellite(request)
|
||||
|
||||
+9
-1
@@ -11,13 +11,15 @@ import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
|
||||
import space.nstart.pcp.slots_service.service.MissionService
|
||||
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/current-plans")
|
||||
class CurrentPlansController(
|
||||
private val satelliteCatalogService: SatelliteCatalogService,
|
||||
private val missionService: MissionService
|
||||
private val missionService: MissionService,
|
||||
private val slotService: SlotService
|
||||
) {
|
||||
|
||||
@GetMapping("/satellites")
|
||||
@@ -36,6 +38,9 @@ class CurrentPlansController(
|
||||
@GetMapping("/missions/{missionId}/modes")
|
||||
fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId)
|
||||
|
||||
@GetMapping("/booked-slots")
|
||||
fun bookedSlots(@RequestParam ids: List<Long>) = slotService.bookedByIds(ids)
|
||||
|
||||
@PostMapping("/missions/{missionId}/surveys/calculate")
|
||||
fun calculateSurveys(
|
||||
@PathVariable missionId: UUID,
|
||||
@@ -44,4 +49,7 @@ class CurrentPlansController(
|
||||
|
||||
@PostMapping("/missions/{missionId}/drops/calculate")
|
||||
fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId)
|
||||
|
||||
@PostMapping("/missions/{missionId}/confirm")
|
||||
fun confirmMission(@PathVariable missionId: UUID) = missionService.confirmMission(missionId)
|
||||
}
|
||||
|
||||
+33
@@ -57,4 +57,37 @@ class RvaRestController(
|
||||
@PathVariable duration: Long,
|
||||
@RequestBody stations: List<StationDTO>
|
||||
) = slotService.rvaMergeOnRev(satelliteId, duration, stations)
|
||||
|
||||
@PostMapping("/average/{satelliteId}/{duration}")
|
||||
@ResponseBody
|
||||
fun rvaAverage(
|
||||
@PathVariable satelliteId: Long,
|
||||
@PathVariable duration: Long,
|
||||
@RequestBody stations: List<StationDTO>
|
||||
): List<RvaAverageDTO> {
|
||||
val days = duration.coerceAtLeast(1).toDouble()
|
||||
val zonesByStation = slotService.rvaCommon(satelliteId, duration, stations)
|
||||
.groupBy { it.stationId }
|
||||
|
||||
return stations
|
||||
.sortedBy { it.number }
|
||||
.map { station ->
|
||||
val zones = zonesByStation[station.number.toLong()].orEmpty()
|
||||
val zoneCount = zones.size
|
||||
val totalDurationSec = zones.sumOf { it.duration }
|
||||
RvaAverageDTO(
|
||||
ppiNumber = station.number.toLong(),
|
||||
ppiLatitude = station.position.lat,
|
||||
averageCount = zoneCount / days,
|
||||
averageDurationSec = if (zoneCount == 0) 0.0 else totalDurationSec / zoneCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RvaAverageDTO(
|
||||
val ppiNumber: Long,
|
||||
val ppiLatitude: Double,
|
||||
val averageCount: Double,
|
||||
val averageDurationSec: Double
|
||||
)
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
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.slots_service.service.TleAnalysisService
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tle-analysis")
|
||||
class TleAnalysisController(
|
||||
private val tleAnalysisService: TleAnalysisService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@GetMapping("/satellites")
|
||||
fun satellites() =
|
||||
tleAnalysisService.satellites().also { satellites ->
|
||||
logger.info("TLE analysis satellites request completed: satellites={}", satellites.size)
|
||||
}
|
||||
|
||||
@GetMapping("/satellites/{satelliteId}")
|
||||
fun analyzeSatellite(
|
||||
@PathVariable satelliteId: Long,
|
||||
@RequestParam(defaultValue = "5") tleCount: Int
|
||||
) = tleAnalysisService.analyzeSatellite(satelliteId, tleCount).also { response ->
|
||||
logger.info(
|
||||
"TLE analysis report request completed: satelliteId={}, requestedTleCount={}, rows={}",
|
||||
satelliteId,
|
||||
tleCount,
|
||||
response.rows.size
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/satellites/{satelliteId}/analysis")
|
||||
fun analyzeSatelliteDetails(
|
||||
@PathVariable satelliteId: Long,
|
||||
@RequestParam(defaultValue = "5") tleCount: Int
|
||||
) = analyzeSatellite(satelliteId, tleCount)
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
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_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomValidationException
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareRequestDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareResponseDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareRowDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TleParsedDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TlePositionDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TlePositionDeltaDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TleTextRequestDTO
|
||||
import space.nstart.pcp.slots_service.service.BallisticsService
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tle-comparison")
|
||||
class TleComparisonController(
|
||||
private val ballisticsService: BallisticsService
|
||||
) {
|
||||
|
||||
@PostMapping("/parse")
|
||||
fun parse(@RequestBody request: TleTextRequestDTO): TleParsedDTO =
|
||||
ballisticsService.parseTle(parseTleText(request.tle))
|
||||
|
||||
@PostMapping("/compare")
|
||||
fun compare(@RequestBody request: TleCompareRequestDTO): TleCompareResponseDTO {
|
||||
val firstTle = parseTleText(request.first)
|
||||
val secondTle = parseTleText(request.second)
|
||||
val firstParsed = ballisticsService.parseTle(firstTle)
|
||||
val secondParsed = ballisticsService.parseTle(secondTle)
|
||||
val calculationTime = request.time
|
||||
?: throw CustomValidationException("Задайте время сравнения TLE")
|
||||
val firstEpoch = firstParsed.time
|
||||
?: throw CustomValidationException("Не удалось определить эпоху первого TLE")
|
||||
val secondEpoch = secondParsed.time
|
||||
?: throw CustomValidationException("Не удалось определить эпоху второго TLE")
|
||||
val maxEpoch = maxOf(firstEpoch, secondEpoch)
|
||||
if (calculationTime < maxEpoch) {
|
||||
throw CustomValidationException("Время сравнения должно быть не раньше максимальной эпохи TLE: $maxEpoch")
|
||||
}
|
||||
|
||||
val firstPoint = ballisticsService.tlePoint(firstTle, calculationTime).toPosition()
|
||||
val secondPoint = ballisticsService.tlePoint(secondTle, calculationTime).toPosition()
|
||||
val delta = TlePositionDeltaDTO(
|
||||
dx = secondPoint.x - firstPoint.x,
|
||||
dy = secondPoint.y - firstPoint.y,
|
||||
dz = secondPoint.z - firstPoint.z,
|
||||
norm = distance(secondPoint.x - firstPoint.x, secondPoint.y - firstPoint.y, secondPoint.z - firstPoint.z)
|
||||
)
|
||||
|
||||
return TleCompareResponseDTO(
|
||||
time = calculationTime,
|
||||
firstEpoch = firstEpoch,
|
||||
secondEpoch = secondEpoch,
|
||||
first = firstPoint,
|
||||
second = secondPoint,
|
||||
radiusVectorDelta = delta,
|
||||
rows = buildRows(firstPoint, secondPoint, delta)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTleText(raw: String): TLEDTO {
|
||||
val lines = raw.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
if (lines.isEmpty()) {
|
||||
throw CustomValidationException("Введите TLE")
|
||||
}
|
||||
|
||||
val firstIndex = lines.indexOfFirst { it.startsWith("1 ") }
|
||||
val secondIndex = lines.indexOfFirst { it.startsWith("2 ") }
|
||||
if (firstIndex < 0 || secondIndex < 0) {
|
||||
throw CustomValidationException("TLE должен содержать строки 1 и 2")
|
||||
}
|
||||
|
||||
val first = lines[firstIndex]
|
||||
val second = lines[secondIndex]
|
||||
val header = lines.take(firstIndex)
|
||||
.firstOrNull { !it.startsWith("1 ") && !it.startsWith("2 ") }
|
||||
|
||||
if (first.length < 69 || second.length < 69) {
|
||||
throw CustomValidationException("Строки TLE должны содержать не менее 69 символов")
|
||||
}
|
||||
|
||||
return TLEDTO(
|
||||
header = header,
|
||||
first = first.take(69),
|
||||
second = second.take(69)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildRows(
|
||||
first: TlePositionDTO,
|
||||
second: TlePositionDTO,
|
||||
delta: TlePositionDeltaDTO
|
||||
): List<TleCompareRowDTO> = listOf(
|
||||
timeRow("time", "Время расчета", first.time, second.time),
|
||||
textRow("revolution", "Виток", first.revolution.toString(), second.revolution.toString()),
|
||||
numberRow("x", "X радиус-вектора, м", first.x, second.x),
|
||||
numberRow("y", "Y радиус-вектора, м", first.y, second.y),
|
||||
numberRow("z", "Z радиус-вектора, м", first.z, second.z),
|
||||
numberRow("radiusNorm", "Модуль радиус-вектора, м", first.radiusNorm, second.radiusNorm),
|
||||
numberRow("vx", "Vx, м/с", first.vx, second.vx),
|
||||
numberRow("vy", "Vy, м/с", first.vy, second.vy),
|
||||
numberRow("vz", "Vz, м/с", first.vz, second.vz),
|
||||
numberRow("velocityNorm", "Модуль скорости, м/с", first.velocityNorm, second.velocityNorm),
|
||||
TleCompareRowDTO("dx", "ΔX = X2 - X1, м", first = null, second = null, delta = delta.dx.formatSignedNumber()),
|
||||
TleCompareRowDTO("dy", "ΔY = Y2 - Y1, м", first = null, second = null, delta = delta.dy.formatSignedNumber()),
|
||||
TleCompareRowDTO("dz", "ΔZ = Z2 - Z1, м", first = null, second = null, delta = delta.dz.formatSignedNumber()),
|
||||
TleCompareRowDTO("radiusDeltaNorm", "|Δr|, м", first = null, second = null, delta = delta.norm.formatNumber())
|
||||
)
|
||||
|
||||
private fun OrbPointDTO.toPosition(): TlePositionDTO = TlePositionDTO(
|
||||
time = time,
|
||||
revolution = revolution,
|
||||
x = x,
|
||||
y = y,
|
||||
z = z,
|
||||
vx = vx,
|
||||
vy = vy,
|
||||
vz = vz,
|
||||
radiusNorm = distance(x, y, z),
|
||||
velocityNorm = distance(vx, vy, vz)
|
||||
)
|
||||
|
||||
private fun textRow(code: String, name: String, first: String?, second: String?): TleCompareRowDTO =
|
||||
TleCompareRowDTO(
|
||||
code = code,
|
||||
name = name,
|
||||
first = first,
|
||||
second = second,
|
||||
delta = if (first != null && second != null && first != second) "изменено" else null
|
||||
)
|
||||
|
||||
private fun timeRow(code: String, name: String, first: LocalDateTime?, second: LocalDateTime?): TleCompareRowDTO {
|
||||
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
|
||||
val delta = if (first != null && second != null) {
|
||||
val seconds = Duration.between(first, second).seconds
|
||||
formatSecondsDelta(seconds)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return TleCompareRowDTO(code, name, first?.format(formatter), second?.format(formatter), delta)
|
||||
}
|
||||
|
||||
private fun numberRow(code: String, name: String, first: Double?, second: Double?): TleCompareRowDTO =
|
||||
TleCompareRowDTO(
|
||||
code = code,
|
||||
name = name,
|
||||
first = first?.formatNumber(),
|
||||
second = second?.formatNumber(),
|
||||
delta = if (first != null && second != null) (second - first).formatSignedNumber() else null
|
||||
)
|
||||
|
||||
private fun distance(x: Double, y: Double, z: Double): Double = sqrt(x * x + y * y + z * z)
|
||||
|
||||
private fun Double.formatNumber(): String = String.format(Locale.US, "%.6f", this)
|
||||
|
||||
private fun Double.formatSignedNumber(): String {
|
||||
val sign = if (this > 0) "+" else ""
|
||||
return sign + String.format(Locale.US, "%.6f", this)
|
||||
}
|
||||
|
||||
private fun formatSecondsDelta(seconds: Long): String {
|
||||
val sign = if (seconds > 0) "+" else if (seconds < 0) "-" else ""
|
||||
val absolute = abs(seconds)
|
||||
val hours = absolute / 3600
|
||||
val minutes = (absolute % 3600) / 60
|
||||
val secs = absolute % 60
|
||||
return "$sign${hours}ч ${minutes}м ${secs}с"
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package space.nstart.pcp.slots_service.dto.bookings
|
||||
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* UI DTO for the bookings page. It intentionally lives in ui-service because the page combines
|
||||
* data from slots-service, request-service and satellite-catalog-service without changing their APIs.
|
||||
*/
|
||||
data class BookedSlotViewDTO(
|
||||
val bookedSlotId: Long,
|
||||
val satelliteId: Long,
|
||||
val satelliteCode: String? = null,
|
||||
val satelliteName: String? = null,
|
||||
val satelliteTypeCode: String? = null,
|
||||
val slotNum: Long,
|
||||
val cycle: Long,
|
||||
val timeStart: LocalDateTime? = null,
|
||||
val timeStop: LocalDateTime? = null,
|
||||
val durationSeconds: Long? = null,
|
||||
val roll: Double? = null,
|
||||
val revolution: Long? = null,
|
||||
val revolutionSign: String? = null,
|
||||
val status: String? = null,
|
||||
val requestIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class BookingRequestOptionDTO(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
data class BookingFilterOptionsDTO(
|
||||
val satellites: List<SatelliteSummaryDTO> = emptyList(),
|
||||
val requests: List<BookingRequestOptionDTO> = emptyList(),
|
||||
)
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package space.nstart.pcp.slots_service.dto.tleanalysis
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class TleAnalysisSatelliteDTO(
|
||||
val id: Long,
|
||||
val catalogId: Long? = null,
|
||||
val noradId: Long? = null,
|
||||
val code: String = "",
|
||||
val name: String = "",
|
||||
val typeCode: String = "",
|
||||
val active: Boolean = true,
|
||||
val scanTle: Boolean = false,
|
||||
val tleRecordsCount: Int = 0
|
||||
)
|
||||
|
||||
data class TleAnalysisResponseDTO(
|
||||
val satelliteId: Long,
|
||||
val rows: List<TleAnalysisRowDTO>
|
||||
)
|
||||
|
||||
data class TleAnalysisRowDTO(
|
||||
val index: Int,
|
||||
val epoch: LocalDateTime,
|
||||
val revolution: Long,
|
||||
val noradId: Long,
|
||||
val tleHeader: String?,
|
||||
val radiusVector: RadiusVectorDTO,
|
||||
val radiusNorm: Double,
|
||||
val discrepancy: RadiusVectorDiscrepancyDTO?
|
||||
)
|
||||
|
||||
data class RadiusVectorDTO(
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val z: Double
|
||||
)
|
||||
|
||||
data class RadiusVectorDiscrepancyDTO(
|
||||
val previousEpoch: LocalDateTime,
|
||||
val epochDifferenceHours: Double,
|
||||
val dx: Double,
|
||||
val dy: Double,
|
||||
val dz: Double,
|
||||
val norm: Double
|
||||
)
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package space.nstart.pcp.slots_service.dto.tlecomparison
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class TleTextRequestDTO(
|
||||
var tle: String = ""
|
||||
)
|
||||
|
||||
class TleCompareRequestDTO(
|
||||
var first: String = "",
|
||||
var second: String = "",
|
||||
var time: LocalDateTime? = null
|
||||
)
|
||||
|
||||
data class TleParsedDTO(
|
||||
val satName: String? = null,
|
||||
val noradId: Long? = null,
|
||||
val revolution: Long? = null,
|
||||
val time: LocalDateTime? = null,
|
||||
val inclination: Double? = null,
|
||||
val perigee: Double? = null,
|
||||
val apogee: Double? = null,
|
||||
val argPerigee: Double? = null,
|
||||
val eccentricity: Double? = null,
|
||||
val major: Double? = null,
|
||||
val minor: Double? = null,
|
||||
val meanAnomaly: Double? = null,
|
||||
val period: Double? = null,
|
||||
val semiMajor: Double? = null,
|
||||
val semiMinor: Double? = null,
|
||||
val ascendingNode: Double? = null,
|
||||
val meanMotion: Double? = null,
|
||||
val meanMotionTLE: Double? = null
|
||||
)
|
||||
|
||||
data class TlePositionDTO(
|
||||
val time: LocalDateTime,
|
||||
val revolution: Long,
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val z: Double,
|
||||
val vx: Double,
|
||||
val vy: Double,
|
||||
val vz: Double,
|
||||
val radiusNorm: Double,
|
||||
val velocityNorm: Double
|
||||
)
|
||||
|
||||
data class TleCompareRowDTO(
|
||||
val code: String,
|
||||
val name: String,
|
||||
val first: String?,
|
||||
val second: String?,
|
||||
val delta: String? = null
|
||||
)
|
||||
|
||||
data class TleCompareResponseDTO(
|
||||
val time: LocalDateTime,
|
||||
val firstEpoch: LocalDateTime,
|
||||
val secondEpoch: LocalDateTime,
|
||||
val first: TlePositionDTO,
|
||||
val second: TlePositionDTO,
|
||||
val radiusVectorDelta: TlePositionDeltaDTO,
|
||||
val rows: List<TleCompareRowDTO>
|
||||
)
|
||||
|
||||
data class TlePositionDeltaDTO(
|
||||
val dx: Double,
|
||||
val dy: Double,
|
||||
val dz: Double,
|
||||
val norm: Double
|
||||
)
|
||||
+5
-2
@@ -65,8 +65,11 @@ class RequestRestController {
|
||||
|
||||
|
||||
@PostMapping("/station/{station_id}")
|
||||
fun sat(@PathVariable("station_id") id : String, @RequestParam view : Boolean) =
|
||||
czmlService.station(id, view)
|
||||
fun sat(
|
||||
@PathVariable("station_id") id : String,
|
||||
@RequestParam view : Boolean,
|
||||
@RequestParam(required = false) orbitHeightKm : Double?
|
||||
) = czmlService.station(id, view, orbitHeightKm)
|
||||
|
||||
|
||||
|
||||
|
||||
+47
@@ -9,7 +9,11 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TlePointRequestDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TleParsedDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import java.net.URI
|
||||
import java.time.LocalDateTime
|
||||
@@ -68,6 +72,49 @@ class BallisticsService (webClientBuilderProvider: ObjectProvider<WebClient.Buil
|
||||
?.firstOrNull()
|
||||
?: throw CustomErrorException("Не удалось получить положение спутника $id на время $time")
|
||||
|
||||
fun receiveRv(request: SatelliteICDTO) {
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/satellites/receive-rv")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
}
|
||||
|
||||
fun currentInitialConditions(satelliteId: Long): SatelliteICDTO? =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/api/satellites/$satelliteId/initial-conditions/current")
|
||||
.exchangeToMono { response ->
|
||||
when {
|
||||
response.statusCode().is2xxSuccessful -> response.bodyToMono(SatelliteICDTO::class.java)
|
||||
response.statusCode().value() == 404 || response.statusCode().value() == 204 -> reactor.core.publisher.Mono.empty()
|
||||
else -> response.createException().flatMap { reactor.core.publisher.Mono.error(it) }
|
||||
}
|
||||
}
|
||||
.block()
|
||||
|
||||
fun parseTle(tle: TLEDTO): TleParsedDTO =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/tle/parse")
|
||||
.bodyValue(tle)
|
||||
.retrieve()
|
||||
.bodyToMono(TleParsedDTO::class.java)
|
||||
.block()
|
||||
?: throw CustomErrorException("Не удалось разобрать TLE")
|
||||
|
||||
fun tlePoint(tle: TLEDTO, time: LocalDateTime): OrbPointDTO =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/tle/point")
|
||||
.bodyValue(TlePointRequestDTO(tle = tle, time = time))
|
||||
.retrieve()
|
||||
.bodyToMono(OrbPointDTO::class.java)
|
||||
.block()
|
||||
?: throw CustomErrorException("Не удалось рассчитать положение по TLE на время $time")
|
||||
|
||||
private fun withTimeInterval(
|
||||
path: String,
|
||||
timeStart: LocalDateTime?,
|
||||
|
||||
+5
-3
@@ -50,6 +50,7 @@ class CZMLService {
|
||||
private val docStationName = "station_"
|
||||
private val entityStationName = "station_"
|
||||
private val docReqCovName ="req_cov_"
|
||||
private val DEFAULT_STATION_ORBIT_HEIGHT_KM = 500.0
|
||||
|
||||
@Autowired
|
||||
private lateinit var stationService: StationService
|
||||
@@ -378,9 +379,10 @@ private val docReqCovName ="req_cov_"
|
||||
}
|
||||
|
||||
|
||||
fun station(id : String, view : Boolean) : Iterable<CZMLEntity>{
|
||||
fun station(id : String, view : Boolean, orbitHeightKm: Double? = null) : Iterable<CZMLEntity>{
|
||||
val req = stationService.station(id).block()?:return listOf()
|
||||
val radius = ellipseAx( (500.0 * 1000.0), req.elevationMin * PI / 180)
|
||||
val normalizedOrbitHeightKm = orbitHeightKm?.takeIf { it > 0 } ?: DEFAULT_STATION_ORBIT_HEIGHT_KM
|
||||
val radius = ellipseAx((normalizedOrbitHeightKm * 1000.0), req.elevationMin * PI / 180)
|
||||
|
||||
val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docStationName+id , "1.1"))
|
||||
r.add(
|
||||
@@ -401,7 +403,7 @@ private val docReqCovName ="req_cov_"
|
||||
extrudedHeight = 1000.0,
|
||||
height = 1000.0
|
||||
),
|
||||
description = "Станция ${req.name}",
|
||||
description = "Станция ${req.name}<br>Высота орбиты для зоны видимости: ${normalizedOrbitHeightKm} км",
|
||||
model = CZMLModel(
|
||||
gltf = "/model/station.glb",
|
||||
show = !view
|
||||
|
||||
+9
@@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>
|
||||
.block()
|
||||
}
|
||||
|
||||
fun confirmMission(missionId: UUID) {
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/missions/$missionId/confirm")
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
}
|
||||
|
||||
fun modesByMission(missionId: UUID): List<ModeResponseDTO> =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
|
||||
+101
@@ -22,6 +22,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
@@ -148,6 +149,85 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun allBooked(): List<BookedSlotDTO> =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/api/slots/booking")
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux(BookedSlotDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
fun bookedByIds(bookedSlotIds: List<Long>): List<BookedSlotDetailsDTO> =
|
||||
bookedDetailsByIds(bookedSlotIds)
|
||||
|
||||
fun bookedDetailsByIds(bookedSlotIds: List<Long>): List<BookedSlotDetailsDTO> {
|
||||
val ids = bookedSlotIds.filter { it > 0 }.distinct().toSet()
|
||||
if (ids.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return searchBooked(
|
||||
timeStart = null,
|
||||
timeStop = null,
|
||||
requestId = null,
|
||||
satelliteIds = null
|
||||
).filter { it.bookedSlotId in ids }
|
||||
}
|
||||
|
||||
|
||||
fun searchBooked(
|
||||
timeStart: LocalDateTime?,
|
||||
timeStop: LocalDateTime?,
|
||||
requestId: String?,
|
||||
satelliteIds: List<Long>?
|
||||
): List<BookedSlotDetailsDTO> = webClientBuilder.build()
|
||||
.get()
|
||||
.uri(
|
||||
UriComponentsBuilder
|
||||
.fromUriString("$url/api/slots/booking/search")
|
||||
.queryParamIfPresent("timeStart", java.util.Optional.ofNullable(timeStart))
|
||||
.queryParamIfPresent("timeStop", java.util.Optional.ofNullable(timeStop))
|
||||
.queryParamIfPresent("requestId", java.util.Optional.ofNullable(requestId?.trim()?.takeIf { it.isNotEmpty() }))
|
||||
.apply {
|
||||
satelliteIds.orEmpty().distinct().forEach { queryParam("satelliteIds", it) }
|
||||
}
|
||||
.build()
|
||||
.toUriString()
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux(BookedSlotDetailsDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
fun bookedByRequest(requestId: String): List<SlotDTO> =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/api/slots/booking/by-request/$requestId")
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux(SlotDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
fun bookReq(req: BookingRequestDTO): List<BookedSlotDTO> =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
@@ -244,6 +324,27 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
}
|
||||
.block()
|
||||
|
||||
|
||||
fun sendSatelliteInitialConditions(satelliteId: Long, time: LocalDateTime) {
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri(
|
||||
UriComponentsBuilder
|
||||
.fromUriString("$url/api/satellite/{satelliteId}/send-ic")
|
||||
.queryParam("time", time)
|
||||
.buildAndExpand(satelliteId)
|
||||
.toUriString()
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
}
|
||||
|
||||
fun saveSatelliteInitialConditions(
|
||||
satelliteId: Long,
|
||||
request: InitialConditionsDTO,
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import ballistics.Ballistics
|
||||
import ballistics.orbitalPoints.timeStepper.TLEStepper
|
||||
import ballistics.types.EarthType
|
||||
import ballistics.types.TLE
|
||||
import ballistics.utils.fromDateTime
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.slots_service.dto.tleanalysis.RadiusVectorDTO
|
||||
import space.nstart.pcp.slots_service.dto.tleanalysis.RadiusVectorDiscrepancyDTO
|
||||
import space.nstart.pcp.slots_service.dto.tleanalysis.TleAnalysisSatelliteDTO
|
||||
import space.nstart.pcp.slots_service.dto.tleanalysis.TleAnalysisResponseDTO
|
||||
import space.nstart.pcp.slots_service.dto.tleanalysis.TleAnalysisRowDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class TleAnalysisService(
|
||||
private val tleMonitoringService: TleMonitoringService,
|
||||
private val satelliteCatalogService: SatelliteCatalogService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
fun satellites(): List<TleAnalysisSatelliteDTO> {
|
||||
logger.info("TLE analysis satellites list stage started")
|
||||
var catalogSatellites = emptyList<SatelliteSummaryDTO>()
|
||||
val catalogLoadMs = measureTimeMillis {
|
||||
catalogSatellites = runCatching { satelliteCatalogService.allSatellites() }
|
||||
.onFailure { exception ->
|
||||
logger.warn("Не удалось загрузить спутники из satellite-catalog-service для анализа TLE: {}", exception.message)
|
||||
}
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
var tleRecords = emptyList<TLEExtensionDTO>()
|
||||
val tleLoadMs = measureTimeMillis {
|
||||
tleRecords = runCatching { tleMonitoringService.allTles() }
|
||||
.onFailure { exception ->
|
||||
logger.warn("Не удалось загрузить TLE из tle-monitoring-service для списка анализа TLE: {}", exception.message)
|
||||
}
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
logger.info(
|
||||
"TLE analysis satellites list sources loaded: catalogSatellites={}, tleRecords={}, catalogLoadMs={}, tleLoadMs={}",
|
||||
catalogSatellites.size,
|
||||
tleRecords.size,
|
||||
catalogLoadMs,
|
||||
tleLoadMs
|
||||
)
|
||||
|
||||
if (tleRecords.isEmpty()) {
|
||||
val fallbackSatellites = catalogSatellites.mapNotNull { satellite -> satellite.toAnalysisSatelliteFallback() }
|
||||
logger.info(
|
||||
"TLE analysis satellites list built from catalog fallback: satellites={}",
|
||||
fallbackSatellites.size
|
||||
)
|
||||
return fallbackSatellites
|
||||
}
|
||||
|
||||
val catalogByNoradId = catalogSatellites
|
||||
.mapNotNull { satellite -> satellite.noradId?.let { noradId -> noradId to satellite } }
|
||||
.toMap()
|
||||
val catalogById = catalogSatellites.associateBy { it.id }
|
||||
|
||||
val satellites = tleRecords
|
||||
.groupingBy { record -> record.satelliteId }
|
||||
.eachCount()
|
||||
.entries
|
||||
.sortedBy { (satelliteId, _) -> satelliteId }
|
||||
.map { (tleSatelliteId, recordsCount) ->
|
||||
val catalogSatellite = catalogByNoradId[tleSatelliteId] ?: catalogById[tleSatelliteId]
|
||||
catalogSatellite?.toAnalysisSatellite(
|
||||
tleSatelliteId = tleSatelliteId,
|
||||
recordsCount = recordsCount
|
||||
) ?: TleAnalysisSatelliteDTO(
|
||||
id = tleSatelliteId,
|
||||
noradId = tleSatelliteId,
|
||||
name = "КА $tleSatelliteId",
|
||||
tleRecordsCount = recordsCount
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"TLE analysis satellites list built from TLE records: satellites={}, matchedCatalogSatellites={}, unmatchedTleSatellites={}",
|
||||
satellites.size,
|
||||
satellites.count { it.catalogId != null },
|
||||
satellites.count { it.catalogId == null }
|
||||
)
|
||||
return satellites
|
||||
}
|
||||
|
||||
fun analyzeSatellite(satelliteId: Long, tleCount: Int = DEFAULT_TLE_ANALYSIS_COUNT): TleAnalysisResponseDTO {
|
||||
val normalizedTleCount = tleCount.coerceAtLeast(1)
|
||||
logger.info(
|
||||
"TLE analysis report stage started: satelliteId={}, requestedTleCount={}, normalizedTleCount={}",
|
||||
satelliteId,
|
||||
tleCount,
|
||||
normalizedTleCount
|
||||
)
|
||||
|
||||
var sourceRecords = emptyList<TLEExtensionDTO>()
|
||||
val sourceLoadMs = measureTimeMillis {
|
||||
sourceRecords = tleMonitoringService.satelliteTles(satelliteId)
|
||||
}
|
||||
logger.info(
|
||||
"TLE analysis report source TLE loaded: satelliteId={}, records={}, durationMs={}",
|
||||
satelliteId,
|
||||
sourceRecords.size,
|
||||
sourceLoadMs
|
||||
)
|
||||
|
||||
val parsedRecords = sourceRecords.mapNotNull { parseRecord(it) }.sortedBy { it.epoch }
|
||||
val skippedRecords = sourceRecords.size - parsedRecords.size
|
||||
if (skippedRecords > 0) {
|
||||
logger.warn(
|
||||
"TLE analysis report skipped invalid TLE records: satelliteId={}, skipped={}, sourceRecords={}",
|
||||
satelliteId,
|
||||
skippedRecords,
|
||||
sourceRecords.size
|
||||
)
|
||||
}
|
||||
|
||||
val records = parsedRecords.takeLast(normalizedTleCount)
|
||||
logger.info(
|
||||
"TLE analysis report records selected: satelliteId={}, parsedRecords={}, selectedRecords={}, firstEpoch={}, lastEpoch={}",
|
||||
satelliteId,
|
||||
parsedRecords.size,
|
||||
records.size,
|
||||
records.firstOrNull()?.epoch,
|
||||
records.lastOrNull()?.epoch
|
||||
)
|
||||
|
||||
var rows = emptyList<TleAnalysisRowDTO>()
|
||||
val rowsBuildMs = measureTimeMillis {
|
||||
rows = records.mapIndexed { index, record ->
|
||||
// Сравнение пары TLE выполняется на эпоху более поздней записи.
|
||||
val comparisonEpoch = record.epoch
|
||||
val currentPoint = calculatePoint(record.tle, comparisonEpoch)
|
||||
val radiusVector = RadiusVectorDTO(
|
||||
x = currentPoint.r.x,
|
||||
y = currentPoint.r.y,
|
||||
z = currentPoint.r.z
|
||||
)
|
||||
|
||||
val discrepancy = records.getOrNull(index - 1)?.let { previous ->
|
||||
val epochDifferenceHours = Duration.between(previous.epoch, comparisonEpoch).toMillis() / MILLIS_IN_HOUR
|
||||
val previousPointAtCurrentEpoch = calculatePoint(previous.tle, comparisonEpoch)
|
||||
val dx = currentPoint.r.x - previousPointAtCurrentEpoch.r.x
|
||||
val dy = currentPoint.r.y - previousPointAtCurrentEpoch.r.y
|
||||
val dz = currentPoint.r.z - previousPointAtCurrentEpoch.r.z
|
||||
RadiusVectorDiscrepancyDTO(
|
||||
previousEpoch = previous.epoch,
|
||||
epochDifferenceHours = epochDifferenceHours,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
dz = dz,
|
||||
norm = norm(dx, dy, dz)
|
||||
)
|
||||
}
|
||||
|
||||
TleAnalysisRowDTO(
|
||||
index = index + 1,
|
||||
epoch = record.epoch,
|
||||
revolution = record.revolution,
|
||||
noradId = record.noradId,
|
||||
tleHeader = record.tle.header,
|
||||
radiusVector = radiusVector,
|
||||
radiusNorm = norm(radiusVector.x, radiusVector.y, radiusVector.z),
|
||||
discrepancy = discrepancy
|
||||
)
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
"TLE analysis report built: satelliteId={}, rows={}, durationMs={}",
|
||||
satelliteId,
|
||||
rows.size,
|
||||
rowsBuildMs
|
||||
)
|
||||
|
||||
return TleAnalysisResponseDTO(
|
||||
satelliteId = satelliteId,
|
||||
rows = rows
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseRecord(source: TLEExtensionDTO): ParsedTleRecord? = runCatching {
|
||||
val tle = source.tle
|
||||
val params = Ballistics().getTLEParams(TLE(tle.first, tle.second), tle.header ?: "")
|
||||
ParsedTleRecord(
|
||||
tle = tle,
|
||||
epoch = params.time,
|
||||
revolution = source.revolution.takeIf { it > 0 } ?: params.revolution,
|
||||
noradId = params.noradId
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun calculatePoint(tle: TLEDTO, time: LocalDateTime) = runCatching {
|
||||
val stepper = TLEStepper(tle.first, tle.second, EarthType.PZ90d02)
|
||||
stepper.calculate(fromDateTime(time))
|
||||
}.getOrElse { ex ->
|
||||
throw CustomErrorException("Ошибка расчета положения по TLE на время $time: ${ex.message}")
|
||||
}
|
||||
|
||||
private fun norm(x: Double, y: Double, z: Double): Double = sqrt(x * x + y * y + z * z)
|
||||
|
||||
private fun SatelliteSummaryDTO.toAnalysisSatellite(
|
||||
tleSatelliteId: Long,
|
||||
recordsCount: Int
|
||||
): TleAnalysisSatelliteDTO =
|
||||
TleAnalysisSatelliteDTO(
|
||||
id = tleSatelliteId,
|
||||
catalogId = id,
|
||||
noradId = noradId ?: tleSatelliteId,
|
||||
code = code,
|
||||
name = name,
|
||||
typeCode = typeCode,
|
||||
active = active,
|
||||
scanTle = scanTle,
|
||||
tleRecordsCount = recordsCount
|
||||
)
|
||||
|
||||
private fun SatelliteSummaryDTO.toAnalysisSatelliteFallback(): TleAnalysisSatelliteDTO? {
|
||||
val analysisId = noradId ?: id.takeIf { it > 0 } ?: return null
|
||||
return TleAnalysisSatelliteDTO(
|
||||
id = analysisId,
|
||||
catalogId = id,
|
||||
noradId = noradId,
|
||||
code = code,
|
||||
name = name,
|
||||
typeCode = typeCode,
|
||||
active = active,
|
||||
scanTle = scanTle,
|
||||
tleRecordsCount = 0
|
||||
)
|
||||
}
|
||||
|
||||
private data class ParsedTleRecord(
|
||||
val tle: TLEDTO,
|
||||
val epoch: LocalDateTime,
|
||||
val revolution: Long,
|
||||
val noradId: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_TLE_ANALYSIS_COUNT = 5
|
||||
private const val MILLIS_IN_HOUR = 3_600_000.0
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.core.publisher.Mono
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
class TleMonitoringService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
@Value("\${settings.tle-monitoring-service:http://tle-monitoring-service:8080}")
|
||||
private val tleMonitoringUrl: String = ""
|
||||
|
||||
fun allTles(): List<TLEExtensionDTO> {
|
||||
val baseUrl = tleMonitoringUrl.trimEnd('/')
|
||||
logger.info("TLE monitoring client all TLE request started: baseUrl={}", baseUrl)
|
||||
return runCatching {
|
||||
var records = emptyList<TLEExtensionDTO>()
|
||||
val durationMs = measureTimeMillis {
|
||||
records = webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$baseUrl/v1/api/tle")
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }, ::mapError)
|
||||
.bodyToFlux(TLEExtensionDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
}
|
||||
logger.info(
|
||||
"TLE monitoring client all TLE request completed: records={}, durationMs={}",
|
||||
records.size,
|
||||
durationMs
|
||||
)
|
||||
records
|
||||
}.getOrElse { exception ->
|
||||
logger.warn("TLE monitoring client all TLE request failed: {}", exception.message)
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
fun satelliteTles(satelliteId: Long): List<TLEExtensionDTO> {
|
||||
val baseUrl = tleMonitoringUrl.trimEnd('/')
|
||||
logger.info(
|
||||
"TLE monitoring client satellite TLE request started: satelliteId={}, baseUrl={}",
|
||||
satelliteId,
|
||||
baseUrl
|
||||
)
|
||||
return runCatching {
|
||||
var records = emptyList<TLEExtensionDTO>()
|
||||
val durationMs = measureTimeMillis {
|
||||
records = webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$baseUrl/v1/api/tle/satellite/$satelliteId")
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }, ::mapError)
|
||||
.bodyToFlux(TLEExtensionDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
}
|
||||
logger.info(
|
||||
"TLE monitoring client satellite TLE request completed: satelliteId={}, records={}, durationMs={}",
|
||||
satelliteId,
|
||||
records.size,
|
||||
durationMs
|
||||
)
|
||||
records
|
||||
}.getOrElse { exception ->
|
||||
logger.warn(
|
||||
"TLE monitoring client satellite TLE request failed: satelliteId={}, error={}",
|
||||
satelliteId,
|
||||
exception.message
|
||||
)
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapError(response: org.springframework.web.reactive.function.client.ClientResponse): Mono<Throwable> =
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
|
||||
private fun extractErrorMessage(body: String): String {
|
||||
val text = body.trim()
|
||||
if (text.isEmpty()) return "error"
|
||||
|
||||
return runCatching {
|
||||
val node = ObjectMapper().readTree(text)
|
||||
when {
|
||||
node.has("message") -> node.get("message").asText()
|
||||
node.has("error") -> node.get("error").asText()
|
||||
else -> text
|
||||
}
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
(function () {
|
||||
const state = {
|
||||
satellites: [],
|
||||
requests: [],
|
||||
selectedSatelliteIds: new Set(),
|
||||
bookings: []
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setDefaultInterval();
|
||||
bindEvents();
|
||||
loadOptions().then(() => loadBookings());
|
||||
});
|
||||
|
||||
function bindEvents() {
|
||||
el('bookings-refresh')?.addEventListener('click', () => loadBookings());
|
||||
el('bookings-reset')?.addEventListener('click', resetFilters);
|
||||
el('bookings-satellites-clear')?.addEventListener('click', () => {
|
||||
selectVisibleSatellites();
|
||||
});
|
||||
el('bookings-satellite-search')?.addEventListener('input', renderSatellites);
|
||||
el('bookings-request')?.addEventListener('change', () => loadBookings());
|
||||
el('bookings-time-start')?.addEventListener('change', () => loadBookings());
|
||||
el('bookings-time-stop')?.addEventListener('change', () => loadBookings());
|
||||
}
|
||||
|
||||
function setDefaultInterval() {
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
const stop = new Date(now.getTime());
|
||||
stop.setDate(stop.getDate() + 7);
|
||||
if (el('bookings-time-start')) el('bookings-time-start').value = toDateTimeLocal(now);
|
||||
if (el('bookings-time-stop')) el('bookings-time-stop').value = toDateTimeLocal(stop);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
state.selectedSatelliteIds.clear();
|
||||
if (el('bookings-request')) el('bookings-request').value = '';
|
||||
if (el('bookings-satellite-search')) el('bookings-satellite-search').value = '';
|
||||
setDefaultInterval();
|
||||
renderSatellites();
|
||||
loadBookings();
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
const options = await fetchJson('/api/bookings/options');
|
||||
state.satellites = Array.isArray(options.satellites) ? options.satellites : [];
|
||||
state.requests = Array.isArray(options.requests) ? options.requests : [];
|
||||
renderRequests();
|
||||
renderSatellites();
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось загрузить справочники страницы бронирований.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookings() {
|
||||
setTableLoading();
|
||||
const params = new URLSearchParams();
|
||||
const timeStart = el('bookings-time-start')?.value;
|
||||
const timeStop = el('bookings-time-stop')?.value;
|
||||
const requestId = el('bookings-request')?.value;
|
||||
|
||||
if (timeStart) params.set('timeStart', toIsoLocal(timeStart));
|
||||
if (timeStop) params.set('timeStop', toIsoLocal(timeStop));
|
||||
if (requestId) params.set('requestId', requestId);
|
||||
state.selectedSatelliteIds.forEach((id) => params.append('satelliteIds', id));
|
||||
|
||||
try {
|
||||
const items = await fetchJson(`/api/bookings?${params.toString()}`);
|
||||
state.bookings = Array.isArray(items) ? items : [];
|
||||
renderBookings();
|
||||
} catch (error) {
|
||||
state.bookings = [];
|
||||
renderBookings();
|
||||
showAlert(error.message || 'Не удалось загрузить забронированные слоты.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function visibleSatellites() {
|
||||
const query = (el('bookings-satellite-search')?.value || '').trim().toLowerCase();
|
||||
return state.satellites.filter((satellite) => satelliteMatches(satellite, query));
|
||||
}
|
||||
|
||||
function selectVisibleSatellites() {
|
||||
const satellites = visibleSatellites();
|
||||
satellites.forEach((satellite) => {
|
||||
const id = Number(satellite.id);
|
||||
if (!Number.isNaN(id)) {
|
||||
state.selectedSatelliteIds.add(id);
|
||||
}
|
||||
});
|
||||
renderSatellites();
|
||||
loadBookings();
|
||||
}
|
||||
|
||||
function renderRequests() {
|
||||
const select = el('bookings-request');
|
||||
if (!select) return;
|
||||
const current = select.value;
|
||||
select.innerHTML = '<option value="">Все заявки</option>' + state.requests.map((request) => {
|
||||
const title = request.name ? `${request.name} / ${request.id}` : request.id;
|
||||
return `<option value="${escapeHtml(request.id)}">${escapeHtml(title)}</option>`;
|
||||
}).join('');
|
||||
select.value = current;
|
||||
}
|
||||
|
||||
function renderSatellites() {
|
||||
const tbody = el('bookings-satellites-body');
|
||||
if (!tbody) return;
|
||||
const satellites = visibleSatellites();
|
||||
|
||||
if (!satellites.length) {
|
||||
tbody.innerHTML = emptyRow(3, 'Спутники не найдены.');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = satellites.map((satellite) => {
|
||||
const selected = state.selectedSatelliteIds.has(Number(satellite.id));
|
||||
return `
|
||||
<tr class="${selected ? 'bookings-selected-row' : ''}" data-satellite-id="${escapeHtml(satellite.id)}">
|
||||
<td><input class="form-check-input" type="checkbox" ${selected ? 'checked' : ''} aria-label="Выбрать спутник"></td>
|
||||
<td class="bookings-id">${escapeHtml(satellite.id ?? '—')}</td>
|
||||
<td>
|
||||
<div class="bookings-main-text">${escapeHtml(satellite.name || satellite.code || 'КА')}</div>
|
||||
<div class="bookings-sub-text">${escapeHtml([satellite.code, satellite.typeCode, satellite.noradId ? `NORAD ${satellite.noradId}` : null].filter(Boolean).join(' / ') || '—')}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('tr[data-satellite-id]').forEach((row) => {
|
||||
row.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const id = Number(row.dataset.satelliteId);
|
||||
if (state.selectedSatelliteIds.has(id)) {
|
||||
state.selectedSatelliteIds.delete(id);
|
||||
} else {
|
||||
state.selectedSatelliteIds.add(id);
|
||||
}
|
||||
renderSatellites();
|
||||
loadBookings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBookings() {
|
||||
const tbody = el('bookings-body');
|
||||
const empty = el('bookings-empty');
|
||||
const wrap = el('bookings-table-wrap');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!state.bookings.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty?.classList.remove('d-none');
|
||||
wrap?.classList.add('d-none');
|
||||
updateSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
empty?.classList.add('d-none');
|
||||
wrap?.classList.remove('d-none');
|
||||
tbody.innerHTML = state.bookings.map((booking) => `
|
||||
<tr>
|
||||
<td class="bookings-id">${escapeHtml(booking.bookedSlotId ?? '—')}</td>
|
||||
<td>
|
||||
<div class="bookings-main-text">${escapeHtml(booking.satelliteName || booking.satelliteCode || `КА ${booking.satelliteId}`)}</div>
|
||||
<div class="bookings-sub-text">ID ${escapeHtml(booking.satelliteId)}${booking.satelliteTypeCode ? ` / ${escapeHtml(booking.satelliteTypeCode)}` : ''}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>Слот ${escapeHtml(booking.slotNum ?? '—')}</div>
|
||||
<div class="bookings-sub-text">Цикл ${escapeHtml(booking.cycle ?? '—')}</div>
|
||||
</td>
|
||||
<td class="bookings-time-cell">
|
||||
<div>${formatDateTime(booking.timeStart)}</div>
|
||||
<div class="bookings-sub-text">${formatDateTime(booking.timeStop)}${booking.durationSeconds != null ? ` / ${formatNumber(booking.durationSeconds)} c` : ''}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>${escapeHtml(booking.revolution ?? '—')}</div>
|
||||
<div class="bookings-sub-text">${escapeHtml(booking.revolutionSign || '—')}</div>
|
||||
</td>
|
||||
<td>${formatNumber(booking.roll)}</td>
|
||||
<td><span class="bookings-badge ${statusClass(booking.status)}">${escapeHtml(booking.status || '—')}</span></td>
|
||||
<td>${formatRequestIds(booking.requestIds)}</td>
|
||||
</tr>`).join('');
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const summary = el('bookings-summary');
|
||||
if (!summary) return;
|
||||
const requestId = el('bookings-request')?.value;
|
||||
const satellites = state.selectedSatelliteIds.size ? `, спутников: ${state.selectedSatelliteIds.size}` : '';
|
||||
const request = requestId ? `, заявка: ${requestId}` : '';
|
||||
summary.textContent = `Найдено бронирований: ${state.bookings.length}${satellites}${request}`;
|
||||
}
|
||||
|
||||
function setTableLoading() {
|
||||
el('bookings-empty')?.classList.add('d-none');
|
||||
el('bookings-table-wrap')?.classList.remove('d-none');
|
||||
const tbody = el('bookings-body');
|
||||
if (tbody) tbody.innerHTML = emptyRow(8, 'Загрузка бронирований...');
|
||||
const summary = el('bookings-summary');
|
||||
if (summary) summary.textContent = 'Загрузка данных...';
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json', ...(options.headers || {}) },
|
||||
...options
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(extractError(text) || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function satelliteMatches(satellite, query) {
|
||||
if (!query) return true;
|
||||
return [satellite.id, satellite.noradId, satellite.code, satellite.name, satellite.typeCode]
|
||||
.filter((value) => value !== undefined && value !== null)
|
||||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function emptyRow(colspan, text) {
|
||||
return `<tr><td colspan="${colspan}" class="catalog-empty">${escapeHtml(text)}</td></tr>`;
|
||||
}
|
||||
|
||||
function formatRequestIds(ids) {
|
||||
if (!Array.isArray(ids) || !ids.length) return '—';
|
||||
return ids.map((id) => `<span class="bookings-badge">${escapeHtml(id)}</span>`).join(' ');
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (!status) return '';
|
||||
return `bookings-status-${String(status).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return escapeHtml(value);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
year: '2-digit', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const number = Number(value);
|
||||
if (Number.isNaN(number)) return escapeHtml(value);
|
||||
return number.toLocaleString('ru-RU', { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function toDateTimeLocal(date) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function toIsoLocal(value) {
|
||||
return value && value.length === 16 ? `${value}:00` : value;
|
||||
}
|
||||
|
||||
function showAlert(message, type = 'info') {
|
||||
const box = el('bookings-alert');
|
||||
if (!box) return;
|
||||
box.innerHTML = `<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function extractError(text) {
|
||||
if (!text) return '';
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json.message || json.error || text;
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,164 @@
|
||||
.bookings-page {
|
||||
height: calc(100vh - 4.25rem);
|
||||
max-height: calc(100vh - 4.25rem);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.bookings-page > .d-flex:first-child,
|
||||
#bookings-alert,
|
||||
.bookings-filter-card {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bookings-layout {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bookings-layout > [class*="col-"] {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookings-layout > [class*="col-"] > .catalog-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bookings-satellites-card,
|
||||
.bookings-table-card {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 17rem);
|
||||
}
|
||||
|
||||
.bookings-satellites-card .card-header,
|
||||
.bookings-table-card .card-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bookings-satellites-wrap,
|
||||
.bookings-table-card .card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookings-satellites-wrap {
|
||||
height: calc(100vh - 23rem);
|
||||
max-height: calc(100vh - 23rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bookings-table-card .card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookings-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 21rem);
|
||||
max-height: calc(100vh - 21rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookings-check-col {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.bookings-satellites-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bookings-selected-row > td {
|
||||
background: rgba(13, 110, 253, 0.12) !important;
|
||||
}
|
||||
|
||||
.bookings-id {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.bookings-main-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bookings-sub-text {
|
||||
color: #6c757d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.bookings-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(108, 117, 125, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
color: #495057;
|
||||
background: rgba(248, 249, 250, 0.9);
|
||||
}
|
||||
|
||||
.bookings-time-cell {
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.bookings-status-booked {
|
||||
border-color: rgba(13, 110, 253, 0.35);
|
||||
color: #084298;
|
||||
background: rgba(13, 110, 253, 0.12);
|
||||
}
|
||||
|
||||
.bookings-status-processed,
|
||||
.bookings-status-finished {
|
||||
border-color: rgba(25, 135, 84, 0.4);
|
||||
color: #0f5132;
|
||||
background: rgba(25, 135, 84, 0.14);
|
||||
}
|
||||
|
||||
.bookings-status-failed,
|
||||
.bookings-status-canceled,
|
||||
.bookings-status-cancelled {
|
||||
border-color: rgba(220, 53, 69, 0.4);
|
||||
color: #842029;
|
||||
background: rgba(220, 53, 69, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.bookings-page {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.bookings-layout,
|
||||
.bookings-layout > [class*="col-"],
|
||||
.bookings-layout > [class*="col-"] > .catalog-card,
|
||||
.bookings-satellites-card,
|
||||
.bookings-table-card,
|
||||
.bookings-satellites-wrap,
|
||||
.bookings-table-wrap,
|
||||
.bookings-table-card .card-body {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
min-height: initial;
|
||||
}
|
||||
|
||||
.bookings-layout > [class*="col-"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,16 @@
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-send-time {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-ic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-status {
|
||||
display: inline-flex;
|
||||
width: 0.75rem;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
.tle-analysis-page {
|
||||
height: calc(100vh - 4.75rem);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tle-analysis-layout {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-analysis-layout > [class*="col-"] {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-analysis-sidebar,
|
||||
.tle-analysis-results-card {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-analysis-filter-body {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tle-analysis-filter-body .form-control {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-wrap,
|
||||
.tle-analysis-results-wrap {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-wrap {
|
||||
flex: 1 1 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.tle-analysis-results-wrap {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
#tle-analysis-table-wrap {
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tle-analysis-results-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-table tbody tr,
|
||||
.tle-analysis-results-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-table tbody tr.active {
|
||||
--bs-table-bg: rgba(13, 110, 253, 0.12);
|
||||
--bs-table-hover-bg: rgba(13, 110, 253, 0.18);
|
||||
}
|
||||
|
||||
.tle-analysis-vector {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tle-analysis-discrepancy {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.tle-analysis-page {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-wrap,
|
||||
.tle-analysis-results-wrap {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.tle-comparison-page {
|
||||
min-height: calc(100vh - 5.5rem);
|
||||
}
|
||||
|
||||
.tle-input-card,
|
||||
.tle-result-card {
|
||||
border: 0;
|
||||
box-shadow: 0 0.5rem 1.25rem rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.tle-input-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tle-textarea {
|
||||
min-height: 11rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tle-reference {
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-reference-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.tle-reference-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.tle-reference-row span {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tle-reference-row strong {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tle-parse-status {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tle-parse-status.tle-parse-ok {
|
||||
color: #198754 !important;
|
||||
}
|
||||
|
||||
.tle-parse-status.tle-parse-error {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.tle-result-table-wrap {
|
||||
max-height: calc(100vh - 30rem);
|
||||
min-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tle-result-table-wrap thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tle-delta-positive {
|
||||
color: #0d6efd;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tle-delta-negative {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.tle-result-table-wrap {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
filteredSatellites: [],
|
||||
missions: [],
|
||||
currentModes: [],
|
||||
bookedSlotsById: {},
|
||||
selectedSatelliteId: null,
|
||||
selectedMissionId: null,
|
||||
selectedMission: null,
|
||||
@@ -168,6 +169,10 @@
|
||||
class="btn btn-outline-success btn-sm current-plans-action-btn"
|
||||
data-action="calculate-drops"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов</button>
|
||||
<button type="button"
|
||||
class="btn btn-success btn-sm current-plans-action-btn"
|
||||
data-action="confirm-mission"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Утвердить</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -184,6 +189,8 @@
|
||||
calculateSurveys(missionId, button);
|
||||
} else if (button.dataset.action === 'calculate-drops') {
|
||||
calculateDrops(missionId, button);
|
||||
} else if (button.dataset.action === 'confirm-mission') {
|
||||
confirmMission(missionId, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -208,6 +215,7 @@
|
||||
try {
|
||||
const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
|
||||
state.currentModes = Array.isArray(modes) ? modes : [];
|
||||
state.bookedSlotsById = await loadBookedSlotsForModes(state.currentModes);
|
||||
renderModes(state.currentModes);
|
||||
setExportEnabled(true);
|
||||
return state.currentModes;
|
||||
@@ -218,6 +226,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookedSlotsForModes(modes) {
|
||||
const bookedSlotIds = [...new Set((modes || []).flatMap((mode) => mode.bookedSlotIds || []))]
|
||||
.filter((slotId) => Number(slotId) > 0);
|
||||
if (!bookedSlotIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
bookedSlotIds.forEach((slotId) => params.append('ids', slotId));
|
||||
|
||||
try {
|
||||
const bookedSlots = await fetchJson(`/api/current-plans/booked-slots?${params.toString()}`);
|
||||
return (bookedSlots || []).reduce((acc, slot) => {
|
||||
acc[slot.bookedSlotId] = slot;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось загрузить детали забронированных слотов.', 'warning');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderModes(modes) {
|
||||
const tbody = el('current-plans-modes-body');
|
||||
if (!tbody) return;
|
||||
@@ -227,35 +257,158 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = modes.map((mode) => {
|
||||
tbody.innerHTML = modes.flatMap((mode) => {
|
||||
const type = mode.type || '—';
|
||||
const duration = formatNumber(mode.duration);
|
||||
const rowClass = modeRowClass(mode);
|
||||
const badgeClass = modeBadgeClass(mode);
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td><span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
|
||||
const expandable = isExpandableMode(mode);
|
||||
const expandMarker = expandable ? '<span class="current-plans-expand-marker">▸</span>' : '';
|
||||
const row = `
|
||||
<tr class="${rowClass}${expandable ? ' current-plans-expandable-row' : ''}" data-mode-id="${escapeHtml(mode.id ?? '')}">
|
||||
<td>${expandMarker}<span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
|
||||
<td>${formatDateTime(mode.timeStart)}</td>
|
||||
<td>${escapeHtml(mode.revolution ?? '—')}</td>
|
||||
<td>${duration}</td>
|
||||
<td class="current-plans-mode-params">${modeParams(mode)}</td>
|
||||
<td>${modeStatus(mode)}</td>
|
||||
</tr>`;
|
||||
const details = expandable ? modeDetailsRow(mode) : null;
|
||||
return details ? [row, details] : [row];
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('.current-plans-expandable-row').forEach((row) => {
|
||||
row.addEventListener('click', () => toggleModeDetails(row.dataset.modeId));
|
||||
});
|
||||
}
|
||||
|
||||
function modeRowClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-mode-row-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-mode-row-slots';
|
||||
if (isSlotsLikeSource(mode.source)) return 'current-plans-mode-row-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function modeBadgeClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-badge-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-badge-slots';
|
||||
if (isSlotsLikeSource(mode.source)) return 'current-plans-badge-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function isSlotsLikeSource(source) {
|
||||
return source === 'SLOTS' || source === 'MIXED';
|
||||
}
|
||||
|
||||
function isExpandableMode(mode) {
|
||||
return (Array.isArray(mode.bookedSlotIds) && mode.bookedSlotIds.length > 0)
|
||||
|| (mode.type === 'DROP' && Array.isArray(mode.surveys) && mode.surveys.length > 0);
|
||||
}
|
||||
|
||||
function toggleModeDetails(modeId) {
|
||||
const row = document.querySelector(`#current-plans-modes-body tr[data-mode-id="${cssEscape(modeId)}"]`);
|
||||
const details = document.querySelector(`#current-plans-modes-body tr[data-parent-mode-id="${cssEscape(modeId)}"]`);
|
||||
if (!row || !details) return;
|
||||
|
||||
const collapsed = details.classList.toggle('d-none');
|
||||
row.classList.toggle('current-plans-expanded-row', !collapsed);
|
||||
const marker = row.querySelector('.current-plans-expand-marker');
|
||||
if (marker) marker.textContent = collapsed ? '▸' : '▾';
|
||||
}
|
||||
|
||||
function modeDetailsRow(mode) {
|
||||
const content = mode.type === 'DROP'
|
||||
? dropDetails(mode)
|
||||
: bookedSlotDetails(mode);
|
||||
|
||||
return `
|
||||
<tr class="current-plans-mode-details-row d-none" data-parent-mode-id="${escapeHtml(mode.id ?? '')}">
|
||||
<td colspan="6">
|
||||
<div class="current-plans-mode-details">${content}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function bookedSlotDetails(mode) {
|
||||
const slotIds = Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds : [];
|
||||
const rows = slotIds.map((slotId) => {
|
||||
const slot = state.bookedSlotsById[slotId];
|
||||
if (!slot) {
|
||||
return `<tr><td class="current-plans-id">#${escapeHtml(slotId)}</td><td colspan="6">Детали слота не найдены</td></tr>`;
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="current-plans-id">#${escapeHtml(slot.bookedSlotId)}</td>
|
||||
<td>${formatDateTime(slot.timeStart)}</td>
|
||||
<td>${formatDuration(slot.durationSeconds)}</td>
|
||||
<td>${escapeHtml(slot.slotNum ?? '—')}</td>
|
||||
<td>${escapeHtml(slot.cycle ?? '—')}</td>
|
||||
<td>${escapeHtml(slot.satelliteId ?? '—')}</td>
|
||||
<td>${escapeHtml(slot.status || '—')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="current-plans-detail-title">Забронированные слоты (${slotIds.length})</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 current-plans-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Начало</th>
|
||||
<th>Длительность</th>
|
||||
<th>Слот</th>
|
||||
<th>Цикл</th>
|
||||
<th>Спутник</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function dropDetails(mode) {
|
||||
const surveyIds = Array.isArray(mode.surveys) ? mode.surveys : [];
|
||||
const surveysById = new Map((state.currentModes || []).map((item) => [Number(item.id), item]));
|
||||
const rows = surveyIds.map((surveyId) => {
|
||||
const survey = surveysById.get(Number(surveyId));
|
||||
if (!survey) {
|
||||
return `<tr><td class="current-plans-id">#${escapeHtml(surveyId)}</td><td colspan="4">Съёмка не найдена в текущем списке режимов</td></tr>`;
|
||||
}
|
||||
|
||||
const booked = Array.isArray(survey.bookedSlotIds) && survey.bookedSlotIds.length
|
||||
? survey.bookedSlotIds.map((slotId) => {
|
||||
const slot = state.bookedSlotsById[slotId];
|
||||
return slot ? `#${slot.bookedSlotId} slot ${slot.slotNum}/cycle ${slot.cycle}` : `#${slotId}`;
|
||||
}).join(', ')
|
||||
: '—';
|
||||
return `
|
||||
<tr>
|
||||
<td class="current-plans-id">#${escapeHtml(survey.id)}</td>
|
||||
<td>${formatDateTime(survey.timeStart)}</td>
|
||||
<td>${escapeHtml(survey.revolution ?? '—')}</td>
|
||||
<td>${escapeHtml(survey.source || '—')}</td>
|
||||
<td>${escapeHtml(booked)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="current-plans-detail-title">Сбрасываемые съёмки (${surveyIds.length})</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 current-plans-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Начало</th>
|
||||
<th>Виток</th>
|
||||
<th>Источник</th>
|
||||
<th>Booked slots</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function modeParams(mode) {
|
||||
if (mode.type === 'DROP') {
|
||||
const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0;
|
||||
@@ -276,7 +429,7 @@
|
||||
if (mode.type === 'DROP') {
|
||||
return '<span class="current-plans-badge current-plans-badge-drop">DROP</span>';
|
||||
}
|
||||
const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : '';
|
||||
const sourceBadgeClass = isSlotsLikeSource(mode.source) ? ' current-plans-badge-slots' : '';
|
||||
return `
|
||||
<div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div>
|
||||
<div><span class="current-plans-badge${sourceBadgeClass}">${escapeHtml(mode.source || '—')}</span></div>`;
|
||||
@@ -379,6 +532,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMission(missionId, button) {
|
||||
setButtonLoading(button, true, 'Утверждение...');
|
||||
try {
|
||||
await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
showAlert('План утверждён.', 'success');
|
||||
if (state.selectedSatelliteId) {
|
||||
await loadMissions(state.selectedSatelliteId, state.missionsPage, missionId);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось утвердить план.', 'danger');
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function exportSelectedMissionCsv() {
|
||||
if (!state.selectedMissionId) {
|
||||
showAlert('Выберите миссию для сохранения CSV.', 'warning');
|
||||
@@ -410,7 +581,7 @@
|
||||
mode.type || '',
|
||||
mode.id ?? '',
|
||||
mode.planId ?? '',
|
||||
mode.timeStart || '',
|
||||
formatDateTime(mode.timeStart),
|
||||
mode.revolution ?? '',
|
||||
mode.duration ?? '',
|
||||
mode.status || '',
|
||||
@@ -469,6 +640,7 @@
|
||||
el('current-plans-modes-body').innerHTML = '';
|
||||
el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.';
|
||||
state.currentModes = [];
|
||||
state.bookedSlotsById = {};
|
||||
setExportEnabled(false);
|
||||
}
|
||||
|
||||
@@ -570,6 +742,12 @@
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—';
|
||||
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (match) {
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
return `${day}.${month}.${year.slice(2)}, ${hour}:${minute}:${second || '00'}`;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return escapeHtml(value);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
@@ -588,6 +766,21 @@
|
||||
return number.toLocaleString('ru-RU', {maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const totalSeconds = Number(seconds);
|
||||
if (!Number.isFinite(totalSeconds)) return '—';
|
||||
|
||||
const rounded = Math.max(0, Math.round(totalSeconds));
|
||||
const hours = Math.floor(rounded / 3600);
|
||||
const minutes = Math.floor((rounded % 3600) / 60);
|
||||
const remainingSeconds = rounded % 60;
|
||||
const pad = (number) => String(number).padStart(2, '0');
|
||||
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(remainingSeconds)}`
|
||||
: `${minutes}:${pad(remainingSeconds)}`;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(date) {
|
||||
const pad = (number) => String(number).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
@@ -652,4 +845,11 @@
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') {
|
||||
return window.CSS.escape(String(value ?? ''));
|
||||
}
|
||||
return String(value ?? '').replaceAll('"', '\\"');
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
checkModels();
|
||||
} else{
|
||||
var sceneMode = (viewer.scene.mode == Cesium.SceneMode.SCENE2D);
|
||||
var u = '/requests/station/'+req.id+'?view='+sceneMode
|
||||
var u = '/requests/station/'+req.id+'?view='+sceneMode+
|
||||
'&orbitHeightKm='+encodeURIComponent(selectedStationOrbitHeightKm())
|
||||
fetch(u, {
|
||||
method: "post",
|
||||
headers: {
|
||||
@@ -115,6 +116,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function selectedStationOrbitHeightKm() {
|
||||
const input = document.getElementById('stationOrbitHeightKm');
|
||||
const value = Number(input?.value || 500);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
if (input) input.value = '500';
|
||||
return 500;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
function drawSat(show, req, startInput, endInput, runId){
|
||||
if (req){
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
return date.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 3) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return '';
|
||||
}
|
||||
return number.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: digits
|
||||
});
|
||||
}
|
||||
|
||||
function selectedMode() {
|
||||
return document.querySelector('input[name="rva-mode"]:checked')?.value || 'common';
|
||||
}
|
||||
@@ -113,7 +124,7 @@
|
||||
<td>${row.durationSec}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else {
|
||||
} else if (resultState.mode === 'merge-on-rev') {
|
||||
head.innerHTML = `
|
||||
<tr>
|
||||
<th>Виток</th>
|
||||
@@ -123,7 +134,24 @@
|
||||
body.innerHTML = resultState.rows.map(row => `
|
||||
<tr>
|
||||
<td>${row.revolution}</td>
|
||||
<td>${row.durationSec}</td>
|
||||
<td>${formatNumber(row.durationSec, 1)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else if (resultState.mode === 'average') {
|
||||
head.innerHTML = `
|
||||
<tr>
|
||||
<th>№ППИ</th>
|
||||
<th>Широта ППИ</th>
|
||||
<th>Сред.кол-во</th>
|
||||
<th>Сред.длит., с</th>
|
||||
</tr>
|
||||
`;
|
||||
body.innerHTML = resultState.rows.map(row => `
|
||||
<tr>
|
||||
<td>${row.ppiNumber}</td>
|
||||
<td>${formatNumber(row.ppiLatitude, 6)}</td>
|
||||
<td>${formatNumber(row.averageCount, 3)}</td>
|
||||
<td>${formatNumber(row.averageDurationSec, 1)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
@@ -145,8 +173,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = resultState.mode === 'common'
|
||||
? [
|
||||
let lines;
|
||||
if (resultState.mode === 'common') {
|
||||
lines = [
|
||||
['stationId', 'revolution', 'onStart', 'onMaximum', 'onStop', 'duration'],
|
||||
...resultState.rows.map(row => [
|
||||
row.stationId,
|
||||
@@ -156,23 +185,35 @@
|
||||
row.onStop.time,
|
||||
row.duration
|
||||
])
|
||||
]
|
||||
: resultState.mode === 'merge'
|
||||
? [
|
||||
];
|
||||
} else if (resultState.mode === 'merge') {
|
||||
lines = [
|
||||
['begin', 'end', 'durationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.begin,
|
||||
row.end,
|
||||
row.durationSec
|
||||
])
|
||||
]
|
||||
: [
|
||||
];
|
||||
} else if (resultState.mode === 'merge-on-rev') {
|
||||
lines = [
|
||||
['revolution', 'durationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.revolution,
|
||||
row.durationSec
|
||||
])
|
||||
];
|
||||
} else {
|
||||
lines = [
|
||||
['ppiNumber', 'ppiLatitude', 'averageCount', 'averageDurationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.ppiNumber,
|
||||
row.ppiLatitude,
|
||||
row.averageCount,
|
||||
row.averageDurationSec
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
const csv = lines.map(line => line.map(csvEscape).join(';')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
selectedSatelliteId: null,
|
||||
details: null
|
||||
};
|
||||
const sendInitialConditionsDateTimePattern = /^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/;
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('satellite-pdcm-alert');
|
||||
@@ -58,6 +59,121 @@
|
||||
return value ? value.replace('T', ' ') : '-';
|
||||
}
|
||||
|
||||
function pad(value, length = 2) {
|
||||
return String(value).padStart(length, '0');
|
||||
}
|
||||
|
||||
function currentDateTimeInputValue() {
|
||||
const now = new Date();
|
||||
return `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
|
||||
}
|
||||
|
||||
function formatDateTimeInput(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const match = String(value).match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?/
|
||||
);
|
||||
if (!match) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second = '00', millis = '000'] = match;
|
||||
return `${day}.${month}.${year} ${hour}:${minute}:${second}.${millis.slice(0, 3).padEnd(3, '0')}`;
|
||||
}
|
||||
|
||||
function parseSendInitialConditionsTime() {
|
||||
const input = document.getElementById('satellite-pdcm-send-ic-time');
|
||||
const value = input?.value.trim() || '';
|
||||
const match = value.match(sendInitialConditionsDateTimePattern);
|
||||
if (!match) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Введите время НУ в формате dd.mm.yyyy hh:MM:ss.zzz'
|
||||
};
|
||||
}
|
||||
|
||||
const [, day, month, year, hour, minute, second, millisecond] = match;
|
||||
const parsed = new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second),
|
||||
Number(millisecond)
|
||||
);
|
||||
const validDate = parsed.getFullYear() === Number(year)
|
||||
&& parsed.getMonth() === Number(month) - 1
|
||||
&& parsed.getDate() === Number(day)
|
||||
&& parsed.getHours() === Number(hour)
|
||||
&& parsed.getMinutes() === Number(minute)
|
||||
&& parsed.getSeconds() === Number(second)
|
||||
&& parsed.getMilliseconds() === Number(millisecond);
|
||||
|
||||
if (!validDate) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Время НУ содержит некорректную дату или время'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
text: value
|
||||
};
|
||||
}
|
||||
|
||||
function parseDateTimeForPayload(value, fieldName) {
|
||||
const text = value?.trim() || '';
|
||||
const match = text.match(sendInitialConditionsDateTimePattern);
|
||||
if (!match) {
|
||||
throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:MM:ss.zzz`);
|
||||
}
|
||||
|
||||
const [, day, month, year, hour, minute, second, millisecond] = match;
|
||||
const parsed = new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second),
|
||||
Number(millisecond)
|
||||
);
|
||||
const validDate = parsed.getFullYear() === Number(year)
|
||||
&& parsed.getMonth() === Number(month) - 1
|
||||
&& parsed.getDate() === Number(day)
|
||||
&& parsed.getHours() === Number(hour)
|
||||
&& parsed.getMinutes() === Number(minute)
|
||||
&& parsed.getSeconds() === Number(second)
|
||||
&& parsed.getMilliseconds() === Number(millisecond);
|
||||
|
||||
if (!validDate) {
|
||||
throw new Error(`${fieldName}: некорректная дата или время`);
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}`;
|
||||
}
|
||||
|
||||
function parseRequiredNumber(inputId, fieldName) {
|
||||
const value = document.getElementById(inputId)?.value;
|
||||
const number = Number(value);
|
||||
if (value === '' || !Number.isFinite(number)) {
|
||||
throw new Error(`${fieldName}: укажите число`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseRequiredInteger(inputId, fieldName) {
|
||||
const number = parseRequiredNumber(inputId, fieldName);
|
||||
if (!Number.isInteger(number)) {
|
||||
throw new Error(`${fieldName}: укажите целое число`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value ?? 0).toFixed(3);
|
||||
}
|
||||
@@ -71,6 +187,39 @@
|
||||
exportButton.disabled = !details || !details.ascNodes || !details.ascNodes.length;
|
||||
}
|
||||
|
||||
function renderSendInitialConditionsState(details, loading = false) {
|
||||
const button = document.getElementById('satellite-pdcm-send-ic');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = parseSendInitialConditionsTime();
|
||||
const enabled = !!details && time.valid && !loading;
|
||||
button.disabled = !enabled;
|
||||
button.textContent = loading ? 'Передача...' : 'Передать НУ в slot-service';
|
||||
if (!details) {
|
||||
button.title = 'Выберите спутник';
|
||||
} else if (!time.valid) {
|
||||
button.title = time.message;
|
||||
} else {
|
||||
button.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSetInitialConditionsState(details, loading = false) {
|
||||
const openButton = document.getElementById('satellite-pdcm-open-set-ic');
|
||||
const submitButton = document.getElementById('satellite-pdcm-set-ic-submit');
|
||||
const satelliteSelected = !!state.selectedSatelliteId;
|
||||
if (openButton) {
|
||||
openButton.disabled = !satelliteSelected || loading;
|
||||
openButton.title = satelliteSelected ? '' : 'Выберите спутник';
|
||||
}
|
||||
if (submitButton) {
|
||||
submitButton.disabled = loading;
|
||||
submitButton.textContent = loading ? 'Запуск...' : 'Запустить расчет';
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const tableBody = document.getElementById('satellite-pdcm-list');
|
||||
tableBody.innerHTML = '';
|
||||
@@ -110,6 +259,8 @@
|
||||
if (!details) {
|
||||
summary.innerHTML = '<div class="catalog-helper">Выберите спутник слева.</div>';
|
||||
renderExportState(null);
|
||||
renderSendInitialConditionsState(null);
|
||||
renderSetInitialConditionsState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,6 +291,8 @@
|
||||
</div>
|
||||
`;
|
||||
renderExportState(details);
|
||||
renderSendInitialConditionsState(details);
|
||||
renderSetInitialConditionsState(details);
|
||||
}
|
||||
|
||||
function renderTable(details) {
|
||||
@@ -270,9 +423,149 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function sendInitialConditions() {
|
||||
if (!state.selectedSatelliteId || !state.details) {
|
||||
showAlert('Выберите спутник для передачи начальных условий');
|
||||
return;
|
||||
}
|
||||
const time = parseSendInitialConditionsTime();
|
||||
if (!time.valid) {
|
||||
showAlert(time.message);
|
||||
return;
|
||||
}
|
||||
renderSendInitialConditionsState(state.details, true);
|
||||
showAlert('');
|
||||
try {
|
||||
await requestJson(`${apiBase}/${state.selectedSatelliteId}/send-ic?time=${encodeURIComponent(time.text)}`, {method: 'POST'});
|
||||
showAlert('Начальные условия спутника переданы в slot-service', 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось передать начальные условия в slot-service');
|
||||
} finally {
|
||||
renderSendInitialConditionsState(state.details);
|
||||
}
|
||||
}
|
||||
|
||||
function resetInitialConditionsForm() {
|
||||
document.getElementById('satellite-pdcm-ic-time').value = currentDateTimeInputValue();
|
||||
document.getElementById('satellite-pdcm-ic-revolution').value = '';
|
||||
document.getElementById('satellite-pdcm-ic-movement-model').value = 'FOTO';
|
||||
document.getElementById('satellite-pdcm-ic-s-ball').value = '0';
|
||||
document.getElementById('satellite-pdcm-ic-f81').value = '147.8';
|
||||
['x', 'y', 'z', 'vx', 'vy', 'vz'].forEach(field => {
|
||||
document.getElementById(`satellite-pdcm-ic-${field}`).value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function fillInitialConditionsForm(initialConditions) {
|
||||
document.getElementById('satellite-pdcm-ic-time').value =
|
||||
formatDateTimeInput(initialConditions?.ic?.orbPoint?.time) || currentDateTimeInputValue();
|
||||
document.getElementById('satellite-pdcm-ic-revolution').value =
|
||||
initialConditions?.ic?.orbPoint?.revolution ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-movement-model').value =
|
||||
initialConditions?.movementModel || 'FOTO';
|
||||
document.getElementById('satellite-pdcm-ic-s-ball').value =
|
||||
initialConditions?.ic?.sBall ?? '0';
|
||||
document.getElementById('satellite-pdcm-ic-f81').value =
|
||||
initialConditions?.ic?.f81 ?? '147.8';
|
||||
document.getElementById('satellite-pdcm-ic-x').value = initialConditions?.ic?.orbPoint?.x ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-y').value = initialConditions?.ic?.orbPoint?.y ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-z').value = initialConditions?.ic?.orbPoint?.z ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-vx').value = initialConditions?.ic?.orbPoint?.vx ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-vy').value = initialConditions?.ic?.orbPoint?.vy ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-vz').value = initialConditions?.ic?.orbPoint?.vz ?? '';
|
||||
}
|
||||
|
||||
async function openSetInitialConditionsModal() {
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('Выберите спутник для задания начальных условий');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedSatellite = state.details?.satelliteId === state.selectedSatelliteId
|
||||
? state.details
|
||||
: state.satellites.find(satellite => satellite.satelliteId === state.selectedSatelliteId);
|
||||
document.getElementById('satellite-pdcm-set-ic-target').textContent =
|
||||
selectedSatellite
|
||||
? `${selectedSatellite.name} · ID ${selectedSatellite.satelliteId}`
|
||||
: `ID ${state.selectedSatelliteId}`;
|
||||
resetInitialConditionsForm();
|
||||
|
||||
if (window.bootstrap) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(document.getElementById('satellite-pdcm-set-ic-modal')).show();
|
||||
}
|
||||
|
||||
try {
|
||||
const currentInitialConditions = await requestJson(`${apiBase}/${state.selectedSatelliteId}/initial-conditions`);
|
||||
if (currentInitialConditions) {
|
||||
fillInitialConditionsForm(currentInitialConditions);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось загрузить актуальные начальные условия');
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialConditionsPayload() {
|
||||
const movementModel = document.getElementById('satellite-pdcm-ic-movement-model').value || 'FOTO';
|
||||
return {
|
||||
satelliteId: state.selectedSatelliteId,
|
||||
movementModel,
|
||||
ic: {
|
||||
orbPoint: {
|
||||
time: parseDateTimeForPayload(document.getElementById('satellite-pdcm-ic-time').value, 'Time'),
|
||||
revolution: parseRequiredInteger('satellite-pdcm-ic-revolution', 'Revolution'),
|
||||
vx: parseRequiredNumber('satellite-pdcm-ic-vx', 'Vx'),
|
||||
vy: parseRequiredNumber('satellite-pdcm-ic-vy', 'Vy'),
|
||||
vz: parseRequiredNumber('satellite-pdcm-ic-vz', 'Vz'),
|
||||
x: parseRequiredNumber('satellite-pdcm-ic-x', 'X'),
|
||||
y: parseRequiredNumber('satellite-pdcm-ic-y', 'Y'),
|
||||
z: parseRequiredNumber('satellite-pdcm-ic-z', 'Z')
|
||||
},
|
||||
sBall: parseRequiredNumber('satellite-pdcm-ic-s-ball', 'S-ball'),
|
||||
f81: parseRequiredNumber('satellite-pdcm-ic-f81', 'F81')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function submitInitialConditions(event) {
|
||||
event.preventDefault();
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('Выберите спутник для задания начальных условий');
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = buildInitialConditionsPayload();
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Проверьте параметры начальных условий');
|
||||
return;
|
||||
}
|
||||
|
||||
renderSetInitialConditionsState(state.details, true);
|
||||
showAlert('');
|
||||
try {
|
||||
await requestJson(`${apiBase}/${state.selectedSatelliteId}/initial-conditions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const modalElement = document.getElementById('satellite-pdcm-set-ic-modal');
|
||||
if (window.bootstrap && modalElement) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modalElement).hide();
|
||||
}
|
||||
showAlert('Расчет ПДЦМ по начальным условиям запущен', 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось запустить расчет ПДЦМ по начальным условиям');
|
||||
} finally {
|
||||
renderSetInitialConditionsState(state.details);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSatellite(satelliteId) {
|
||||
state.selectedSatelliteId = satelliteId;
|
||||
state.details = null;
|
||||
renderList();
|
||||
renderSendInitialConditionsState(null);
|
||||
renderSetInitialConditionsState(null);
|
||||
showAlert('');
|
||||
|
||||
try {
|
||||
@@ -284,6 +577,7 @@
|
||||
state.details = null;
|
||||
renderSummary(null);
|
||||
renderTable(null);
|
||||
renderSetInitialConditionsState(null);
|
||||
showAlert(error.message || 'Не удалось загрузить ПДЦМ спутника');
|
||||
}
|
||||
}
|
||||
@@ -299,12 +593,14 @@
|
||||
if (nextSatelliteId) {
|
||||
await selectSatellite(nextSatelliteId);
|
||||
} else {
|
||||
state.selectedSatelliteId = null;
|
||||
state.details = null;
|
||||
renderSummary(null);
|
||||
renderTable(null);
|
||||
}
|
||||
} catch (error) {
|
||||
state.satellites = [];
|
||||
state.selectedSatelliteId = null;
|
||||
state.details = null;
|
||||
renderList();
|
||||
renderSummary(null);
|
||||
@@ -317,6 +613,12 @@
|
||||
loadSatellites();
|
||||
});
|
||||
document.getElementById('satellite-pdcm-export').addEventListener('click', exportCurrentCsv);
|
||||
document.getElementById('satellite-pdcm-send-ic').addEventListener('click', sendInitialConditions);
|
||||
document.getElementById('satellite-pdcm-open-set-ic').addEventListener('click', openSetInitialConditionsModal);
|
||||
document.getElementById('satellite-pdcm-set-ic-form').addEventListener('submit', submitInitialConditions);
|
||||
const sendInitialConditionsTimeInput = document.getElementById('satellite-pdcm-send-ic-time');
|
||||
sendInitialConditionsTimeInput.value = currentDateTimeInputValue();
|
||||
sendInitialConditionsTimeInput.addEventListener('input', () => renderSendInitialConditionsState(state.details));
|
||||
|
||||
loadSatellites();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
(function () {
|
||||
const state = {
|
||||
satellites: [],
|
||||
selectedSatelliteId: null,
|
||||
analysis: null,
|
||||
loading: false
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
async function init() {
|
||||
bindEvents();
|
||||
await loadSatellites();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById('tle-analysis-refresh')?.addEventListener('click', async () => {
|
||||
state.selectedSatelliteId = null;
|
||||
state.analysis = null;
|
||||
state.loading = false;
|
||||
renderResults();
|
||||
await loadSatellites();
|
||||
});
|
||||
document.getElementById('tle-analysis-satellite-filter')?.addEventListener('input', renderSatellites);
|
||||
document.getElementById('tle-analysis-count')?.addEventListener('change', async () => {
|
||||
if (state.selectedSatelliteId) {
|
||||
await loadAnalysis(state.selectedSatelliteId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSatellites() {
|
||||
showAlert('Загрузка списка спутников...', 'info');
|
||||
try {
|
||||
const response = await fetch('/api/tle-analysis/satellites');
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorText(response));
|
||||
}
|
||||
state.satellites = normalizeSatelliteResponse(await response.json());
|
||||
renderSatellites();
|
||||
renderResults();
|
||||
clearAlert();
|
||||
} catch (error) {
|
||||
showAlert(`Не удалось загрузить спутники: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnalysis(satelliteId) {
|
||||
state.loading = true;
|
||||
state.analysis = null;
|
||||
renderResults();
|
||||
showAlert('Формирование анализа TLE...', 'info');
|
||||
try {
|
||||
const params = new URLSearchParams({ tleCount: String(selectedTleCount()) });
|
||||
const response = await fetch(`/api/tle-analysis/satellites/${encodeURIComponent(satelliteId)}/analysis?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorText(response));
|
||||
}
|
||||
state.analysis = await response.json();
|
||||
renderResults();
|
||||
clearAlert();
|
||||
} catch (error) {
|
||||
state.analysis = null;
|
||||
showAlert(`Не удалось сформировать анализ TLE: ${error.message}`, 'danger');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSatellites() {
|
||||
const tbody = document.getElementById('tle-analysis-satellites-body');
|
||||
if (!tbody) return;
|
||||
|
||||
const filter = normalize(document.getElementById('tle-analysis-satellite-filter')?.value || '');
|
||||
const satellites = state.satellites.filter((satellite) => {
|
||||
if (!filter) return true;
|
||||
return normalize([
|
||||
satellite.id,
|
||||
satellite.catalogId,
|
||||
satellite.noradId,
|
||||
satellite.code,
|
||||
satellite.name,
|
||||
satellite.typeCode,
|
||||
satellite.tleRecordsCount
|
||||
].join(' ')).includes(filter);
|
||||
});
|
||||
|
||||
if (satellites.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="3" class="catalog-empty">Спутники не найдены</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = satellites.map((satellite) => {
|
||||
const active = Number(satellite.id) === Number(state.selectedSatelliteId) ? 'active' : '';
|
||||
const title = escapeHtml(satellite.name || satellite.code || `КА ${satellite.id}`);
|
||||
const catalogId = satellite.catalogId == null ? satellite.id : satellite.catalogId;
|
||||
const tleCount = Number(satellite.tleRecordsCount || 0);
|
||||
return `
|
||||
<tr class="${active}" data-satellite-id="${escapeHtml(satellite.id)}">
|
||||
<td>${escapeHtml(catalogId)}</td>
|
||||
<td>
|
||||
<div class="catalog-main-text">${title}</div>
|
||||
<div class="catalog-helper">${escapeHtml(satellite.code || '')} ${satellite.typeCode ? ' / ' + escapeHtml(satellite.typeCode) : ''}</div>
|
||||
<div class="catalog-helper">TLE-записей: ${escapeHtml(tleCount)}</div>
|
||||
</td>
|
||||
<td>${satellite.noradId == null ? '—' : escapeHtml(satellite.noradId)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('tr[data-satellite-id]').forEach((row) => {
|
||||
row.addEventListener('click', async () => {
|
||||
const satelliteId = Number(row.dataset.satelliteId);
|
||||
state.selectedSatelliteId = satelliteId;
|
||||
state.analysis = null;
|
||||
renderSatellites();
|
||||
renderSelectedSatellite();
|
||||
await loadAnalysis(satelliteId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedSatellite() {
|
||||
const label = document.getElementById('tle-analysis-selected-satellite');
|
||||
if (!label) return;
|
||||
const satellite = state.satellites.find((item) => Number(item.id) === Number(state.selectedSatelliteId));
|
||||
if (!satellite) {
|
||||
label.textContent = 'Выберите спутник слева.';
|
||||
return;
|
||||
}
|
||||
const name = satellite.name || satellite.code || `КА ${satellite.id}`;
|
||||
const catalogId = satellite.catalogId == null ? satellite.id : satellite.catalogId;
|
||||
label.textContent = `КА ${catalogId}: ${name}${satellite.noradId == null ? '' : `, NORAD ${satellite.noradId}`}`;
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
renderSelectedSatellite();
|
||||
const empty = document.getElementById('tle-analysis-empty');
|
||||
const wrap = document.getElementById('tle-analysis-table-wrap');
|
||||
const tbody = document.getElementById('tle-analysis-results-body');
|
||||
const summary = document.getElementById('tle-analysis-summary');
|
||||
if (!empty || !wrap || !tbody || !summary) return;
|
||||
|
||||
if (state.loading) {
|
||||
empty.textContent = 'Формирование анализа TLE...';
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
summary.textContent = '';
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = state.analysis?.rows || [];
|
||||
summary.textContent = rows.length ? `Записей TLE: ${rows.length}` : '';
|
||||
|
||||
if (!state.selectedSatelliteId) {
|
||||
empty.textContent = 'Выберите спутник для загрузки TLE.';
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = 'Для выбранного спутника TLE не найдены.';
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.classList.add('d-none');
|
||||
wrap.classList.remove('d-none');
|
||||
tbody.innerHTML = rows.map(renderAnalysisRow).join('');
|
||||
}
|
||||
|
||||
function renderAnalysisRow(row) {
|
||||
const r = row.radiusVector || {};
|
||||
const discrepancy = row.discrepancy;
|
||||
const diffText = discrepancy
|
||||
? `<div class="tle-analysis-discrepancy">${formatNumber(discrepancy.norm, 3)}</div>
|
||||
<div class="catalog-helper tle-analysis-vector">Δx=${formatNumber(discrepancy.dx, 3)}, Δy=${formatNumber(discrepancy.dy, 3)}, Δz=${formatNumber(discrepancy.dz, 3)}</div>
|
||||
<div class="catalog-helper">к предыдущей эпохе ${escapeHtml(formatDateTime(discrepancy.previousEpoch))}</div>`
|
||||
: '<span class="catalog-helper">не рассчитывается</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(row.index)}</td>
|
||||
<td>
|
||||
<div>${escapeHtml(formatDateTime(row.epoch))}</div>
|
||||
${row.tleHeader ? `<div class="catalog-helper">${escapeHtml(row.tleHeader)}</div>` : ''}
|
||||
</td>
|
||||
<td>${escapeHtml(row.revolution)}</td>
|
||||
<td>${escapeHtml(row.noradId)}</td>
|
||||
<td class="tle-analysis-vector">
|
||||
x=${formatNumber(r.x, 3)}<br>
|
||||
y=${formatNumber(r.y, 3)}<br>
|
||||
z=${formatNumber(r.z, 3)}
|
||||
</td>
|
||||
<td>${formatNumber(row.radiusNorm, 3)}</td>
|
||||
<td>${discrepancy ? formatNumber(discrepancy.epochDifferenceHours, 3) : '—'}</td>
|
||||
<td>${diffText}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function normalizeSatelliteResponse(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items;
|
||||
}
|
||||
if (Array.isArray(payload?.content)) {
|
||||
return payload.content;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function selectedTleCount() {
|
||||
const input = document.getElementById('tle-analysis-count');
|
||||
const value = Number(input?.value || 5);
|
||||
if (!Number.isFinite(value) || value < 1) {
|
||||
if (input) input.value = '5';
|
||||
return 5;
|
||||
}
|
||||
const count = Math.floor(value);
|
||||
if (input && String(count) !== input.value) {
|
||||
input.value = String(count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—';
|
||||
return String(value).replace('T', ' ');
|
||||
}
|
||||
|
||||
function formatNumber(value, fractionDigits) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '—';
|
||||
return number.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits
|
||||
});
|
||||
}
|
||||
|
||||
async function errorText(response) {
|
||||
const text = await response.text();
|
||||
if (!text) return `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json.message || json.error || text;
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alert = document.getElementById('tle-analysis-alert');
|
||||
if (!alert) return;
|
||||
alert.innerHTML = `<div class="alert alert-${type} py-2" role="alert">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function clearAlert() {
|
||||
const alert = document.getElementById('tle-analysis-alert');
|
||||
if (alert) alert.innerHTML = '';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,269 @@
|
||||
(() => {
|
||||
const state = {
|
||||
first: null,
|
||||
second: null,
|
||||
parseTimers: {}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
bindEvents();
|
||||
updateReference('first', null);
|
||||
updateReference('second', null);
|
||||
updateTimeLimits();
|
||||
});
|
||||
|
||||
function bindEvents() {
|
||||
const first = el('tle-first-text');
|
||||
const second = el('tle-second-text');
|
||||
first?.addEventListener('input', () => scheduleParse('first'));
|
||||
second?.addEventListener('input', () => scheduleParse('second'));
|
||||
el('tle-comparison-time')?.addEventListener('change', validateCalculationTime);
|
||||
el('tle-compare-submit')?.addEventListener('click', compareTle);
|
||||
el('tle-compare-clear')?.addEventListener('click', clearPage);
|
||||
}
|
||||
|
||||
function scheduleParse(side) {
|
||||
window.clearTimeout(state.parseTimers[side]);
|
||||
const text = getTleText(side);
|
||||
if (!text.trim()) {
|
||||
state[side] = null;
|
||||
updateReference(side, null);
|
||||
updateTimeLimits();
|
||||
return;
|
||||
}
|
||||
setStatus(side, 'Разбор...', 'text-muted');
|
||||
state.parseTimers[side] = window.setTimeout(() => parseTle(side), 350);
|
||||
}
|
||||
|
||||
async function parseTle(side) {
|
||||
try {
|
||||
const parsed = await postJson('/api/tle-comparison/parse', { tle: getTleText(side) });
|
||||
state[side] = parsed;
|
||||
updateReference(side, parsed);
|
||||
updateTimeLimits();
|
||||
setStatus(side, 'Разобрано', 'tle-parse-ok');
|
||||
} catch (error) {
|
||||
state[side] = null;
|
||||
updateReference(side, null);
|
||||
updateTimeLimits();
|
||||
setStatus(side, error.message || 'Ошибка разбора', 'tle-parse-error');
|
||||
}
|
||||
}
|
||||
|
||||
async function compareTle() {
|
||||
showAlert('');
|
||||
const first = getTleText('first');
|
||||
const second = getTleText('second');
|
||||
const time = getCalculationTime();
|
||||
if (!first.trim() || !second.trim()) {
|
||||
showAlert('Введите оба TLE для сравнения.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!time) {
|
||||
showAlert('Задайте время расчета.', 'warning');
|
||||
return;
|
||||
}
|
||||
const minTime = maxEpoch();
|
||||
if (minTime && time < minTime) {
|
||||
showAlert(`Время расчета должно быть не раньше максимальной эпохи TLE: ${formatDateTime(minTime)}.`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setCompareLoading(true);
|
||||
try {
|
||||
const response = await postJson('/api/tle-comparison/compare', { first, second, time });
|
||||
renderComparison(response);
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось выполнить сравнение TLE.', 'danger');
|
||||
} finally {
|
||||
setCompareLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderComparison(response) {
|
||||
const rows = response?.rows || [];
|
||||
const body = el('tle-comparison-result-body');
|
||||
const empty = el('tle-comparison-empty');
|
||||
const wrap = el('tle-comparison-table-wrap');
|
||||
const meta = el('tle-comparison-result-meta');
|
||||
if (!body || !empty || !wrap) return;
|
||||
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '';
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
if (meta) meta.textContent = 'Нет данных для сравнения.';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = rows.map((row) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(row.name || row.code || '')}</td>
|
||||
<td>${escapeHtml(emptyToDash(row.first))}</td>
|
||||
<td>${escapeHtml(emptyToDash(row.second))}</td>
|
||||
<td class="${deltaClass(row.delta)}">${escapeHtml(emptyToDash(row.delta))}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
empty.classList.add('d-none');
|
||||
wrap.classList.remove('d-none');
|
||||
if (meta) {
|
||||
const deltaNorm = response.radiusVectorDelta?.norm;
|
||||
const deltaText = Number.isFinite(deltaNorm) ? `${formatNumber(deltaNorm)} м` : '—';
|
||||
meta.textContent = `Время расчета: ${formatDateTime(response.time)}. |Δr| = ${deltaText}. Эпохи: TLE 1 — ${formatDateTime(response.firstEpoch)}, TLE 2 — ${formatDateTime(response.secondEpoch)}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateReference(side, parsed) {
|
||||
setText(`tle-${side}-epoch`, formatDateTime(parsed?.time) || '—');
|
||||
setText(`tle-${side}-norad`, parsed?.noradId ?? '—');
|
||||
setText(`tle-${side}-revolution`, parsed?.revolution ?? '—');
|
||||
if (!parsed) {
|
||||
const text = getTleText(side);
|
||||
setStatus(side, text.trim() ? 'Ошибка разбора' : 'Нет данных', text.trim() ? 'tle-parse-error' : 'text-muted');
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimeLimits() {
|
||||
const input = el('tle-comparison-time');
|
||||
const helper = el('tle-comparison-time-helper');
|
||||
const minTime = maxEpoch();
|
||||
if (!input) return;
|
||||
if (!minTime) {
|
||||
input.removeAttribute('min');
|
||||
if (helper) helper.textContent = 'Минимальное допустимое время появится после разбора двух TLE.';
|
||||
return;
|
||||
}
|
||||
input.min = toDateTimeLocalValue(minTime);
|
||||
if (!input.value || input.value < input.min) {
|
||||
input.value = input.min;
|
||||
}
|
||||
if (helper) helper.textContent = `Минимальное допустимое время: ${formatDateTime(minTime)}.`;
|
||||
}
|
||||
|
||||
function validateCalculationTime() {
|
||||
const input = el('tle-comparison-time');
|
||||
const minTime = maxEpoch();
|
||||
if (!input || !minTime || !input.value) return;
|
||||
const time = getCalculationTime();
|
||||
if (time && time < minTime) {
|
||||
showAlert(`Время расчета должно быть не раньше максимальной эпохи TLE: ${formatDateTime(minTime)}.`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function clearPage() {
|
||||
el('tle-first-text').value = '';
|
||||
el('tle-second-text').value = '';
|
||||
const time = el('tle-comparison-time');
|
||||
if (time) time.value = '';
|
||||
state.first = null;
|
||||
state.second = null;
|
||||
updateReference('first', null);
|
||||
updateReference('second', null);
|
||||
updateTimeLimits();
|
||||
showAlert('');
|
||||
renderComparison({ rows: [] });
|
||||
setText('tle-comparison-result-meta', 'Задайте время не раньше максимальной эпохи TLE и нажмите “Сравнить”.');
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const error = await response.json();
|
||||
message = error.error || Object.values(error)[0] || message;
|
||||
} catch (_) {
|
||||
message = await response.text() || message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function maxEpoch() {
|
||||
if (!state.first?.time || !state.second?.time) return null;
|
||||
return state.first.time >= state.second.time ? state.first.time : state.second.time;
|
||||
}
|
||||
|
||||
function getCalculationTime() {
|
||||
const value = el('tle-comparison-time')?.value;
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
function getTleText(side) {
|
||||
return el(`tle-${side}-text`)?.value || '';
|
||||
}
|
||||
|
||||
function setStatus(side, text, cssClass) {
|
||||
const node = el(`tle-${side}-status`);
|
||||
if (!node) return;
|
||||
node.className = `tle-parse-status ${cssClass || 'text-muted'}`;
|
||||
node.textContent = text;
|
||||
}
|
||||
|
||||
function setCompareLoading(loading) {
|
||||
const button = el('tle-compare-submit');
|
||||
if (!button) return;
|
||||
button.disabled = loading;
|
||||
button.textContent = loading ? 'Расчет...' : 'Рассчитать';
|
||||
}
|
||||
|
||||
function showAlert(message, type = 'info') {
|
||||
const container = el('tle-comparison-alert');
|
||||
if (!container) return;
|
||||
container.innerHTML = message
|
||||
? `<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
|
||||
</div>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value) {
|
||||
if (!value) return '';
|
||||
return String(value).slice(0, 19);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replace('T', ' ');
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value).toLocaleString('ru-RU', { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function emptyToDash(value) {
|
||||
return value === null || value === undefined || value === '' ? '—' : value;
|
||||
}
|
||||
|
||||
function deltaClass(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (text.startsWith('+')) return 'tle-delta-positive';
|
||||
if (text.startsWith('-')) return 'tle-delta-negative';
|
||||
return '';
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const node = el(id);
|
||||
if (node) node.textContent = value;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Бронирования</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/bookings.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="bookings-page" class="catalog-page bookings-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Бронирования</h2>
|
||||
<div class="catalog-helper">Табличный просмотр забронированных слотов с фильтрацией по времени, заявке и спутникам.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button id="bookings-reset" type="button" class="btn btn-outline-secondary">Сбросить</button>
|
||||
<button id="bookings-refresh" type="button" class="btn btn-primary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bookings-alert"></div>
|
||||
|
||||
<div class="card catalog-card bookings-filter-card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-time-start" class="form-label">Начало интервала</label>
|
||||
<input id="bookings-time-start" type="datetime-local" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-time-stop" class="form-label">Конец интервала</label>
|
||||
<input id="bookings-time-stop" type="datetime-local" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-request" class="form-label">Заявка</label>
|
||||
<select id="bookings-request" class="form-select form-select-sm">
|
||||
<option value="">Все заявки</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-satellite-search" class="form-label">Фильтр спутников</label>
|
||||
<input id="bookings-satellite-search"
|
||||
type="search"
|
||||
class="form-control form-control-sm"
|
||||
autocomplete="off"
|
||||
placeholder="ID, NORAD, код, имя, тип">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 bookings-layout">
|
||||
<div class="col-12 col-xl-3">
|
||||
<div class="card catalog-card bookings-satellites-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<div class="catalog-section-title">Спутники</div>
|
||||
<div class="catalog-helper mt-1">Выберите один или несколько КА.</div>
|
||||
</div>
|
||||
<button id="bookings-satellites-clear" type="button" class="btn btn-outline-secondary btn-sm">Выбрать все</button>
|
||||
</div>
|
||||
<div class="card-body p-0 bookings-satellites-wrap">
|
||||
<table class="table table-hover mb-0 bookings-satellites-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="bookings-check-col"></th>
|
||||
<th>ID</th>
|
||||
<th>Спутник</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookings-satellites-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-9">
|
||||
<div class="card catalog-card bookings-table-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Забронированные слоты</div>
|
||||
<div id="bookings-summary" class="catalog-helper mt-1">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="bookings-empty" class="catalog-empty d-none">Забронированные слоты не найдены.</div>
|
||||
<div id="bookings-table-wrap" class="table-responsive bookings-table-wrap">
|
||||
<table class="table table-hover mb-0 bookings-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Бронь</th>
|
||||
<th>КА</th>
|
||||
<th>Слот / цикл</th>
|
||||
<th>Время</th>
|
||||
<th>Виток</th>
|
||||
<th>Крен</th>
|
||||
<th>Статус</th>
|
||||
<th>Заявки</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookings-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/bookings_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,6 +46,8 @@
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="/satellites">Управление</a></li>
|
||||
<li><a class="dropdown-item" href="/satellites/pdcm">ПДЦМ</a></li>
|
||||
<li><a class="dropdown-item" href="/tle-comparison">Сравнение TLE</a></li>
|
||||
<li><a class="dropdown-item" href="/tle-analysis">Анализ TLE</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -74,6 +76,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/tgu-planning">Планирование ТГУ</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/bookings">Бронирования</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -288,13 +288,23 @@
|
||||
<input class="form-check-input" type="checkbox" id="toggleStations" checked>
|
||||
<label class="form-check-label" for="toggleStations">Показать станции</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="toggleCoverage">
|
||||
<label class="form-check-label" for="toggleCoverage">Показать зоны покрытия</label>
|
||||
</div>
|
||||
<select class="form-select form-select-sm mb-2">
|
||||
<option selected>Все типы станций</option>
|
||||
<option value="1">Наземные</option>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="toggleCoverage">
|
||||
<label class="form-check-label" for="toggleCoverage">Показать зоны покрытия</label>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="stationOrbitHeightKm" class="form-label form-label-sm mb-1">Высота орбиты, км</label>
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
id="stationOrbitHeightKm"
|
||||
min="1"
|
||||
step="1"
|
||||
value="500"
|
||||
onchange="handleStationOrbitHeightChange()">
|
||||
</div>
|
||||
<select class="form-select form-select-sm mb-2">
|
||||
<option selected>Все типы станций</option>
|
||||
<option value="1">Наземные</option>
|
||||
<option value="2">Мобильные</option>
|
||||
<option value="3">Контрольные</option>
|
||||
</select>
|
||||
@@ -1701,16 +1711,23 @@
|
||||
}
|
||||
|
||||
|
||||
function checkStation(show, st_id) {
|
||||
const sts = [[${stations}]];
|
||||
const req = sts.find(function(element) {
|
||||
return element.id == st_id;
|
||||
});
|
||||
function checkStation(show, st_id) {
|
||||
const sts = [[${stations}]];
|
||||
const req = sts.find(function(element) {
|
||||
return element.id == st_id;
|
||||
});
|
||||
if (req) {
|
||||
console.log('изменить видимость станции ' + req.id)
|
||||
drawSt(show, req);
|
||||
}
|
||||
}
|
||||
drawSt(show, req);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStationOrbitHeightChange() {
|
||||
document.querySelectorAll('#stationsTableBody input[type="checkbox"]:checked').forEach(function(checkbox) {
|
||||
checkStation(false, checkbox.value);
|
||||
checkStation(true, checkbox.value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Функция для обработки запросов
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-merge-on-rev" value="merge-on-rev">
|
||||
<label class="form-check-label" for="rva-mode-merge-on-rev">Суммарная длительность на витке</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-average" value="average">
|
||||
<label class="form-check-label" for="rva-mode-average">Средняя длительность</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,22 @@
|
||||
<h2 class="mb-1">Параметры движения центра масс</h2>
|
||||
<div class="catalog-helper">Табличное представление asc-node по выбранному спутнику.</div>
|
||||
</div>
|
||||
<button id="satellite-pdcm-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
<div class="d-flex gap-2 flex-wrap justify-content-end align-items-end satellite-pdcm-send-controls">
|
||||
<div>
|
||||
<label for="satellite-pdcm-send-ic-time" class="form-label catalog-helper mb-1">Время НУ</label>
|
||||
<input
|
||||
id="satellite-pdcm-send-ic-time"
|
||||
type="text"
|
||||
class="form-control satellite-pdcm-send-time"
|
||||
inputmode="numeric"
|
||||
placeholder="dd.mm.yyyy hh:MM:ss.zzz"
|
||||
aria-label="Время НУ"
|
||||
/>
|
||||
</div>
|
||||
<button id="satellite-pdcm-send-ic" type="button" class="btn btn-outline-primary" disabled>Передать НУ в slot-service</button>
|
||||
<button id="satellite-pdcm-open-set-ic" type="button" class="btn btn-primary" disabled>Задать НУ</button>
|
||||
<button id="satellite-pdcm-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="satellite-pdcm-alert"></div>
|
||||
@@ -66,6 +81,84 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="satellite-pdcm-set-ic-modal" tabindex="-1" aria-labelledby="satellite-pdcm-set-ic-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<form id="satellite-pdcm-set-ic-form" class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="satellite-pdcm-set-ic-title">Задать начальные условия</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="satellite-pdcm-set-ic-target" class="catalog-helper mb-3">Выберите спутник слева.</div>
|
||||
|
||||
<div class="satellite-pdcm-ic-grid">
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-time" class="form-label">Time</label>
|
||||
<input id="satellite-pdcm-ic-time" type="text" class="form-control" placeholder="dd.mm.yyyy hh:mm:ss.zzz" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-revolution" class="form-label">Revolution</label>
|
||||
<input id="satellite-pdcm-ic-revolution" type="number" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-movement-model" class="form-label">Модель движения</label>
|
||||
<select id="satellite-pdcm-ic-movement-model" class="form-select" required>
|
||||
<option value="FOTO" selected>FOTO</option>
|
||||
<option value="KONDOR">KONDOR</option>
|
||||
<option value="METEORM1">METEORM1</option>
|
||||
<option value="METEORM2">METEORM2</option>
|
||||
<option value="BARS">BARS</option>
|
||||
<option value="KONDOR_PROGNOZ">KONDOR_PROGNOZ</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-s-ball" class="form-label">S-ball</label>
|
||||
<input id="satellite-pdcm-ic-s-ball" type="number" step="any" class="form-control" value="0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-f81" class="form-label">F81</label>
|
||||
<input id="satellite-pdcm-ic-f81" type="number" step="any" class="form-control" value="147.8" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="satellite-pdcm-ic-grid mt-3">
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-x" class="form-label">X</label>
|
||||
<input id="satellite-pdcm-ic-x" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-y" class="form-label">Y</label>
|
||||
<input id="satellite-pdcm-ic-y" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-z" class="form-label">Z</label>
|
||||
<input id="satellite-pdcm-ic-z" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="satellite-pdcm-ic-grid mt-3">
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-vx" class="form-label">Vx</label>
|
||||
<input id="satellite-pdcm-ic-vx" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-vy" class="form-label">Vy</label>
|
||||
<input id="satellite-pdcm-ic-vy" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-vz" class="form-label">Vz</label>
|
||||
<input id="satellite-pdcm-ic-vz" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button id="satellite-pdcm-set-ic-submit" type="submit" class="btn btn-primary">Запустить расчет</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/satellite_pdcm_scripts.js"></script>
|
||||
</th:block>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Анализ TLE</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/tle-analysis.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="tle-analysis-page" class="catalog-page tle-analysis-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Анализ TLE</h2>
|
||||
<div class="catalog-helper">Анализ изменения TLE выбранного спутника по разнице рассчитанных радиус-векторов.</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-end gap-2 flex-wrap">
|
||||
<div>
|
||||
<label for="tle-analysis-count" class="form-label mb-1 small text-muted">Количество TLE</label>
|
||||
<input id="tle-analysis-count"
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
min="1"
|
||||
step="1"
|
||||
value="5">
|
||||
</div>
|
||||
<button id="tle-analysis-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tle-analysis-alert"></div>
|
||||
|
||||
<div class="row g-3 tle-analysis-layout">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card catalog-card tle-analysis-sidebar h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Спутники</div>
|
||||
<div class="catalog-helper mt-1">Выберите КА для формирования аналитической таблицы TLE.</div>
|
||||
</div>
|
||||
<div class="card-body p-2 tle-analysis-filter-body">
|
||||
<label for="tle-analysis-satellite-filter" class="form-label mb-1 small text-muted">Фильтр</label>
|
||||
<input id="tle-analysis-satellite-filter"
|
||||
type="search"
|
||||
class="form-control form-control-sm"
|
||||
autocomplete="off"
|
||||
placeholder="ID, NORAD, код, имя, тип">
|
||||
</div>
|
||||
<div class="card-body p-0 tle-analysis-satellites-wrap">
|
||||
<table class="table table-hover mb-0 tle-analysis-satellites-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Спутник</th>
|
||||
<th>NORAD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tle-analysis-satellites-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card catalog-card tle-analysis-results-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Таблица анализа TLE</div>
|
||||
<div id="tle-analysis-selected-satellite" class="catalog-helper mt-1">Выберите спутник слева.</div>
|
||||
</div>
|
||||
<div id="tle-analysis-summary" class="catalog-helper text-end"></div>
|
||||
</div>
|
||||
<div class="card-body p-0 tle-analysis-results-wrap">
|
||||
<div id="tle-analysis-empty" class="catalog-empty">Выберите спутник для загрузки TLE.</div>
|
||||
<div id="tle-analysis-table-wrap" class="table-responsive d-none">
|
||||
<table class="table table-hover table-sm mb-0 tle-analysis-results-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Эпоха</th>
|
||||
<th>Виток</th>
|
||||
<th>NORAD</th>
|
||||
<th>Радиус-вектор, м</th>
|
||||
<th>|r|, м</th>
|
||||
<th>Разница Времени, ч</th>
|
||||
<th>Расхождение, м</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tle-analysis-results-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/tle_analysis_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Сравнение TLE</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/tle-comparison.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="tle-comparison-page" class="catalog-page tle-comparison-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Сравнение TLE</h2>
|
||||
<div class="catalog-helper">Вставьте два набора TLE, проверьте эпохи и рассчитайте положение спутника на заданный момент времени.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button id="tle-compare-submit" type="button" class="btn btn-primary">Рассчитать</button>
|
||||
<button id="tle-compare-clear" type="button" class="btn btn-outline-secondary">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tle-comparison-alert"></div>
|
||||
|
||||
<div class="card catalog-card tle-input-card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Ввод данных</div>
|
||||
<div class="catalog-helper mt-1">Можно вставить две или три строки TLE. Заголовок определяется автоматически, строки 1 и 2 обязательны. Время расчета должно быть не раньше максимальной эпохи двух TLE.</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-5 col-xl-4">
|
||||
<label for="tle-comparison-time" class="form-label">Время расчета</label>
|
||||
<input id="tle-comparison-time" type="datetime-local" class="form-control" step="1"/>
|
||||
</div>
|
||||
<div class="col-12 col-md-7 col-xl-8 d-flex align-items-end">
|
||||
<div id="tle-comparison-time-helper" class="catalog-helper">Минимальное допустимое время появится после разбора двух TLE.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="tle-input-widget h-100">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
|
||||
<label for="tle-first-text" class="form-label mb-0">TLE 1</label>
|
||||
<span id="tle-first-status" class="tle-parse-status text-muted">Нет данных</span>
|
||||
</div>
|
||||
<textarea id="tle-first-text"
|
||||
class="form-control tle-textarea"
|
||||
spellcheck="false"
|
||||
placeholder="ISS (ZARYA) 1 25544U 98067A ... 2 25544 ..."></textarea>
|
||||
<div class="tle-reference mt-3">
|
||||
<div class="tle-reference-row">
|
||||
<span>Эпоха TLE</span>
|
||||
<strong id="tle-first-epoch">—</strong>
|
||||
</div>
|
||||
<div class="tle-reference-row">
|
||||
<span>NORAD</span>
|
||||
<strong id="tle-first-norad">—</strong>
|
||||
</div>
|
||||
<div class="tle-reference-row">
|
||||
<span>Виток</span>
|
||||
<strong id="tle-first-revolution">—</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="tle-input-widget h-100">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
|
||||
<label for="tle-second-text" class="form-label mb-0">TLE 2</label>
|
||||
<span id="tle-second-status" class="tle-parse-status text-muted">Нет данных</span>
|
||||
</div>
|
||||
<textarea id="tle-second-text"
|
||||
class="form-control tle-textarea"
|
||||
spellcheck="false"
|
||||
placeholder="ISS (ZARYA) 1 25544U 98067A ... 2 25544 ..."></textarea>
|
||||
<div class="tle-reference mt-3">
|
||||
<div class="tle-reference-row">
|
||||
<span>Эпоха TLE</span>
|
||||
<strong id="tle-second-epoch">—</strong>
|
||||
</div>
|
||||
<div class="tle-reference-row">
|
||||
<span>NORAD</span>
|
||||
<strong id="tle-second-norad">—</strong>
|
||||
</div>
|
||||
<div class="tle-reference-row">
|
||||
<span>Виток</span>
|
||||
<strong id="tle-second-revolution">—</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card tle-result-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Результаты</div>
|
||||
<div id="tle-comparison-result-meta" class="catalog-helper mt-1">Задайте время не раньше максимальной эпохи TLE и нажмите “Сравнить”.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="tle-comparison-empty" class="catalog-empty">Параметры положения и разность радиус-векторов появятся здесь.</div>
|
||||
<div id="tle-comparison-table-wrap" class="table-responsive tle-result-table-wrap d-none">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Параметр</th>
|
||||
<th>TLE 1</th>
|
||||
<th>TLE 2</th>
|
||||
<th>Разница / значение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tle-comparison-result-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/tle_comparison_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
+140
@@ -1,10 +1,12 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.doThrow
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
@@ -14,6 +16,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.core.publisher.Mono
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
|
||||
@@ -34,6 +37,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
|
||||
import space.nstart.pcp.slots_service.service.BallisticsService
|
||||
import space.nstart.pcp.slots_service.service.GroupStateService
|
||||
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
|
||||
import space.nstart.pcp.slots_service.service.SatellitePdcmService
|
||||
@@ -77,6 +81,9 @@ class CatalogControllerTest {
|
||||
@MockitoBean
|
||||
private lateinit var tguPlanningService: TguPlanningService
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var ballisticsService: BallisticsService
|
||||
|
||||
@Test
|
||||
fun `groups page is rendered`() {
|
||||
val body = WebClient.create("http://localhost:$port")
|
||||
@@ -139,9 +146,31 @@ class CatalogControllerTest {
|
||||
assertTrue(body.contains("Asc-node"))
|
||||
assertTrue(body.contains("Спутники"))
|
||||
assertTrue(body.contains("Скачать CSV"))
|
||||
assertTrue(body.contains("satellite-pdcm-send-ic-time"))
|
||||
assertTrue(body.contains("Задать НУ"))
|
||||
assertTrue(body.contains("satellite-pdcm-set-ic-modal"))
|
||||
assertTrue(body.contains("value=\"FOTO\" selected"))
|
||||
assertTrue(body.contains("dd.mm.yyyy hh:MM:ss.zzz"))
|
||||
assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tle analysis page is rendered with page script`() {
|
||||
val body = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/tle-analysis")
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.block()!!
|
||||
|
||||
assertTrue(body.contains("Анализ TLE"))
|
||||
assertTrue(body.contains("tle-analysis-satellites-body"))
|
||||
assertTrue(body.contains("tle-analysis-count"))
|
||||
assertTrue(body.contains("Разница Времени, ч"))
|
||||
assertTrue(body.contains("value=\"5\""))
|
||||
assertTrue(body.contains("/tle_analysis_scripts.js"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stations page is rendered`() {
|
||||
val body = WebClient.create("http://localhost:$port")
|
||||
@@ -334,6 +363,114 @@ class CatalogControllerTest {
|
||||
verify(satellitePdcmService).satelliteSummaries()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite pdcm send initial conditions endpoint parses ui time and proxies request`() {
|
||||
val requestedTime = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000)
|
||||
|
||||
val status = WebClient.create("http://localhost:$port")
|
||||
.post()
|
||||
.uri { builder ->
|
||||
builder
|
||||
.path("/api/catalog/satellites/pdcm/501/send-ic")
|
||||
.queryParam("time", "24.04.2026 10:15:30.123")
|
||||
.build()
|
||||
}
|
||||
.exchangeToMono { response -> Mono.just(response.statusCode()) }
|
||||
.block()!!
|
||||
|
||||
assertEquals(HttpStatus.ACCEPTED, status)
|
||||
verify(slotService).sendSatelliteInitialConditions(501L, requestedTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite pdcm send initial conditions endpoint rejects invalid ui time`() {
|
||||
val status = WebClient.create("http://localhost:$port")
|
||||
.post()
|
||||
.uri { builder ->
|
||||
builder
|
||||
.path("/api/catalog/satellites/pdcm/501/send-ic")
|
||||
.queryParam("time", "2026-04-24T10:15:30")
|
||||
.build()
|
||||
}
|
||||
.exchangeToMono { response -> Mono.just(response.statusCode()) }
|
||||
.block()!!
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, status)
|
||||
verify(slotService, never()).sendSatelliteInitialConditions(eq(501L), anyLocalDateTime())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite pdcm set initial conditions endpoint proxies request to ballistics`() {
|
||||
val request = SatelliteICDTO(
|
||||
satelliteId = 999L,
|
||||
movementModel = MovementModel.KONDOR,
|
||||
ic = InitialConditionsDTO(
|
||||
orbPoint = OrbPointDTO(
|
||||
time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000),
|
||||
revolution = 42L,
|
||||
vx = 1.0,
|
||||
vy = 2.0,
|
||||
vz = 3.0,
|
||||
x = 4.0,
|
||||
y = 5.0,
|
||||
z = 6.0
|
||||
),
|
||||
sBall = 0.07,
|
||||
f81 = 145.2
|
||||
)
|
||||
)
|
||||
|
||||
val status = WebClient.create("http://localhost:$port")
|
||||
.post()
|
||||
.uri("/api/catalog/satellites/pdcm/501/initial-conditions")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request)
|
||||
.exchangeToMono { response -> Mono.just(response.statusCode()) }
|
||||
.block()!!
|
||||
|
||||
val requestCaptor = ArgumentCaptor.forClass(SatelliteICDTO::class.java)
|
||||
assertEquals(HttpStatus.ACCEPTED, status)
|
||||
verify(ballisticsService).receiveRv(captureSatelliteIc(requestCaptor))
|
||||
assertEquals(501L, requestCaptor.value.satelliteId)
|
||||
assertEquals(MovementModel.KONDOR, requestCaptor.value.movementModel)
|
||||
assertEquals(42L, requestCaptor.value.ic.orbPoint.revolution)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite pdcm current initial conditions endpoint proxies request to ballistics`() {
|
||||
val response = SatelliteICDTO(
|
||||
satelliteId = 501L,
|
||||
movementModel = MovementModel.KONDOR,
|
||||
ic = InitialConditionsDTO(
|
||||
orbPoint = OrbPointDTO(
|
||||
time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000),
|
||||
revolution = 42L,
|
||||
vx = 1.0,
|
||||
vy = 2.0,
|
||||
vz = 3.0,
|
||||
x = 4.0,
|
||||
y = 5.0,
|
||||
z = 6.0
|
||||
),
|
||||
sBall = 0.07,
|
||||
f81 = 145.2
|
||||
)
|
||||
)
|
||||
doReturn(response).`when`(ballisticsService).currentInitialConditions(501L)
|
||||
|
||||
val actual = WebClient.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/catalog/satellites/pdcm/501/initial-conditions")
|
||||
.retrieve()
|
||||
.bodyToMono(SatelliteICDTO::class.java)
|
||||
.block()!!
|
||||
|
||||
assertEquals(501L, actual.satelliteId)
|
||||
assertEquals(MovementModel.KONDOR, actual.movementModel)
|
||||
assertEquals(42L, actual.ic.orbPoint.revolution)
|
||||
verify(ballisticsService).currentInitialConditions(501L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite create endpoint proxies request`() {
|
||||
val request = SatelliteCreateDTO(
|
||||
@@ -568,4 +705,7 @@ class CatalogControllerTest {
|
||||
private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO()
|
||||
private fun anyInitialConditions(): InitialConditionsDTO = any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO()
|
||||
private fun anySlotProfile(): SatelliteSlotProfileDTO = any(SatelliteSlotProfileDTO::class.java) ?: SatelliteSlotProfileDTO()
|
||||
private fun anyLocalDateTime(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN
|
||||
private fun captureSatelliteIc(captor: ArgumentCaptor<SatelliteICDTO>): SatelliteICDTO =
|
||||
captor.capture() ?: SatelliteICDTO()
|
||||
}
|
||||
|
||||
+37
@@ -10,6 +10,8 @@ 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 reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
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.ballistics.StationDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
|
||||
@@ -61,6 +63,18 @@ class MapControllerComplexPlanTest {
|
||||
|
||||
private val objectMapper = ObjectMapper()
|
||||
|
||||
private fun stationEllipseRadius(stationId: String, orbitHeightKm: Double): Double {
|
||||
val actual = WebClient.create("http://localhost:$port")
|
||||
.post()
|
||||
.uri("/requests/station/$stationId?view=true&orbitHeightKm=$orbitHeightKm")
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.map(objectMapper::readTree)
|
||||
.block()!!
|
||||
|
||||
return actual[1]["ellipse"]["semiMajorAxis"].asDouble()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local post api requests delegates to earth service facade`() {
|
||||
val responseId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
@@ -256,11 +270,34 @@ class MapControllerComplexPlanTest {
|
||||
.block()!!
|
||||
|
||||
assertTrue(body.contains("cesiumContainer"))
|
||||
assertTrue(body.contains("stationOrbitHeightKm"))
|
||||
assertTrue(body.contains("Высота орбиты, км"))
|
||||
assertTrue(body.contains("dynamic_plan_map_layers.js"))
|
||||
assertTrue(body.contains("PcpDynamicPlanMapLayers"))
|
||||
verify(earthService).reqs()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station czml endpoint uses orbit height for visibility zone`() {
|
||||
val stationId = "11111111-1111-4111-8111-111111111111"
|
||||
doReturn(
|
||||
Mono.just(
|
||||
StationDTO(
|
||||
id = UUID.fromString(stationId),
|
||||
number = 1,
|
||||
name = "Station 1",
|
||||
position = PositionDTO(lat = 55.0, long = 37.0, height = 200.0),
|
||||
elevationMin = 5.0
|
||||
)
|
||||
)
|
||||
).`when`(stationService).station(stationId)
|
||||
|
||||
val baseRadius = stationEllipseRadius(stationId, 500.0)
|
||||
val higherOrbitRadius = stationEllipseRadius(stationId, 700.0)
|
||||
|
||||
assertTrue(higherOrbitRadius > baseRadius)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dynamic plan map layer keeps only one visible run`() {
|
||||
val body = WebClient.create("http://localhost:$port")
|
||||
|
||||
+64
-3
@@ -7,7 +7,10 @@ import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
import java.time.LocalDateTime
|
||||
@@ -27,7 +30,8 @@ class BallisticsServiceTest {
|
||||
@Test
|
||||
fun `sat data methods forward time interval to ballistics service`() {
|
||||
val requests = CopyOnWriteArrayList<URI>()
|
||||
val serverUrl = startServer(requests)
|
||||
val requestBodies = CopyOnWriteArrayList<String>()
|
||||
val serverUrl = startServer(requests, requestBodies)
|
||||
val service = createService(serverUrl)
|
||||
val timeStart = LocalDateTime.of(2026, 4, 21, 10, 0)
|
||||
val timeStop = LocalDateTime.of(2026, 4, 21, 12, 0)
|
||||
@@ -37,6 +41,25 @@ class BallisticsServiceTest {
|
||||
service.points(101L, timeStart, timeStop).collectList().block()
|
||||
val availability = service.orbitAvailability()
|
||||
val exactPoint = service.exactTimePoint(101L, timeStart)
|
||||
service.receiveRv(
|
||||
SatelliteICDTO(
|
||||
satelliteId = 101L,
|
||||
movementModel = MovementModel.KONDOR,
|
||||
ic = InitialConditionsDTO(
|
||||
orbPoint = OrbPointDTO(
|
||||
time = timeStart,
|
||||
revolution = 7L,
|
||||
vx = 1.0,
|
||||
vy = 2.0,
|
||||
vz = 3.0,
|
||||
x = 4.0,
|
||||
y = 5.0,
|
||||
z = 6.0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val currentInitialConditions = service.currentInitialConditions(101L)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
@@ -44,7 +67,9 @@ class BallisticsServiceTest {
|
||||
"/api/satellites/101/flight-line",
|
||||
"/api/satellites/101/orbit",
|
||||
"/api/satellites/orbit/availability",
|
||||
"/api/satellites/101/extract-time"
|
||||
"/api/satellites/101/extract-time",
|
||||
"/api/satellites/receive-rv",
|
||||
"/api/satellites/101/initial-conditions/current"
|
||||
),
|
||||
requests.map { it.path }
|
||||
)
|
||||
@@ -54,6 +79,8 @@ class BallisticsServiceTest {
|
||||
"time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00",
|
||||
"time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
),
|
||||
requests.map { it.query }
|
||||
@@ -62,6 +89,9 @@ class BallisticsServiceTest {
|
||||
assertEquals(101L, availability.single().satelliteId)
|
||||
assertNotNull(exactPoint)
|
||||
assertEquals(timeStart, exactPoint.time)
|
||||
assertEquals(true, requestBodies.single().contains(""""movementModel":"KONDOR""""))
|
||||
assertNotNull(currentInitialConditions)
|
||||
assertEquals(101L, currentInitialConditions.satelliteId)
|
||||
}
|
||||
|
||||
private fun createService(serverUrl: String): BallisticsService {
|
||||
@@ -77,7 +107,7 @@ class BallisticsServiceTest {
|
||||
return service
|
||||
}
|
||||
|
||||
private fun startServer(requests: MutableList<URI>): String {
|
||||
private fun startServer(requests: MutableList<URI>, requestBodies: MutableList<String>): String {
|
||||
val startedServer = HttpServer.create(InetSocketAddress(0), 0)
|
||||
listOf(
|
||||
"/api/satellites/101/asc-node",
|
||||
@@ -103,6 +133,37 @@ class BallisticsServiceTest {
|
||||
"""[{"time":"2026-04-21T10:00:00","revolution":1,"vx":1.0,"vy":2.0,"vz":3.0,"x":4.0,"y":5.0,"z":6.0}]"""
|
||||
)
|
||||
}
|
||||
startedServer.createContext("/api/satellites/receive-rv") { exchange ->
|
||||
requests.add(exchange.requestURI)
|
||||
requestBodies.add(exchange.requestBody.bufferedReader().use { it.readText() })
|
||||
respond(exchange, "")
|
||||
}
|
||||
startedServer.createContext("/api/satellites/101/initial-conditions/current") { exchange ->
|
||||
requests.add(exchange.requestURI)
|
||||
respond(
|
||||
exchange,
|
||||
"""
|
||||
{
|
||||
"satelliteId": 101,
|
||||
"movementModel": "KONDOR",
|
||||
"ic": {
|
||||
"orbPoint": {
|
||||
"time": "2026-04-21T10:00:00",
|
||||
"revolution": 7,
|
||||
"vx": 1.0,
|
||||
"vy": 2.0,
|
||||
"vz": 3.0,
|
||||
"x": 4.0,
|
||||
"y": 5.0,
|
||||
"z": 6.0
|
||||
},
|
||||
"sBall": 0.0,
|
||||
"f81": 147.8
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
startedServer.start()
|
||||
server = startedServer
|
||||
return "http://localhost:${startedServer.address.port}"
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package space.nstart.pcp.slots_service.service
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.doReturn
|
||||
import org.mockito.Mockito.mock
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TleAnalysisServiceTest {
|
||||
|
||||
private val tleMonitoringService: TleMonitoringService = mock()
|
||||
private val satelliteCatalogService: SatelliteCatalogService = mock()
|
||||
private val service = TleAnalysisService(tleMonitoringService, satelliteCatalogService)
|
||||
|
||||
@Test
|
||||
fun `satellites uses TLE satellite id and enriches rows from catalog by norad id`() {
|
||||
doReturn(
|
||||
listOf(
|
||||
SatelliteSummaryDTO(
|
||||
id = 101,
|
||||
noradId = 62138,
|
||||
code = "SAT-62138",
|
||||
name = "Test satellite",
|
||||
typeCode = "EO",
|
||||
scanTle = true
|
||||
)
|
||||
)
|
||||
).`when`(satelliteCatalogService).allSatellites()
|
||||
doReturn(
|
||||
listOf(
|
||||
TLEExtensionDTO(satelliteId = 62138, revolution = 10, tle = TLEDTO()),
|
||||
TLEExtensionDTO(satelliteId = 62138, revolution = 11, tle = TLEDTO())
|
||||
)
|
||||
).`when`(tleMonitoringService).allTles()
|
||||
|
||||
val satellites = service.satellites()
|
||||
|
||||
assertEquals(1, satellites.size)
|
||||
assertEquals(62138, satellites.single().id)
|
||||
assertEquals(101, satellites.single().catalogId)
|
||||
assertEquals(62138, satellites.single().noradId)
|
||||
assertEquals("SAT-62138", satellites.single().code)
|
||||
assertEquals("Test satellite", satellites.single().name)
|
||||
assertEquals("EO", satellites.single().typeCode)
|
||||
assertEquals(true, satellites.single().scanTle)
|
||||
assertEquals(2, satellites.single().tleRecordsCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellites falls back to catalog when TLE records are empty`() {
|
||||
doReturn(
|
||||
listOf(
|
||||
SatelliteSummaryDTO(
|
||||
id = 101,
|
||||
noradId = 62138,
|
||||
code = "SAT-62138",
|
||||
name = "Test satellite"
|
||||
)
|
||||
)
|
||||
).`when`(satelliteCatalogService).allSatellites()
|
||||
doReturn(emptyList<TLEExtensionDTO>()).`when`(tleMonitoringService).allTles()
|
||||
|
||||
val satellites = service.satellites()
|
||||
|
||||
assertEquals(1, satellites.size)
|
||||
assertEquals(62138, satellites.single().id)
|
||||
assertEquals(101, satellites.single().catalogId)
|
||||
assertEquals(62138, satellites.single().noradId)
|
||||
assertEquals(0, satellites.single().tleRecordsCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyze satellite uses only requested number of latest TLE records`() {
|
||||
doReturn(
|
||||
listOf(
|
||||
tleRecord(revolution = 1, epoch = "21274.51041667"),
|
||||
tleRecord(revolution = 2, epoch = "21275.51041667"),
|
||||
tleRecord(revolution = 3, epoch = "21276.51041667")
|
||||
)
|
||||
).`when`(tleMonitoringService).satelliteTles(25544)
|
||||
|
||||
val response = service.analyzeSatellite(25544, tleCount = 2)
|
||||
|
||||
assertEquals(2, response.rows.size)
|
||||
assertEquals(listOf(2L, 3L), response.rows.map { it.revolution })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyze satellite reports epoch difference between compared TLE records`() {
|
||||
doReturn(
|
||||
listOf(
|
||||
tleRecord(revolution = 1, epoch = "21275.51041667"),
|
||||
tleRecord(revolution = 2, epoch = "21276.01041667")
|
||||
)
|
||||
).`when`(tleMonitoringService).satelliteTles(25544)
|
||||
|
||||
val response = service.analyzeSatellite(25544, tleCount = 2)
|
||||
|
||||
assertEquals(2, response.rows.size)
|
||||
val currentRow = response.rows[1]
|
||||
val discrepancy = assertNotNull(currentRow.discrepancy)
|
||||
assertEquals(response.rows[0].epoch, discrepancy.previousEpoch)
|
||||
assertTrue(discrepancy.previousEpoch.isBefore(currentRow.epoch))
|
||||
assertEquals(12.0, discrepancy.epochDifferenceHours, 0.001)
|
||||
}
|
||||
|
||||
private fun tleRecord(revolution: Long, epoch: String = "21275.51041667") =
|
||||
TLEExtensionDTO(
|
||||
satelliteId = 25544,
|
||||
revolution = revolution,
|
||||
tle = TLEDTO(
|
||||
header = "ISS (ZARYA)",
|
||||
first = "1 25544U 98067A $epoch .00000282 00000-0 12558-4 0 9993",
|
||||
second = "2 25544 51.6435 177.7258 0003783 65.7820 55.4027 15.48915330299929"
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user