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:
Дмитрий Соловьев
2026-06-01 09:19:29 +03:00
120 changed files with 5180 additions and 345 deletions
@@ -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,
)
}
@@ -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"
}
@@ -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)
@@ -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)
}
@@ -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
)
@@ -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)
}
@@ -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}с"
}
}
@@ -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(),
)
@@ -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
)
@@ -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
)
@@ -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)
@@ -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?,
@@ -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
@@ -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()
@@ -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,
@@ -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
}
}
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
})();
@@ -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('"', '&quot;')
.replaceAll("'", '&#039;');
}
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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)&#10;1 25544U 98067A ...&#10;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)&#10;1 25544U 98067A ...&#10;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>
@@ -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()
}
@@ -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")
@@ -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}"
@@ -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"
)
)
}