сравнение TLE

This commit is contained in:
emelianov
2026-05-27 12:00:52 +03:00
parent 29c06a4c9c
commit f4014a08a1
8 changed files with 247 additions and 52 deletions
@@ -1,15 +1,13 @@
package space.nstart.pcp.pcp_request_service.controller
import ballistics.types.OrbitalPoint
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.GetMapping
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_request_service.service.TLEService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TlePointRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
@RestController
@@ -23,6 +21,9 @@ class TLEController {
@PostMapping("/parse")
fun orbit(@RequestBody tle : TLEDTO) = tleService.parseTLE(tle)
@PostMapping("/point")
fun point(@RequestBody req: TlePointRequestDTO) = tleService.point(req)
@PostMapping("/rva")
fun rva(@RequestBody req : TleRvaRequestDTO) = tleService.rva(req)
@@ -1,7 +1,9 @@
package space.nstart.pcp.pcp_request_service.service
import ballistics.Ballistics
import ballistics.orbitalPoints.timeStepper.TLEStepper
import ballistics.types.BallisticsError
import ballistics.types.EarthType
import ballistics.types.PPI
import ballistics.types.TLE
import ballistics.types.TLEParams
@@ -15,6 +17,7 @@ import space.nstart.pcp.pcp_request_service.configuration.CustomValidationExcept
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TlePointRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
import kotlin.math.PI
@@ -47,6 +50,37 @@ class TLEService() {
fun point(req: TlePointRequestDTO): OrbPointDTO {
val bal = Ballistics()
val tleParams = try {
bal.getTLEParams(TLE(req.tle.first, req.tle.second), req.tle.header ?: "empty")
} catch (ex: Exception) {
throw CustomValidationException("Ошибка формата TLE : ${ex.message}")
}
if (req.time < tleParams.time) {
throw CustomValidationException("Время расчета должно быть не раньше эпохи TLE ${tleParams.time}")
}
val point = try {
val stepper = TLEStepper(req.tle.first, req.tle.second, EarthType.PZ90d02)
stepper.calculate(fromDateTime(req.time))
} catch (ex: Exception) {
throw CustomErrorException("Ошибка расчета положения по TLE : ${ex.message}")
}
return OrbPointDTO(
toDateTime(point.t),
point.vit.toLong(),
point.v.x,
point.v.y,
point.v.z,
point.r.x,
point.r.y,
point.r.z
)
}
fun rva(req : TleRvaRequestDTO) : Iterable<RadioVisibilityAreaDTO> {
val bal = Ballistics()
@@ -4,18 +4,23 @@ 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")
@@ -29,12 +34,38 @@ class TleComparisonController(
@PostMapping("/compare")
fun compare(@RequestBody request: TleCompareRequestDTO): TleCompareResponseDTO {
val first = ballisticsService.parseTle(parseTleText(request.first))
val second = ballisticsService.parseTle(parseTleText(request.second))
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(
first = first,
second = second,
rows = buildRows(first, second)
time = calculationTime,
firstEpoch = firstEpoch,
secondEpoch = secondEpoch,
first = firstPoint,
second = secondPoint,
radiusVectorDelta = delta,
rows = buildRows(firstPoint, secondPoint, delta)
)
}
@@ -69,23 +100,38 @@ class TleComparisonController(
)
}
private fun buildRows(first: TleParsedDTO, second: TleParsedDTO): List<TleCompareRowDTO> = listOf(
textRow("satName", "Название", first.satName, second.satName),
textRow("noradId", "NORAD ID", first.noradId?.toString(), second.noradId?.toString()),
textRow("revolution", "Номер витка", first.revolution?.toString(), second.revolution?.toString()),
timeRow("time", "Эпоха", first.time, second.time),
numberRow("inclination", "Наклонение", first.inclination, second.inclination),
numberRow("ascendingNode", "ВУЗ", first.ascendingNode, second.ascendingNode),
numberRow("argPerigee", "Аргумент перигея", first.argPerigee, second.argPerigee),
numberRow("meanAnomaly", "Средняя аномалия", first.meanAnomaly, second.meanAnomaly),
numberRow("eccentricity", "Эксцентриситет", first.eccentricity, second.eccentricity),
numberRow("perigee", "Перигей", first.perigee, second.perigee),
numberRow("apogee", "Апогей", first.apogee, second.apogee),
numberRow("period", "Период", first.period, second.period),
numberRow("semiMajor", "Большая полуось", first.semiMajor, second.semiMajor),
numberRow("semiMinor", "Малая полуось", first.semiMinor, second.semiMinor),
numberRow("meanMotion", "Среднее движение", first.meanMotion, second.meanMotion),
numberRow("meanMotionTLE", "Среднее движение TLE", first.meanMotionTLE, second.meanMotionTLE)
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 =
@@ -100,7 +146,7 @@ class TleComparisonController(
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 = java.time.Duration.between(first, second).seconds
val seconds = Duration.between(first, second).seconds
formatSecondsDelta(seconds)
} else {
null
@@ -117,11 +163,13 @@ class TleComparisonController(
delta = if (first != null && second != null) (second - first).formatSignedNumber() else null
)
private fun Double.formatNumber(): String = String.format(Locale.US, "%.8f", this)
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, "%.8f", this)
return sign + String.format(Locale.US, "%.6f", this)
}
private fun formatSecondsDelta(seconds: Long): String {
@@ -8,7 +8,8 @@ class TleTextRequestDTO(
class TleCompareRequestDTO(
var first: String = "",
var second: String = ""
var second: String = "",
var time: LocalDateTime? = null
)
data class TleParsedDTO(
@@ -32,6 +33,19 @@ data class TleParsedDTO(
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,
@@ -41,7 +55,18 @@ data class TleCompareRowDTO(
)
data class TleCompareResponseDTO(
val first: TleParsedDTO,
val second: TleParsedDTO,
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
)
@@ -11,6 +11,7 @@ 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.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
@@ -80,6 +81,16 @@ class BallisticsService (webClientBuilderProvider: ObjectProvider<WebClient.Buil
.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?,
@@ -9,6 +9,7 @@
bindEvents();
updateReference('first', null);
updateReference('second', null);
updateTimeLimits();
});
function bindEvents() {
@@ -16,6 +17,7 @@
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);
}
@@ -26,6 +28,7 @@
if (!text.trim()) {
state[side] = null;
updateReference(side, null);
updateTimeLimits();
return;
}
setStatus(side, 'Разбор...', 'text-muted');
@@ -37,10 +40,12 @@
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');
}
}
@@ -49,21 +54,25 @@
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 });
state.first = response.first;
state.second = response.second;
updateReference('first', response.first);
updateReference('second', response.second);
setStatus('first', 'Разобрано', 'tle-parse-ok');
setStatus('second', 'Разобрано', 'tle-parse-ok');
renderComparison(response.rows || []);
const response = await postJson('/api/tle-comparison/compare', { first, second, time });
renderComparison(response);
} catch (error) {
showAlert(error.message || 'Не удалось выполнить сравнение TLE.', 'danger');
} finally {
@@ -71,7 +80,8 @@
}
}
function renderComparison(rows) {
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');
@@ -98,9 +108,9 @@
empty.classList.add('d-none');
wrap.classList.remove('d-none');
if (meta) {
const firstEpoch = formatDateTime(state.first?.time);
const secondEpoch = formatDateTime(state.second?.time);
meta.textContent = `Эпохи: TLE 1 — ${firstEpoch || '—'}, TLE 2 — ${secondEpoch || '—'}`;
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)}.`;
}
}
@@ -114,16 +124,46 @@
}
}
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([]);
setText('tle-comparison-result-meta', 'Нажмите “Сравнить” после ввода двух TLE.');
renderComparison({ rows: [] });
setText('tle-comparison-result-meta', 'Задайте время не раньше максимальной эпохи TLE и нажмите “Сравнить”.');
}
async function postJson(url, payload) {
@@ -145,6 +185,16 @@
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 || '';
}
@@ -160,7 +210,7 @@
const button = el('tle-compare-submit');
if (!button) return;
button.disabled = loading;
button.textContent = loading ? 'Сравнение...' : 'Сравнить';
button.textContent = loading ? 'Расчет...' : 'Рассчитать';
}
function showAlert(message, type = 'info') {
@@ -174,11 +224,20 @@
: '';
}
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;
}
@@ -13,10 +13,10 @@
<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, проверьте эпохи и сравните параметры, полученные через ballistics-service.</div>
<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-submit" type="button" class="btn btn-primary">Рассчитать</button>
<button id="tle-compare-clear" type="button" class="btn btn-outline-secondary">Очистить</button>
</div>
</div>
@@ -26,9 +26,18 @@
<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 обязательны.</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">
@@ -90,11 +99,11 @@
<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 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-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">
@@ -102,7 +111,7 @@
<th>Параметр</th>
<th>TLE 1</th>
<th>TLE 2</th>
<th>Разница</th>
<th>Разница / значение</th>
</tr>
</thead>
<tbody id="tle-comparison-result-body"></tbody>