From d07b17e03e45de06597d3f9849a9f8ceaef3324d Mon Sep 17 00:00:00 2001 From: emelianov Date: Wed, 27 May 2026 12:35:20 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20TLE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config-repo/pcp-ui-service.yaml | 1 + .../controller/CatalogPageController.kt | 3 + .../controller/TleAnalysisController.kt | 16 ++ .../dto/tleanalysis/TleAnalysisDTO.kt | 33 +++ .../service/TleAnalysisService.kt | 94 +++++++ .../service/TleMonitoringService.kt | 48 ++++ .../resources/static/css/tle-analysis.css | 69 +++++ .../resources/static/tle_analysis_scripts.js | 239 ++++++++++++++++++ .../templates/fragments/base/menu.html | 1 + .../resources/templates/tle-analysis.html | 92 +++++++ .../controller/TLEController.kt | 33 ++- .../repository/TLERepository.kt | 2 + 12 files changed, 625 insertions(+), 6 deletions(-) create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleAnalysisController.kt create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisService.kt create mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleMonitoringService.kt create mode 100644 services/pcp-ui-service/src/main/resources/static/css/tle-analysis.css create mode 100644 services/pcp-ui-service/src/main/resources/static/tle_analysis_scripts.js create mode 100644 services/pcp-ui-service/src/main/resources/templates/tle-analysis.html diff --git a/config-repo/pcp-ui-service.yaml b/config-repo/pcp-ui-service.yaml index 99ea857..74f1a9f 100644 --- a/config-repo/pcp-ui-service.yaml +++ b/config-repo/pcp-ui-service.yaml @@ -39,6 +39,7 @@ settings: dynamic-plan-service: ${pcp.services.dynamic-plan} satellite-catalog-service: ${pcp.services.satellite-catalog} ballistics-service: ${pcp.services.ballistics} + tle-monitoring-service: ${pcp.services.tle-monitoring} stations-service: ${pcp.services.stations} slots-service: ${pcp.services.slots} mission-service: ${pcp.services.mission} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt index 57dcfff..ae68fed 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt @@ -26,4 +26,7 @@ class CatalogPageController { @GetMapping("/bookings") fun bookingsPage(): String = "bookings" + + @GetMapping("/tle-analysis") + fun tleAnalysisPage(): String = "tle-analysis" } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleAnalysisController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleAnalysisController.kt new file mode 100644 index 0000000..6423084 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleAnalysisController.kt @@ -0,0 +1,16 @@ +package space.nstart.pcp.slots_service.controller + +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.RestController +import space.nstart.pcp.slots_service.service.TleAnalysisService + +@RestController +@RequestMapping("/api/tle-analysis") +class TleAnalysisController( + private val tleAnalysisService: TleAnalysisService +) { + @GetMapping("/satellites/{satelliteId}") + fun analyzeSatellite(@PathVariable satelliteId: Long) = tleAnalysisService.analyzeSatellite(satelliteId) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt new file mode 100644 index 0000000..d9b58f3 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt @@ -0,0 +1,33 @@ +package space.nstart.pcp.slots_service.dto.tleanalysis + +import java.time.LocalDateTime + +data class TleAnalysisResponseDTO( + val satelliteId: Long, + val rows: List +) + +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 dx: Double, + val dy: Double, + val dz: Double, + val norm: Double +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisService.kt new file mode 100644 index 0000000..0094053 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisService.kt @@ -0,0 +1,94 @@ +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.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.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.TleAnalysisResponseDTO +import space.nstart.pcp.slots_service.dto.tleanalysis.TleAnalysisRowDTO +import java.time.LocalDateTime +import kotlin.math.sqrt + +@Service +class TleAnalysisService( + private val tleMonitoringService: TleMonitoringService +) { + fun analyzeSatellite(satelliteId: Long): TleAnalysisResponseDTO { + val records = tleMonitoringService.satelliteTles(satelliteId) + .mapNotNull { parseRecord(it) } + .sortedBy { it.epoch } + + val rows = records.mapIndexed { index, record -> + val currentPoint = calculatePoint(record.tle, record.epoch) + val radiusVector = RadiusVectorDTO( + x = currentPoint.r.x, + y = currentPoint.r.y, + z = currentPoint.r.z + ) + + val discrepancy = records.getOrNull(index - 1)?.let { previous -> + val previousPointAtCurrentEpoch = calculatePoint(previous.tle, record.epoch) + 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, + 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 + ) + } + + 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 data class ParsedTleRecord( + val tle: TLEDTO, + val epoch: LocalDateTime, + val revolution: Long, + val noradId: Long + ) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleMonitoringService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleMonitoringService.kt new file mode 100644 index 0000000..e4317b9 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleMonitoringService.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp.slots_service.service + +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 + +@Service +class TleMonitoringService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.tle-monitoring-service:tle-monitoring-service}") + private val tleMonitoringUrl: String = "" + + fun satelliteTles(satelliteId: Long): List = + webClientBuilder.build() + .get() + .uri("$tleMonitoringUrl/v1/api/tle/satellite/$satelliteId") + .retrieve() + .onStatus({ status -> status.isError }, ::mapError) + .bodyToFlux(TLEExtensionDTO::class.java) + .collectList() + .block() + ?: emptyList() + + private fun mapError(response: org.springframework.web.reactive.function.client.ClientResponse): Mono = + 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) + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/tle-analysis.css b/services/pcp-ui-service/src/main/resources/static/css/tle-analysis.css new file mode 100644 index 0000000..c0d45de --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/tle-analysis.css @@ -0,0 +1,69 @@ +.tle-analysis-page { + height: calc(100vh - 4.75rem); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tle-analysis-layout { + flex: 1 1 auto; + min-height: 0; +} + +.tle-analysis-layout > [class*="col-"] { + min-height: 0; + display: flex; + flex-direction: column; +} + +.tle-analysis-sidebar, +.tle-analysis-results-card { + min-height: 0; + width: 100%; +} + +.tle-analysis-satellites-wrap, +.tle-analysis-results-wrap { + overflow: auto; + min-height: 0; +} + +.tle-analysis-satellites-wrap { + flex: 1 1 auto; +} + +.tle-analysis-results-wrap { + flex: 1 1 auto; +} + +.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; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/tle_analysis_scripts.js b/services/pcp-ui-service/src/main/resources/static/tle_analysis_scripts.js new file mode 100644 index 0000000..590a0c5 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/tle_analysis_scripts.js @@ -0,0 +1,239 @@ +(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 () => { + await loadSatellites(); + if (state.selectedSatelliteId) { + await loadAnalysis(state.selectedSatelliteId); + } + }); + document.getElementById('tle-analysis-satellite-filter')?.addEventListener('input', renderSatellites); + } + + async function loadSatellites() { + showAlert('Загрузка списка спутников...', 'info'); + try { + const response = await fetch('/api/catalog/satellites'); + if (!response.ok) { + throw new Error(await errorText(response)); + } + state.satellites = await response.json(); + renderSatellites(); + clearAlert(); + } catch (error) { + showAlert(`Не удалось загрузить спутники: ${error.message}`, 'danger'); + } + } + + async function loadAnalysis(satelliteId) { + state.loading = true; + state.analysis = null; + renderResults(); + showAlert('Формирование анализа TLE...', 'info'); + try { + const response = await fetch(`/api/tle-analysis/satellites/${encodeURIComponent(satelliteId)}`); + if (!response.ok) { + throw new Error(await errorText(response)); + } + state.analysis = await response.json(); + renderResults(); + clearAlert(); + } catch (error) { + state.analysis = null; + renderResults(); + showAlert(`Не удалось сформировать анализ TLE: ${error.message}`, 'danger'); + } finally { + state.loading = false; + } + } + + 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.noradId, + satellite.code, + satellite.name, + satellite.typeCode + ].join(' ')).includes(filter); + }); + + if (satellites.length === 0) { + tbody.innerHTML = `Спутники не найдены`; + return; + } + + tbody.innerHTML = satellites.map((satellite) => { + const active = Number(satellite.id) === Number(state.selectedSatelliteId) ? 'active' : ''; + const title = escapeHtml(satellite.name || satellite.code || `КА ${satellite.id}`); + return ` + + ${escapeHtml(satellite.id)} + +
${title}
+
${escapeHtml(satellite.code || '')} ${satellite.typeCode ? ' / ' + escapeHtml(satellite.typeCode) : ''}
+ + ${satellite.noradId == null ? '—' : escapeHtml(satellite.noradId)} + `; + }).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}`; + label.textContent = `КА ${satellite.id}: ${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 + ? `
${formatNumber(discrepancy.norm, 3)}
+
Δx=${formatNumber(discrepancy.dx, 3)}, Δy=${formatNumber(discrepancy.dy, 3)}, Δz=${formatNumber(discrepancy.dz, 3)}
+
к предыдущей эпохе ${escapeHtml(formatDateTime(discrepancy.previousEpoch))}
` + : 'не рассчитывается'; + + return ` + + ${escapeHtml(row.index)} + +
${escapeHtml(formatDateTime(row.epoch))}
+ ${row.tleHeader ? `
${escapeHtml(row.tleHeader)}
` : ''} + + ${escapeHtml(row.revolution)} + ${escapeHtml(row.noradId)} + + x=${formatNumber(r.x, 3)}
+ y=${formatNumber(r.y, 3)}
+ z=${formatNumber(r.z, 3)} + + ${formatNumber(row.radiusNorm, 3)} + ${diffText} + `; + } + + 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 = ``; + } + + function clearAlert() { + const alert = document.getElementById('tle-analysis-alert'); + if (alert) alert.innerHTML = ''; + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html index b8160a6..bfc5436 100644 --- a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -47,6 +47,7 @@
  • Управление
  • ПДЦМ
  • Сравнение TLE
  • +
  • Анализ TLE