анализ TLE

This commit is contained in:
emelianov
2026-05-27 15:30:24 +03:00
parent a7e950a131
commit bfb9d5b01f
25 changed files with 591 additions and 93 deletions
+2 -2
View File
@@ -95,7 +95,7 @@ variables:
ballistics:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
services:
- name: repo.nstart.cloud/postgis/postgis:16-3.4
alias: postgres
@@ -124,7 +124,7 @@ ballistics:test:
ballistics:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .ballistics:rules:build-and-image
- .ballistics:gradle-cache
@@ -139,7 +139,7 @@ variables:
complex-mission-service:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .complex-mission-service:rules:test
- .pcp-gradle-cache
@@ -158,7 +158,7 @@ complex-mission-service:test:
complex-mission-service:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .complex-mission-service:rules:build-and-image
- .pcp-gradle-cache
@@ -300,7 +300,7 @@ complex-mission-service:deploy:master:
tle-monitoring:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
services:
- name: repo.nstart.cloud/postgis/postgis:16-3.4
alias: postgres
@@ -330,7 +330,7 @@ tle-monitoring:test:
tle-monitoring:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .tle-monitoring:rules:build-and-image
- .pcp-gradle-cache
+2 -2
View File
@@ -86,7 +86,7 @@ variables:
coverage-scheme:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .coverage_scheme:rules:test
- .coverage_scheme:gradle-cache
@@ -105,7 +105,7 @@ coverage-scheme:test:
coverage-scheme:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .coverage_scheme:rules:build-and-image
- .coverage_scheme:gradle-cache
+2 -2
View File
@@ -87,7 +87,7 @@ variables:
dynamic-plan:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .dynamic_plan:rules:test
- .dynamic_plan:gradle-cache
@@ -106,7 +106,7 @@ dynamic-plan:test:
dynamic-plan:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .dynamic_plan:rules:build-and-image
- .dynamic_plan:gradle-cache
+2 -2
View File
@@ -86,7 +86,7 @@ variables:
mission-planing:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .mission-planing:rules:test
- .mission-planing:gradle-cache
@@ -105,7 +105,7 @@ mission-planing:test:
mission-planing:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .mission-planing:rules:build-and-image
- .mission-planing:gradle-cache
+2 -2
View File
@@ -85,7 +85,7 @@ variables:
request:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .request:rules:test
- .request:gradle-cache
@@ -104,7 +104,7 @@ request:test:
request:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .request:rules:build-and-image
- .request:gradle-cache
+2 -2
View File
@@ -85,7 +85,7 @@ variables:
satellite-catalog:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .satellite-catalog:rules:test
- .satellite-catalog:gradle-cache
@@ -104,7 +104,7 @@ satellite-catalog:test:
satellite-catalog:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .satellite-catalog:rules:build-and-image
- .satellite-catalog:gradle-cache
+2 -2
View File
@@ -76,7 +76,7 @@ variables:
spring-boot-admin:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .spring-boot-admin:rules:test
- .spring-boot-admin:gradle-cache
@@ -95,7 +95,7 @@ spring-boot-admin:test:
spring-boot-admin:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .spring-boot-admin:rules:build-and-image
- .spring-boot-admin:gradle-cache
+2 -2
View File
@@ -85,7 +85,7 @@ variables:
stations:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .stations:rules:test
- .stations:gradle-cache
@@ -104,7 +104,7 @@ stations:test:
stations:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .stations:rules:build-and-image
- .stations:gradle-cache
+2 -2
View File
@@ -86,7 +86,7 @@ variables:
ui:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .ui:rules:test
- .ui:gradle-cache
@@ -105,7 +105,7 @@ ui:test:
ui:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .ui:rules:build-and-image
- .ui:gradle-cache
+2 -2
View File
@@ -86,7 +86,7 @@ variables:
slots:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
services:
- name: repo.nstart.cloud/postgis/postgis:16-3.4
alias: postgres
@@ -115,7 +115,7 @@ slots:test:
slots:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .slots:rules:build-and-image
- .slots:gradle-cache
+2 -2
View File
@@ -82,7 +82,7 @@ variables:
config-server:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .config-server:rules:test
- .config-server:gradle-cache
@@ -101,7 +101,7 @@ config-server:test:
config-server:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk17-ubi10
image: repo.nstart.cloud/cloud/gradle:jdk17-ubi10
extends:
- .config-server:rules:build-and-image
- .config-server:gradle-cache
+4 -2
View File
@@ -3,8 +3,10 @@
pcp:
infra:
postgres:
host: ${PCP_POSTGRES_HOST:localhost}
port: ${PCP_POSTGRES_PORT:5432}
# host: ${PCP_POSTGRES_HOST:localhost}
# port: ${PCP_POSTGRES_PORT:5432}
host: ${PCP_POSTGRES_HOST:192.168.100.160}
port: ${PCP_POSTGRES_PORT:35400}
kafka:
host: ${PCP_KAFKA_HOST:localhost}
port: ${PCP_KAFKA_PORT:29092}
+1 -1
View File
@@ -136,7 +136,7 @@ spring:
camunda:
client:
mode: self-managed
grpc-address: http://camunda.k8s.265.nstart.local:30901
grpc-address: http://camunda.k8s.265.nstart.cloud:30901
auth:
method: none
prefer-rest-over-grpc: false
@@ -1,8 +1,10 @@
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
@@ -11,6 +13,30 @@ import space.nstart.pcp.slots_service.service.TleAnalysisService
class TleAnalysisController(
private val tleAnalysisService: TleAnalysisService
) {
@GetMapping("/satellites/{satelliteId}")
fun analyzeSatellite(@PathVariable satelliteId: Long) = tleAnalysisService.analyzeSatellite(satelliteId)
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)
}
@@ -2,6 +2,18 @@ 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>
@@ -26,6 +38,7 @@ data class RadiusVectorDTO(
data class RadiusVectorDiscrepancyDTO(
val previousEpoch: LocalDateTime,
val epochDifferenceHours: Double,
val dx: Double,
val dy: Double,
val dz: Double,
@@ -5,28 +5,142 @@ 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 tleMonitoringService: TleMonitoringService,
private val satelliteCatalogService: SatelliteCatalogService
) {
fun analyzeSatellite(satelliteId: Long): TleAnalysisResponseDTO {
val records = tleMonitoringService.satelliteTles(satelliteId)
.mapNotNull { parseRecord(it) }
.sortedBy { it.epoch }
private val logger = LoggerFactory.getLogger(this::class.java)
val rows = records.mapIndexed { index, record ->
val currentPoint = calculatePoint(record.tle, record.epoch)
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,
@@ -34,12 +148,14 @@ class TleAnalysisService(
)
val discrepancy = records.getOrNull(index - 1)?.let { previous ->
val previousPointAtCurrentEpoch = calculatePoint(previous.tle, record.epoch)
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,
@@ -58,6 +174,13 @@ class TleAnalysisService(
discrepancy = discrepancy
)
}
}
logger.info(
"TLE analysis report built: satelliteId={}, rows={}, durationMs={}",
satelliteId,
rows.size,
rowsBuildMs
)
return TleAnalysisResponseDTO(
satelliteId = satelliteId,
@@ -85,10 +208,46 @@ class TleAnalysisService(
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
}
}
@@ -1,5 +1,6 @@
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
@@ -8,16 +9,49 @@ 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:tle-monitoring-service}")
private val tleMonitoringUrl: String = ""
fun satelliteTles(satelliteId: Long): List<TLEExtensionDTO> =
webClientBuilder.build()
fun allTles(): List<TLEExtensionDTO> {
logger.info("TLE monitoring client all TLE request started")
return runCatching {
var records = emptyList<TLEExtensionDTO>()
val durationMs = measureTimeMillis {
records = webClientBuilder.build()
.get()
.uri("$tleMonitoringUrl/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> {
logger.info("TLE monitoring client satellite TLE request started: satelliteId={}", satelliteId)
return runCatching {
var records = emptyList<TLEExtensionDTO>()
val durationMs = measureTimeMillis {
records = webClientBuilder.build()
.get()
.uri("$tleMonitoringUrl/v1/api/tle/satellite/$satelliteId")
.retrieve()
@@ -26,6 +60,23 @@ class TleMonitoringService(webClientBuilderProvider: ObjectProvider<WebClient.Bu
.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)
@@ -1,25 +1,40 @@
.tle-analysis-page {
height: calc(100vh - 4.75rem);
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.tle-analysis-layout {
flex: 1 1 auto;
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,
@@ -29,11 +44,28 @@
}
.tle-analysis-satellites-wrap {
flex: 1 1 auto;
flex: 1 1 0;
max-height: 100%;
}
.tle-analysis-results-wrap {
flex: 1 1 auto;
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,
@@ -15,23 +15,30 @@
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);
}
});
document.getElementById('tle-analysis-satellite-filter')?.addEventListener('input', renderSatellites);
}
async function loadSatellites() {
showAlert('Загрузка списка спутников...', 'info');
try {
const response = await fetch('/api/catalog/satellites');
const response = await fetch('/api/tle-analysis/satellites');
if (!response.ok) {
throw new Error(await errorText(response));
}
state.satellites = await response.json();
state.satellites = normalizeSatelliteResponse(await response.json());
renderSatellites();
renderResults();
clearAlert();
} catch (error) {
showAlert(`Не удалось загрузить спутники: ${error.message}`, 'danger');
@@ -44,7 +51,8 @@
renderResults();
showAlert('Формирование анализа TLE...', 'info');
try {
const response = await fetch(`/api/tle-analysis/satellites/${encodeURIComponent(satelliteId)}`);
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));
}
@@ -53,10 +61,10 @@
clearAlert();
} catch (error) {
state.analysis = null;
renderResults();
showAlert(`Не удалось сформировать анализ TLE: ${error.message}`, 'danger');
} finally {
state.loading = false;
renderResults();
}
}
@@ -69,10 +77,12 @@
if (!filter) return true;
return normalize([
satellite.id,
satellite.catalogId,
satellite.noradId,
satellite.code,
satellite.name,
satellite.typeCode
satellite.typeCode,
satellite.tleRecordsCount
].join(' ')).includes(filter);
});
@@ -84,12 +94,15 @@
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(satellite.id)}</td>
<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>`;
@@ -116,7 +129,8 @@
return;
}
const name = satellite.name || satellite.code || `КА ${satellite.id}`;
label.textContent = `КА ${satellite.id}: ${name}${satellite.noradId == null ? '' : `, NORAD ${satellite.noradId}`}`;
const catalogId = satellite.catalogId == null ? satellite.id : satellite.catalogId;
label.textContent = `КА ${catalogId}: ${name}${satellite.noradId == null ? '' : `, NORAD ${satellite.noradId}`}`;
}
function renderResults() {
@@ -184,10 +198,38 @@
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();
}
@@ -15,7 +15,16 @@
<h2 class="mb-1">Анализ TLE</h2>
<div class="catalog-helper">Анализ изменения TLE выбранного спутника по разнице рассчитанных радиус-векторов.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<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>
@@ -29,8 +38,8 @@
<div class="catalog-section-title">Спутники</div>
<div class="catalog-helper mt-1">Выберите КА для формирования аналитической таблицы TLE.</div>
</div>
<div class="card-body p-3">
<label for="tle-analysis-satellite-filter" class="form-label">Фильтр</label>
<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"
@@ -73,6 +82,7 @@
<th>NORAD</th>
<th>Радиус-вектор, м</th>
<th>|r|, м</th>
<th>Разница Времени, ч</th>
<th>Расхождение, м</th>
</tr>
</thead>
@@ -84,8 +94,6 @@
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="scripts">
<script src="/tle_analysis_scripts.js"></script>
</th:block>
</body>
@@ -140,6 +140,23 @@ class CatalogControllerTest {
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")
@@ -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"
)
)
}
@@ -1,5 +1,6 @@
package space.nstart.pcp.tle_monitoring_service.controller
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -12,12 +13,14 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO
import space.nstart.pcp.tle_monitoring_service.repository.TLERepository
import space.nstart.pcp.tle_monitoring_service.service.CelesTrakService
import kotlin.system.measureTimeMillis
@RestController
@RequestMapping("v1/api/tle")
class TLEController {
private val logger = LoggerFactory.getLogger(this::class.java)
@Autowired
private lateinit var tleRepository: TLERepository
@@ -25,11 +28,35 @@ class TLEController {
private lateinit var celestrakService: CelesTrakService
@GetMapping()
fun all() = tleRepository.findAll().map { tle -> tle.toDto() }
fun all(): List<TLEExtensionDTO> {
logger.info("TLE API all records request started")
var records = emptyList<TLEExtensionDTO>()
val durationMs = measureTimeMillis {
records = tleRepository.findAll().map { tle -> tle.toDto() }
}
logger.info(
"TLE API all records request completed: records={}, durationMs={}",
records.size,
durationMs
)
return records
}
@GetMapping("/satellite/{satelliteId}")
fun bySatellite(@PathVariable satelliteId: Long) =
tleRepository.findAllBySatelliteIdOrderByTimeTleAsc(satelliteId).map { tle -> tle.toDto() }
fun bySatellite(@PathVariable satelliteId: Long): List<TLEExtensionDTO> {
logger.info("TLE API satellite records request started: satelliteId={}", satelliteId)
var records = emptyList<TLEExtensionDTO>()
val durationMs = measureTimeMillis {
records = tleRepository.findAllBySatelliteIdOrderByTimeTleAsc(satelliteId).map { tle -> tle.toDto() }
}
logger.info(
"TLE API satellite records request completed: satelliteId={}, records={}, durationMs={}",
satelliteId,
records.size,
durationMs
)
return records
}
private fun space.nstart.pcp.tle_monitoring_service.entity.TLEEntity.toDto(): TLEExtensionDTO {
val lines = tle.lines().map { it.trimEnd() }.filter { it.isNotBlank() }
+2 -2
View File
@@ -31,8 +31,8 @@ pluginManagement {
maven {
url = uri("https://repo.nstart.cloud/repository/maven-proxy/")
}
// mavenCentral()
// gradlePluginPortal()
mavenCentral()
gradlePluginPortal()
// maven {
// url = uri("https://repo.nstart.local/repository/maven-proxy/")
// }