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
+3
View File
@@ -124,6 +124,9 @@ default:
tags:
- nstart
variables:
INTERNAL_REGISTRY: "$CI_REGISTRY_IMAGE"
stages:
- test
- build
+2
View File
@@ -14,6 +14,8 @@ variables:
- libs/pcp-types-lib/**/*
- build.gradle.kts
- settings.gradle.kts
- gradle.properties
- gradle/**/*
- .gitlab/ci/pcp-request-service.yml
- helm/pcp-request-service/**/*
- deploy/**/*
+13 -7
View File
@@ -8,7 +8,7 @@ plugins {
kotlin("plugin.lombok") apply false
id("org.springframework.boot") apply false
id("io.spring.dependency-management") apply false
id("org.sonarqube") apply false
//id("org.sonarqube") apply false
}
@@ -16,12 +16,18 @@ allprojects {
group = "space.nstart.pcp"
ext.set("kotlinVersion", "2.2.20")
repositories {
// maven {
// url = uri("https://repo.nstart.cloud/repository/maven-proxy/")
// }
mavenCentral()
mavenLocal()
gradlePluginPortal()
maven {
url = uri("https://repo.nstart.cloud/repository/maven-proxy/")
}
mavenCentral {
content {
includeGroup("org.jacoco")
includeGroup("org.ow2.asm")
}
}
// mavenCentral()
// mavenLocal()
// gradlePluginPortal()
maven { url = uri("https://jitpack.io") }
exclusiveContent {
+7 -5
View File
@@ -3,19 +3,21 @@
pcp:
infra:
postgres:
host: ${PCP_POSTGRES_HOST:192.168.1.8}
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:192.168.1.8}
host: ${PCP_KAFKA_HOST:localhost}
port: ${PCP_KAFKA_PORT:29092}
network:
host: ${PCP_NETWORK_HOST:192.168.1.8}
host: ${PCP_NETWORK_HOST:localhost}
ports:
config: 8888
config: 38888
admin: 7000
route-processing: 7004
request: 7005
+6 -3
View File
@@ -49,9 +49,12 @@ spring:
default-topic: pcp.tle
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_ballistics
username: postgres
password: password
url: "${SPRING_DATASOURCE_URL}"
username: "${SPRING_DATASOURCE_USERNAME}"
password: "${SPRING_DATASOURCE_PASSWORD}"
# url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_ballistics
# username: postgres
# password: password
jpa:
hibernate:
ddl-auto: validate
+3 -3
View File
@@ -96,9 +96,9 @@ spring:
on-profile: dev
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_satellites
username: postgres
password: password
url: "${SPRING_DATASOURCE_URL}"
username: "${SPRING_DATASOURCE_USERNAME}"
password: "${SPRING_DATASOURCE_PASSWORD}"
jpa:
hibernate:
ddl-auto: validate
+5 -5
View File
@@ -75,7 +75,7 @@ spring:
camunda:
client:
mode: self-managed
grpc-address: http://192.168.1.8:26500
grpc-address: http://camunda.k8s.265.nstart.cloud:30901
auth:
method: none
prefer-rest-over-grpc: false
@@ -115,9 +115,9 @@ spring:
missing-topics-fatal: false
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_missions
username: postgres
password: password
url: "${SPRING_DATASOURCE_URL}"
username: "${SPRING_DATASOURCE_USERNAME}"
password: "${SPRING_DATASOURCE_PASSWORD}"
jpa:
hibernate:
ddl-auto: validate
@@ -136,7 +136,7 @@ spring:
camunda:
client:
mode: self-managed
grpc-address: http://192.168.1.8:26500
grpc-address: http://camunda.k8s.265.nstart.cloud:30901
auth:
method: none
prefer-rest-over-grpc: false
+3 -3
View File
@@ -17,9 +17,9 @@ spring:
management-base-url: ${pcp.services.request}
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_requests
username: postgres
password: password
url: "${SPRING_DATASOURCE_URL}"
username: "${SPRING_DATASOURCE_USERNAME}"
password: "${SPRING_DATASOURCE_PASSWORD}"
jpa:
hibernate:
ddl-auto: validate
+3 -3
View File
@@ -68,9 +68,9 @@ spring:
on-profile: dev
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_stations
username: postgres
password: password
url: "${SPRING_DATASOURCE_URL}"
username: "${SPRING_DATASOURCE_USERNAME}"
password: "${SPRING_DATASOURCE_PASSWORD}"
jpa:
hibernate:
ddl-auto: validate
+1
View File
@@ -40,6 +40,7 @@ settings:
tgu-service: ${pcp.services.tgu}
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}
+3 -3
View File
@@ -108,9 +108,9 @@ spring:
default-topic: pcp.tle
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_slots
username: postgres
password: password
url: "${SPRING_DATASOURCE_URL}"
username: "${SPRING_DATASOURCE_USERNAME}"
password: "${SPRING_DATASOURCE_PASSWORD}"
jpa:
hibernate:
ddl-auto: validate
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
./create-chatgpt-archive.sh [archive.zip]
./create-chatgpt-archive.sh --list
Creates a zip archive with project files useful for code analysis.
Environment:
MAX_FILE_SIZE_BYTES Per-file size limit. Default: 1048576
The script includes source, configuration, build metadata, CI, Helm and docs.
It excludes build outputs, logs, local caches, IDE files, archives, binaries and env files.
USAGE
}
mode="create"
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
if [[ "${1:-}" == "--list" ]]; then
mode="list"
shift
fi
if (( $# > 1 )); then
usage >&2
exit 2
fi
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
archive_arg="${1:-pcp-chatgpt-context.zip}"
max_file_size_bytes="${MAX_FILE_SIZE_BYTES:-1048576}"
if [[ "$archive_arg" = /* ]]; then
archive_path="$archive_arg"
else
archive_path="$project_root/$archive_arg"
fi
include_paths=(
"AGENTS.md"
"README.md"
"HELP.md"
".dockerignore"
".gitattributes"
".gitignore"
".gitlab-ci.yml"
"build.gradle.kts"
"settings.gradle.kts"
"gradle.properties"
"gradlew"
"gradlew.bat"
"create-chatgpt-archive.sh"
"changeLogsDynamicSlots.md"
".gitlab"
"config-repo"
"docs"
"gradle"
"helm"
"libs"
"schemes"
"services"
)
should_skip_path() {
local path="$1"
case "$path" in
.git|.git/*|.gradle|.gradle/*|.gradle-local|.gradle-local/*|.idea|.idea/*|.kotlin|.kotlin/*|.vscode|.vscode/*)
return 0
;;
build|build/*|logs|logs/*|out|out/*|bin|bin/*|target|target/*|node_modules|node_modules/*)
return 0
;;
*/build|*/build/*|*/logs|*/logs/*|*/out|*/out/*|*/bin|*/bin/*|*/target|*/target/*|*/node_modules|*/node_modules/*)
return 0
;;
esac
case "$path" in
.env|.env.*|*/.env|*/.env.*)
return 0
;;
*.log|*.tmp|*.bak|*.orig|*.class|*.jar|*.war|*.ear|*.zip|*.tar|*.tgz|*.tar.gz|*.gz|*.7z|*.rar)
return 0
;;
*.png|*.jpg|*.jpeg|*.gif|*.webp|*.ico|*.pdf|*.doc|*.docx|*.xls|*.xlsx|*.ppt|*.pptx)
return 0
;;
*.keystore|*.jks|*.p12|*.pem|*.key|*.crt)
return 0
;;
esac
return 1
}
is_allowed_file() {
local path="$1"
case "$path" in
AGENTS.md|README.md|HELP.md|.dockerignore|.gitattributes|.gitignore|.gitlab-ci.yml)
return 0
;;
build.gradle.kts|settings.gradle.kts|gradle.properties|gradlew|gradlew.bat|create-chatgpt-archive.sh|changeLogsDynamicSlots.md)
return 0
;;
Dockerfile|Makefile|Jenkinsfile)
return 0
;;
*.kt|*.kts|*.java|*.groovy|*.xml|*.yaml|*.yml|*.properties|*.json|*.toml|*.md|*.txt)
return 0
;;
*.sql|*.graphql|*.graphqls|*.proto|*.bpmn|*.dmn|*.tpl|*.sh)
return 0
;;
*.html|*.css|*.scss|*.js|*.jsx|*.ts|*.tsx|*.vue)
return 0
;;
esac
return 1
}
manifest_file="$(mktemp)"
trap 'rm -f "$manifest_file"' EXIT
collect_files() {
local root_path
local relative_path
local file_size
for root_path in "${include_paths[@]}"; do
if [[ ! -e "$project_root/$root_path" ]]; then
continue
fi
if [[ -f "$project_root/$root_path" ]]; then
printf '%s\n' "$root_path"
else
(cd "$project_root" && find "$root_path" -type f -print)
fi
done | while IFS= read -r relative_path; do
relative_path="${relative_path#./}"
if should_skip_path "$relative_path"; then
continue
fi
if ! is_allowed_file "$relative_path"; then
continue
fi
file_size="$(stat -c '%s' "$project_root/$relative_path")"
if (( file_size > max_file_size_bytes )); then
continue
fi
printf '%s\n' "$relative_path"
done | sort -u
}
collect_files > "$manifest_file"
file_count="$(wc -l < "$manifest_file" | tr -d ' ')"
if (( file_count == 0 )); then
echo "No files selected for archive." >&2
exit 1
fi
if [[ "$mode" == "list" ]]; then
cat "$manifest_file"
echo
echo "Selected files: $file_count" >&2
exit 0
fi
if ! command -v zip >/dev/null 2>&1; then
echo "zip command is required but was not found." >&2
exit 1
fi
mkdir -p "$(dirname "$archive_path")"
rm -f "$archive_path"
(cd "$project_root" && zip -q -X "$archive_path" -@ < "$manifest_file")
archive_size="$(du -h "$archive_path" | awk '{print $1}')"
echo "Archive created: $archive_path"
echo "Selected files: $file_count"
echo "Archive size: $archive_size"
+1 -1
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=60000
networkTimeout=300000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -9,3 +9,19 @@ service:
ingress:
enabled: true
host: pcp-ballistics-service.dev.k8s.264.nstart.cloud
extraEnv:
- name: SERVER_PORT
value: "8080"
- name: SPRING_DATASOURCE_URL
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_ballistics
- name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: pcp-ballistics-db
key: SPRING_DATASOURCE_USERNAME
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: pcp-ballistics-db
key: SPRING_DATASOURCE_PASSWORD
@@ -9,3 +9,19 @@ service:
ingress:
enabled: true
host: pcp-complex-mission-service.dev.k8s.264.nstart.cloud
extraEnv:
- name: SERVER_PORT
value: "8080"
- name: SPRING_DATASOURCE_URL
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_complex_missions
- name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: pcp-complex-missions-db
key: SPRING_DATASOURCE_USERNAME
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: pcp-complex-missions-db
key: SPRING_DATASOURCE_PASSWORD
@@ -13,3 +13,15 @@ ingress:
extraEnv:
- name: SERVER_PORT
value: "8080"
- name: SPRING_DATASOURCE_URL
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_missions
- name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: pcp-missions-db
key: SPRING_DATASOURCE_USERNAME
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: pcp-missions-db
key: SPRING_DATASOURCE_PASSWORD
+12
View File
@@ -13,3 +13,15 @@ ingress:
extraEnv:
- name: SERVER_PORT
value: "8080"
- name: SPRING_DATASOURCE_URL
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_requests
- name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: pcp-requests-db
key: SPRING_DATASOURCE_USERNAME
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: pcp-requests-db
key: SPRING_DATASOURCE_PASSWORD
+12
View File
@@ -13,3 +13,15 @@ ingress:
extraEnv:
- name: SERVER_PORT
value: "8080"
- name: SPRING_DATASOURCE_URL
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_stations
- name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: pcp-stations-db
key: SPRING_DATASOURCE_USERNAME
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: pcp-stations-db
key: SPRING_DATASOURCE_PASSWORD
+2
View File
@@ -13,3 +13,5 @@ ingress:
extraEnv:
- name: SERVER_PORT
value: "8080"
- name: SETTINGS_TLE_MONITORING_SERVICE
value: "http://tle-monitoring-service:8080"
+2
View File
@@ -12,3 +12,5 @@ ingress:
extraEnv:
- name: SERVER_PORT
value: "8080"
- name: SETTINGS_TLE_MONITORING_SERVICE
value: "http://tle-monitoring-service:8080"
+12
View File
@@ -23,3 +23,15 @@ extraEnv:
value: "10"
- name: SPRING_FLYWAY_CONNECT_RETRIES_INTERVAL
value: "5s"
- name: SPRING_DATASOURCE_URL
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_slots
- name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: pcp-slots-db
key: SPRING_DATASOURCE_USERNAME
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef:
name: pcp-slots-db
key: SPRING_DATASOURCE_PASSWORD
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
}
+34 -95
View File
@@ -1,8 +1,10 @@
import ballistics.Ballistics
import ballistics.types.EarthType
import ballistics.types.InitialConditions
import ballistics.types.IntegrationType
import ballistics.types.ModDVType
import ballistics.types.OrbitalPoint
import ballistics.utils.astro.AstronomerJ2000
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import ballistics.utils.toDateTime
@@ -15,106 +17,38 @@ fun main() {
var ts = System.currentTimeMillis()
val r = Ballistics()
r.modDVType = ModDVType.BARS // BARS
r.integrationType = IntegrationType.ADAMS7
r.modDVType = ModDVType.KONDOR // BARS
r.integrationType = IntegrationType.RUNG4
val tt2 = LocalDateTime.of(2028, 12, 11, 1, 24, 24, 257000000)
val op = OrbitalPoint(
fromDateTime(tt2),
1,
Vector3D(
2610612.004898679,
-3507514.947081291,
-5291534.374006776,
),
Vector3D(
-4935.515395115775,
3497.047795669036,
-4742.501356855924,
),
)
val mnu = mutableListOf<InitialConditions>()
mnu.add(
InitialConditions(
OrbitalPoint(
LocalDateTime.of(2021, 12, 3, 0, 5, 45, 0).toEpochSecond(ZoneOffset.UTC).toDouble(),
1,
Vector3D(
-5357933.7872,
4328646.1787,
0.0,
),
Vector3D(
941.8995373,
1150.6919822,
7544.9920840,
),
),
0.0061,
op,
0.06 / 9.8,
125.0,
),
)
/*
mnu.add(InitialConditions(OrbitalPoint(1681135417.0,
1499,
6782429.138044466,
-1205586.994919729,
0.0,
-272.89561674108205,
-1460.1016031205972,
7539.209413654664),
0.0,
100.0)
)
mnu.add(InitialConditions(OrbitalPoint(1681203547.0,
1511,
2796871.797932365,
6295220.245166648,
0.0,
1351.432690993002,
-615.1950685524682,
7539.420044409451),
0.0,
100.0)
)
mnu.add(InitialConditions(OrbitalPoint(1681288710.0,
1526,
2219522.8066600505,
6520960.574145492,
0.0,
1401.0525909402313,
-491.90052480161194,
7539.654233369637),
0.0,
100.0)
)
mnu.add(InitialConditions(OrbitalPoint(1681373873.0,
1541,
1624190.3979707498,
6693858.330353258,
0.0,
1439.4621272728175,
-364.5959436771924,
7539.927000039702),
0.0,
100.0)
)
mnu.add(InitialConditions(OrbitalPoint(1681464714.0,
1557,
3663882.4989639986,
5832354.67692114,
0.0,
1249.158953470858,
-802.9876366520331,
7540.375561571017),
0.0,
100.0)
)
mnu.add(InitialConditions(OrbitalPoint(1681538522.0,
1570,
-2393483.7360840607,
6458206.838182452,
0.0,
1398.0568057512764,
501.055477122656,
7540.680958625912),
0.0,
100.0)
)
*/
mnu.sortBy { it.point.t }
@@ -123,9 +57,14 @@ fun main() {
println("время расчета = ${System.currentTimeMillis() - ts}мс")
// for (p in r.points)
// println("${p.vit} ${LocalDateTime.ofEpochSecond(p.t.toLong(),0, ZoneOffset.UTC)} ${p.r.x} ${p.r.y} ${p.r.z} ${p.v.x} ${p.v.y} ${p.v.z}")
//
val astro = AstronomerJ2000(EarthType.PZ90d02)
for (p in r.revolutions) {
val op = astro.grinvToASK(p.vuz)
println(
"${op.vit} ${toDateTime(op.t)} " +
"${op.r.x} ${op.r.y} ${op.r.z} ${op.v.x} ${op.v.y} ${op.v.z}"
)
}
ts = System.currentTimeMillis()
r.calculateFlightLine(mnu.first().point.t, mnu.first().point.t + 86400.0 * 5)
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
}
@@ -0,0 +1,25 @@
package space.nstart.pcp.pcp_types_lib.dto
import java.time.LocalDateTime
/**
* Read model for booked slots pages and integrations.
*
* The DTO is intentionally stored in pcp-types-lib because it is returned by slots-service
* and consumed by ui-service. It contains only booking/slot fields; UI-specific enrichment
* such as satellite name/type is performed in ui-service.
*/
data class BookedSlotDetailsDTO(
val bookedSlotId: Long,
val satelliteId: Long,
val slotNum: Long,
val cycle: Long,
val timeStart: LocalDateTime,
val timeStop: LocalDateTime,
val durationSeconds: Long,
val roll: Double?,
val revolution: Long?,
val revolutionSign: String?,
val status: String?,
val requestIds: List<String> = emptyList(),
)
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_types_lib.dto.ballistics
enum class MovementModel {
FOTO,
KONDOR,
METEORM1,
METEORM2,
BARS,
KONDOR_PROGNOZ
}
@@ -2,6 +2,8 @@ package space.nstart.pcp.pcp_types_lib.dto.ballistics
class SatelliteICDTO(
val satelliteId: Long = -1,
val ic : InitialConditionsDTO = InitialConditionsDTO()
val ic : InitialConditionsDTO = InitialConditionsDTO(),
movementModel: MovementModel? = MovementModel.FOTO
) {
}
val movementModel: MovementModel = movementModel ?: MovementModel.FOTO
}
@@ -0,0 +1,8 @@
package space.nstart.pcp.pcp_types_lib.dto.ballistics
import java.time.LocalDateTime
class TlePointRequestDTO(
var tle: TLEDTO = TLEDTO(),
var time: LocalDateTime = LocalDateTime.now()
)
@@ -0,0 +1,18 @@
package space.nstart.pcp.pcp_types_lib.dto.requests.slots
import java.time.LocalDateTime
data class BookedSlotDetailsDTO(
val bookedSlotId: Long,
val satelliteId: Long,
val slotNum: Long,
val cycle: Long,
val timeStart: LocalDateTime,
val timeStop: LocalDateTime,
val durationSeconds: Long,
val roll: Double?,
val revolution: Long?,
val revolutionSign: Int?,
val status: String?,
val requestIds: List<Long> = emptyList()
)
+2 -1
View File
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
@@ -8,7 +8,7 @@ plugins {
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
// id("org.graalvm.buildtools.native")
}
@@ -102,11 +102,11 @@ tasks.jacocoTestReport {
}
}
sonar {
properties {
property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}")
}
}
//sonar {
// properties {
// property("sonar.projectKey", "pcp")
// property("sonar.login", "sqp_tokenExample")
// property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
// property("sonar.host.url", "${property("sonar.host.url")}")
// }
//}
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -11,9 +12,11 @@ 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 space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
import space.nstart.pcp.pcp_request_service.service.SatelliteService
import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import java.time.LocalDateTime
@@ -23,6 +26,9 @@ class SatelliteController {
@Autowired
private lateinit var satelliteService: SatelliteService
@Autowired
private lateinit var satelliteIcEventService: SatelliteIcEventService
@PostMapping("/{satellite_id}/extract-time")
fun extractTime(
@PathVariable("satellite_id") satelliteId : Long,
@@ -30,6 +36,20 @@ class SatelliteController {
) =
satelliteService.exactTime(satelliteId, body)
@PostMapping("/receive-rv")
fun receiveRv(@RequestBody body: SatelliteICDTO): ResponseEntity<Void> {
satelliteIcEventService.handlePlacedIcRv(body)
return ResponseEntity.accepted().build()
}
@GetMapping("/{satellite_id}/initial-conditions/current")
fun currentInitialConditions(
@PathVariable("satellite_id") satelliteId: Long
): ResponseEntity<SatelliteICDTO> =
satelliteService.currentInitialConditions(satelliteId)
?.let { ResponseEntity.ok(it) }
?: ResponseEntity.noContent().build()
@Operation(
@@ -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)
@@ -0,0 +1,94 @@
package space.nstart.pcp.pcp_request_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
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.time.LocalDateTime
@Entity
@Table(name = "satellite_initial_conditions")
class SatelliteInitialConditionEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "satellite_initial_condition_id")
val id: Long? = null,
@Column(name = "satellite_id", nullable = false)
val satelliteId: Long = 0,
@Enumerated(EnumType.STRING)
@Column(name = "movement_model", nullable = false)
val movementModel: MovementModel = MovementModel.FOTO,
@Column(name = "orb_time", nullable = false)
val orbTime: LocalDateTime = LocalDateTime.now(),
@Column(name = "orb_revolution", nullable = false)
val orbRevolution: Long = 0,
@Column(nullable = false)
val x: Double = 0.0,
@Column(nullable = false)
val y: Double = 0.0,
@Column(nullable = false)
val z: Double = 0.0,
@Column(nullable = false)
val vx: Double = 0.0,
@Column(nullable = false)
val vy: Double = 0.0,
@Column(nullable = false)
val vz: Double = 0.0,
@Column(name = "s_ball", nullable = false)
val sBall: Double = 0.0,
@Column(nullable = false)
val f81: Double = 147.8,
@Column(name = "received_at", nullable = false)
val receivedAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "calculated_at")
var calculatedAt: LocalDateTime? = null,
@Column(name = "last_calculated", nullable = false)
var lastCalculated: Boolean = false
) {
constructor(request: SatelliteICDTO) : this(
satelliteId = request.satelliteId,
movementModel = request.movementModel,
orbTime = request.ic.orbPoint.time,
orbRevolution = request.ic.orbPoint.revolution,
x = request.ic.orbPoint.x,
y = request.ic.orbPoint.y,
z = request.ic.orbPoint.z,
vx = request.ic.orbPoint.vx,
vy = request.ic.orbPoint.vy,
vz = request.ic.orbPoint.vz,
sBall = request.ic.sBall,
f81 = request.ic.f81
)
fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now()) {
this.calculatedAt = calculatedAt
this.lastCalculated = true
}
fun toDto() = SatelliteICDTO(
satelliteId = satelliteId,
movementModel = movementModel,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = orbTime,
revolution = orbRevolution,
vx = vx,
vy = vy,
vz = vz,
x = x,
y = y,
z = z
),
sBall = sBall,
f81 = f81
)
)
}
@@ -6,6 +6,7 @@ import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
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.SatelliteICDTO
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.node.ObjectNode
@@ -52,6 +53,11 @@ class SatelliteIcRvKafkaListener(
*/
private fun normalizePayload(payloadNode: tools.jackson.databind.JsonNode): tools.jackson.databind.JsonNode {
val normalizedPayload = payloadNode.deepCopy()
if (normalizedPayload is ObjectNode &&
(normalizedPayload["movementModel"] == null || normalizedPayload["movementModel"].isNull)
) {
normalizedPayload.put("movementModel", DEFAULT_MOVEMENT_MODEL.name)
}
val initialConditionsNode = normalizedPayload["ic"]
if (initialConditionsNode is ObjectNode) {
if (initialConditionsNode["sBall"] == null || initialConditionsNode["sBall"].isNull) {
@@ -66,5 +72,6 @@ class SatelliteIcRvKafkaListener(
companion object {
private val DEFAULT_INITIAL_CONDITIONS = InitialConditionsDTO()
private val DEFAULT_MOVEMENT_MODEL = MovementModel.FOTO
}
}
@@ -0,0 +1,26 @@
package space.nstart.pcp.pcp_request_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_request_service.entity.SatelliteInitialConditionEntity
interface SatelliteInitialConditionRepository : JpaRepository<SatelliteInitialConditionEntity, Long> {
fun findFirstBySatelliteIdAndLastCalculatedTrueOrderByCalculatedAtDescIdDesc(
satelliteId: Long
): SatelliteInitialConditionEntity?
@Modifying
@Transactional
@Query(
"""
update SatelliteInitialConditionEntity entity
set entity.lastCalculated = false
where entity.satelliteId = :satelliteId
and entity.lastCalculated = true
"""
)
fun clearLastCalculated(@Param("satelliteId") satelliteId: Long): Int
}
@@ -0,0 +1,30 @@
package space.nstart.pcp.pcp_request_service.service
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_request_service.entity.SatelliteInitialConditionEntity
import space.nstart.pcp.pcp_request_service.repository.SatelliteInitialConditionRepository
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
@Service
class SatelliteInitialConditionService(
private val satelliteInitialConditionRepository: SatelliteInitialConditionRepository
) {
@Transactional
fun saveReceived(request: SatelliteICDTO): SatelliteInitialConditionEntity =
satelliteInitialConditionRepository.save(SatelliteInitialConditionEntity(request))
@Transactional
fun markLastCalculated(initialCondition: SatelliteInitialConditionEntity) {
satelliteInitialConditionRepository.clearLastCalculated(initialCondition.satelliteId)
initialCondition.markLastCalculated()
satelliteInitialConditionRepository.save(initialCondition)
}
@Transactional(readOnly = true)
fun current(satelliteId: Long): SatelliteICDTO? =
satelliteInitialConditionRepository
.findFirstBySatelliteIdAndLastCalculatedTrueOrderByCalculatedAtDescIdDesc(satelliteId)
?.toDto()
}
@@ -7,6 +7,7 @@ import ballistics.types.EarthType
import ballistics.types.FleghtLineSector
import ballistics.types.InitialConditions
import ballistics.types.KeplerParams
import ballistics.types.ModDVType
import ballistics.types.OPKatObj
import ballistics.types.OrbitalPoint
import ballistics.types.PPI
@@ -46,6 +47,7 @@ import space.nstart.pcp.pcp_request_service.utils.ContourClipService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
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.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
@@ -87,6 +89,10 @@ class SatelliteService {
@Autowired
private lateinit var pdcmRepository: PDCMRepository
@Autowired
private lateinit var satelliteInitialConditionService: SatelliteInitialConditionService
@Autowired
private lateinit var ascNodeRepository: AscNodeRepository
@Autowired
@@ -183,9 +189,11 @@ class SatelliteService {
)
suspend fun resieveRV(rv: SatelliteICDTO) {
val savedInitialCondition = satelliteInitialConditionService.saveReceived(rv)
val bal = Ballistics()
bal.modDVType = rv.movementModel.toModDVType()
logger.info("Начало расчета баллистики по RV id = ${rv.satelliteId}")
logger.info("Начало расчета баллистики по RV id = ${rv.satelliteId}, model = ${rv.movementModel}")
bal.rollMax = (45.0 * PI / 180.0)
bal.rollMin = (20.0 * PI / 180.0)
@@ -227,10 +235,25 @@ class SatelliteService {
prepareEarthCovering(bal, rv.satelliteId)
satelliteInitialConditionService.markLastCalculated(savedInitialCondition)
bal.clear()
logger.info("Расчет для КА ${rv.satelliteId} завершился успешно")
}
fun currentInitialConditions(satelliteId: Long): SatelliteICDTO? =
satelliteInitialConditionService.current(satelliteId)
private fun MovementModel.toModDVType(): ModDVType =
when (this) {
MovementModel.FOTO -> ModDVType.FOTO
MovementModel.KONDOR -> ModDVType.KONDOR
MovementModel.METEORM1 -> ModDVType.METEORM1
MovementModel.METEORM2 -> ModDVType.METEORM2
MovementModel.BARS -> ModDVType.BARS
MovementModel.KONDOR_PROGNOZ -> ModDVType.KONDOR_PROGNOZ
}
fun mergeSectors(sectors: List<FleghtLineSector>): List<FleghtLineSector> {
if (sectors.isEmpty()) return emptyList()
@@ -851,8 +874,6 @@ class SatelliteService {
return res
}
fun getAscNodes(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) : List<AscNodeDTO> {
return ascNodeRepository.findBySatelliteIdAndTimeBetween(
@@ -881,3 +902,7 @@ data class SatelliteBallisticsDeletionSummary(
val earthCoverageDeleted: Int,
val earthCellViewsDeleted: Int
)
@@ -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()
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS satellite_initial_conditions (
satellite_initial_condition_id BIGSERIAL PRIMARY KEY,
satellite_id BIGINT NOT NULL,
movement_model VARCHAR(32) NOT NULL DEFAULT 'FOTO',
orb_time TIMESTAMP NOT NULL,
orb_revolution BIGINT NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
z DOUBLE PRECISION NOT NULL,
vx DOUBLE PRECISION NOT NULL,
vy DOUBLE PRECISION NOT NULL,
vz DOUBLE PRECISION NOT NULL,
s_ball DOUBLE PRECISION NOT NULL,
f81 DOUBLE PRECISION NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
calculated_at TIMESTAMP,
last_calculated BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_satellite_initial_conditions_satellite_id ON satellite_initial_conditions(satellite_id);
CREATE INDEX idx_satellite_initial_conditions_last_calculated ON satellite_initial_conditions(satellite_id, last_calculated);
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import tools.jackson.databind.ObjectMapper
@@ -46,6 +47,7 @@ class SatelliteIcRvKafkaListenerTest {
assertEquals(56756L, satelliteIcEventService.lastMessage?.satelliteId)
assertEquals(42L, satelliteIcEventService.lastMessage?.ic?.orbPoint?.revolution)
assertEquals(140.0, satelliteIcEventService.lastMessage?.ic?.f81)
assertEquals(MovementModel.FOTO, satelliteIcEventService.lastMessage?.movementModel)
}
@Test
@@ -54,8 +56,9 @@ class SatelliteIcRvKafkaListenerTest {
"""
{
"type": "ICRVPlacedEvent",
"data": {
"data": {
"satelliteId": 56756,
"movementModel": null,
"ic": {
"orbPoint": {
"time": "2026-04-16T12:00:00",
@@ -77,6 +80,7 @@ class SatelliteIcRvKafkaListenerTest {
assertEquals(0.0, satelliteIcEventService.lastMessage?.ic?.sBall)
assertEquals(147.8, satelliteIcEventService.lastMessage?.ic?.f81)
assertEquals(MovementModel.FOTO, satelliteIcEventService.lastMessage?.movementModel)
}
private class RecordingSatelliteIcEventService : SatelliteIcEventService() {
@@ -14,6 +14,11 @@ import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.listener.adapter.RecordFilterStrategy
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_request_service.entity.PDCMEntity
import space.nstart.pcp.pcp_request_service.entity.SatelliteInitialConditionEntity
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.time.LocalDateTime
import kotlin.test.assertEquals
@@ -41,6 +46,9 @@ class PDCMRepositoryTest {
@Autowired
private lateinit var repository: PDCMRepository
@Autowired
private lateinit var satelliteInitialConditionRepository: SatelliteInitialConditionRepository
@Test
fun `find satellite orbit availability returns all satellites with points when time is null`() {
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0))
@@ -74,6 +82,34 @@ class PDCMRepositoryTest {
assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual.single().timeStop)
}
@Test
fun `satellite initial conditions repository returns last calculated condition`() {
val oldCondition = satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 1L))
oldCondition.markLastCalculated(LocalDateTime.of(2026, 4, 14, 10, 0))
satelliteInitialConditionRepository.save(oldCondition)
val newCondition = satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 2L))
satelliteInitialConditionRepository.clearLastCalculated(11L)
newCondition.markLastCalculated(LocalDateTime.of(2026, 4, 14, 12, 0))
satelliteInitialConditionRepository.save(newCondition)
satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 3L))
entityManager.flush()
entityManager.clear()
val actual = satelliteInitialConditionRepository
.findFirstBySatelliteIdAndLastCalculatedTrueOrderByCalculatedAtDescIdDesc(11L)
assertEquals(2L, actual?.toDto()?.ic?.orbPoint?.revolution)
assertEquals(MovementModel.KONDOR, actual?.movementModel)
assertEquals(
1,
satelliteInitialConditionRepository.findAll().count { entity ->
entity.satelliteId == 11L && entity.lastCalculated
}
)
}
private fun persistPoint(satelliteId: Long, time: LocalDateTime) {
entityManager.persist(
PDCMEntity(
@@ -91,6 +127,28 @@ class PDCMRepositoryTest {
entityManager.flush()
}
private fun sampleInitialCondition(satelliteId: Long, revolution: Long) =
SatelliteInitialConditionEntity(
SatelliteICDTO(
satelliteId = satelliteId,
movementModel = MovementModel.KONDOR,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 14, 10, 0).plusHours(revolution),
revolution = revolution,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
),
sBall = 0.2,
f81 = 141.0
)
)
)
@TestConfiguration
class KafkaFilterStubConfiguration {
@Bean("kafkaListenerContainerFactory")
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
@@ -8,7 +8,7 @@ plugins {
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
// id("org.graalvm.buildtools.native")
}
@@ -101,11 +101,11 @@ tasks.jacocoTestReport {
}
}
sonar {
properties {
property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}")
}
}
//sonar {
// properties {
// property("sonar.projectKey", "pcp")
// property("sonar.login", "sqp_tokenExample")
// property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
// property("sonar.host.url", "${property("sonar.host.url")}")
// }
//}
@@ -33,7 +33,6 @@ class SatelliteModel(
val longMission : LongTermMission = LongTermMission(),
val bls: BLS = BLS(),
val scanTLE : Boolean = false
) {
fun toDTO() = SatelliteInfoDTO(
@@ -18,8 +18,11 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import java.time.Duration
import java.time.LocalDateTime
import kotlin.math.abs
import kotlin.system.measureTimeMillis
@Service
@@ -36,6 +39,11 @@ class ComplexPlanCalculationService(
private val logger: Logger = LoggerFactory.getLogger(this::class.java)
companion object {
private const val LOG_INTERVAL_SAMPLE_LIMIT = 10
private const val ROLL_EPSILON = 0.01
}
fun process(
intervalStart: LocalDateTime,
intervalEnd: LocalDateTime,
@@ -134,7 +142,7 @@ class ComplexPlanCalculationService(
logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId)
val plannedModesMs = measureTimeMillis {
addPlannedModes(workingSatellites, intervalStart, intervalEnd)
addPlannedModes(runId, workingSatellites, intervalStart, intervalEnd)
}
logger.info(
"Stage planned-modes-load: runId={}, satellites={}, durationMs={}",
@@ -161,8 +169,9 @@ class ComplexPlanCalculationService(
}
val targetsChunk = buildTargetsChunk(chunk)
var coverageByTargetId = emptyMap<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>()
var coverageByTargetId = emptyMap<Long, List<SlotDTO>>()
val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites)
logOccupiedIntervalsSnapshot(runId, chunkIndex + 1, chunkCount, occupiedIntervals)
val coverageFetchMs = measureTimeMillis {
coverageByTargetId = coverageCalculationClient.coverageModes(
targets = targetsChunk,
@@ -189,6 +198,7 @@ class ComplexPlanCalculationService(
var acceptedForChunk = 0
val chunkProcessMs = measureTimeMillis {
acceptedForChunk = processChunk(
runId = runId,
workingSatellites = workingSatellites,
cells = chunk,
coverageByTargetId = coverageByTargetId,
@@ -294,6 +304,7 @@ class ComplexPlanCalculationService(
}
private fun addPlannedModes(
runId: Long?,
workingSatellites: List<SatelliteModel>,
intervalStart: LocalDateTime,
intervalEnd: LocalDateTime
@@ -308,35 +319,35 @@ class ComplexPlanCalculationService(
)
workingSatellites.forEach { satellite ->
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
plannedModesBySatelliteId[satellite.satelliteId]
.orEmpty()
.forEach { slot ->
surveyPlacementService.mergeSurvey(
existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode(
revolution = slot.revolution,
time = slot.tn,
timeStop = slot.tk,
roll = slot.roll,
latitude = slot.latitude,
longitude = slot.longitude,
duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
contourWKT = slot.contour,
cellNum = -1,
source = SatelliteModeSource.SLOTS,
bookedSlotIds = slot.slotIds,
cellNums = emptyList()
),
satellite = satellite
)
}
logger.info(
"Добавлены booked-маршруты для satelliteId={}: count={}",
satellite.satelliteId,
plannedForSatellite.size
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite)
plannedForSatellite.forEach { slot ->
surveyPlacementService.mergeSurvey(
existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode(
revolution = slot.revolution,
time = slot.tn,
timeStop = slot.tk,
roll = slot.roll,
latitude = slot.latitude,
longitude = slot.longitude,
duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
contourWKT = slot.contour,
cellNum = -1,
source = SatelliteModeSource.SLOTS,
bookedSlotIds = slot.slotIds,
cellNums = emptyList()
),
satellite = satellite
)
}
logger.info(
"Добавлены booked-маршруты: runId={}, satelliteId={}, count={}",
runId,
satellite.satelliteId,
plannedForSatellite.size
)
}
}
private fun buildCoverageTargets(cells: List<EarthCellWithRequestsDTO>): List<SlotCoverageTargetDTO> =
@@ -367,10 +378,59 @@ class ComplexPlanCalculationService(
.sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime })
.toList()
private fun logBookedSlotsLoaded(runId: Long?, satelliteId: Long, slots: List<SurveySlotDTO>) {
if (slots.isEmpty()) {
logger.info("Booked slots loaded: runId={}, satelliteId={}, count=0", runId, satelliteId)
return
}
logger.info(
"Booked slots loaded: runId={}, satelliteId={}, count={}, interval=[{} - {}], sample={}",
runId,
satelliteId,
slots.size,
slots.minOf { it.tn },
slots.maxOf { it.tk },
slots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { slot ->
"start=${slot.tn},end=${slot.tk},roll=${slot.roll},revolution=${slot.revolution},slotIds=${slot.slotIds}"
}
)
}
private fun logOccupiedIntervalsSnapshot(
runId: Long?,
chunkNumber: Int,
chunkCount: Int,
occupiedIntervals: List<OccupiedIntervalDTO>
) {
val bySource = occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount()
val slotsBySatellite = occupiedIntervals
.filter { it.source == SatelliteModeSource.SLOTS.name }
.groupingBy { it.satelliteId }
.eachCount()
logger.info(
"Occupied intervals snapshot before coverage request: runId={}, chunk={}/{}, total={}, bySource={}, slotsBySatellite={}, slotsSample={}",
runId,
chunkNumber,
chunkCount,
occupiedIntervals.size,
bySource,
slotsBySatellite,
occupiedIntervals
.filter { it.source == SatelliteModeSource.SLOTS.name }
.take(LOG_INTERVAL_SAMPLE_LIMIT)
.map { interval ->
"satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}"
}
)
}
private fun processChunk(
runId: Long?,
workingSatellites: List<SatelliteModel>,
cells: List<EarthCellWithRequestsDTO>,
coverageByTargetId: Map<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>,
coverageByTargetId: Map<Long, List<SlotDTO>>,
intervalStart: LocalDateTime,
intervalEnd: LocalDateTime
): Int {
@@ -391,6 +451,13 @@ class ComplexPlanCalculationService(
val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId }
?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван")
logComplanCandidateBookedSlotOverlaps(
runId = runId,
cell = cell,
slot = slot,
existingSurveys = satellite.longMission.surveys
)
val decision = surveyPlacementService.tryPlaceSurvey(
existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode(
@@ -425,6 +492,45 @@ class ComplexPlanCalculationService(
return acceptedModes
}
private fun logComplanCandidateBookedSlotOverlaps(
runId: Long?,
cell: EarthCellWithRequestsDTO,
slot: SlotDTO,
existingSurveys: List<SurveyMode>
) {
val overlappingBookedSlots = existingSurveys
.filter { survey -> survey.source == SatelliteModeSource.SLOTS }
.filter { survey -> intersectsInTime(survey.time, survey.timeStop, slot.tn, slot.tk) }
if (overlappingBookedSlots.isEmpty()) {
return
}
logger.warn(
"COMPLAN candidate overlaps booked SLOTS before placement: runId={}, satelliteId={}, targetId={}, cellNum={}, candidateInterval=[{} - {}], candidateRoll={}, candidateRevolution={}, overlaps={}, sameRollOverlaps={}, bookedSlotsSample={}",
runId,
slot.satelliteId,
cell.targetId(),
cell.num,
slot.tn,
slot.tk,
slot.roll,
slot.revolution,
overlappingBookedSlots.size,
overlappingBookedSlots.count { survey -> abs(survey.roll - slot.roll) <= ROLL_EPSILON },
overlappingBookedSlots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { survey ->
"start=${survey.time},end=${survey.timeStop},roll=${survey.roll},source=${survey.source},bookedSlotIds=${survey.bookedSlotIds}"
}
)
}
private fun intersectsInTime(
start1: LocalDateTime,
stop1: LocalDateTime,
start2: LocalDateTime,
stop2: LocalDateTime
): Boolean = start1 < stop2 && start2 < stop1
private fun hasRemainingCapacity(
workingSatellites: List<SatelliteModel>,
intervalStart: LocalDateTime,
@@ -36,7 +36,7 @@ class ComplexPlanRunOrchestrationService(
)
lateinit var createdRun: ComplexPlanRunEntity
val createRunMs = measureTimeMillis {
val createRunMs = measureTimeMillis {
createdRun = complexPlanRunPersistenceService.createRun(
ComplexPlanRunCreateDTO(
intervalStart = request.intervalStart,
@@ -18,6 +18,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRespon
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
@@ -161,18 +162,42 @@ class CoverageBatchClient(webClientBuilderProvider: ObjectProvider<WebClient.Bui
}
}
val rawCandidateSlots = result.response.sumOf { it.slots.size }
val filteredResponse = removeBookedSlotsFromCoverageResponse(result.response)
val filteredCandidateSlots = filteredResponse.sumOf { it.slots.size }
val removedBookedSlots = rawCandidateSlots - filteredCandidateSlots
logger.info(
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}, effectiveCoverageStrategy={}",
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlotsRaw={}, candidateSlotsAfterBookedFilter={}, removedBookedSlots={}, durationMs={}, effectiveCoverageStrategy={}",
targets.size,
result.response.size,
result.response.sumOf { it.slots.size },
filteredResponse.size,
rawCandidateSlots,
filteredCandidateSlots,
removedBookedSlots,
result.durationMs,
result.coverageStrategy
)
return result.response.associate { it.targetId to it.slots }
return filteredResponse.associate { it.targetId to it.slots }
}
/**
* Complex plan loads booked slots before asking slots-service for coverage candidates.
* slots-service still has to see booked slots internally: it marks them as BOOKED and the
* coverage solver subtracts their geometry from the uncovered target area. However these
* BOOKED slots are already present in the working complex plan and must not be returned
* to chunk processing again as COMPLAN candidates.
*/
private fun removeBookedSlotsFromCoverageResponse(
response: List<SlotCoverageBatchResponseDTO>
): List<SlotCoverageBatchResponseDTO> =
response.map { targetResponse ->
targetResponse.copy(
slots = targetResponse.slots.filterNot { slot -> slot.state != SlotStatus.AVAILABLE }
)
}
private fun requestCoverageModes(
targets: List<SlotCoverageTargetDTO>,
intervalStart: LocalDateTime,
@@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
private val logger = LoggerFactory.getLogger(this::class.java)
companion object {
private const val LOG_INTERVAL_SAMPLE_LIMIT = 10
}
@Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}")
val url = ""
@@ -55,6 +59,16 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
intervalStart,
intervalEnd
)
if (occupiedIntervals.isNotEmpty()) {
logger.warn(
"coverage-scheme-service client received occupiedIntervals but does not pass them to coverage-scheme-service request: occupiedIntervals={}, bySource={}, sample={}",
occupiedIntervals.size,
occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount(),
occupiedIntervals.take(LOG_INTERVAL_SAMPLE_LIMIT).map { interval ->
"satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}"
}
)
}
val result = linkedMapOf<Long, List<SlotDTO>>()
val durationMs = measureTimeMillis {
@@ -226,6 +226,9 @@ class SurveyPlacementService {
if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) {
return@forEach
}
if (shouldSkipComplanInsideSlotsMerge(existing, mergedSurvey)) {
return@forEach
}
val overlapsInTime = intersectsInTime(
existing.time,
existing.timeStop,
@@ -377,6 +380,21 @@ class SurveyPlacementService {
return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS
}
private fun shouldSkipComplanInsideSlotsMerge(
left: SurveyMode,
right: SurveyMode
): Boolean {
val complanSurvey = when {
left.source == SatelliteModeSource.COMPLAN && right.source == SatelliteModeSource.SLOTS -> left
right.source == SatelliteModeSource.COMPLAN && left.source == SatelliteModeSource.SLOTS -> right
else -> return false
}
val slotsSurvey = if (complanSurvey === left) right else left
return !complanSurvey.time.isBefore(slotsSurvey.time) &&
!complanSurvey.timeStop.isAfter(slotsSurvey.timeStop)
}
private fun intersectsInTime(
start1: LocalDateTime,
stop1: LocalDateTime,
@@ -121,6 +121,39 @@ class SurveyPlacementServiceTest {
assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON"))
}
@Test
fun `complan inside booked slot is not merged to mixed`() {
val surveys = mutableListOf(
survey(
start = 0,
end = 100,
roll = 10.0,
source = SatelliteModeSource.SLOTS,
contour = "POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0))"
)
)
val satellite = satellite()
val decision = service.tryPlaceSurvey(
surveys,
survey(
start = 0,
end = 40,
roll = 10.0,
source = SatelliteModeSource.COMPLAN,
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
),
satellite
)
assertTrue(decision.accepted)
assertEquals(2, surveys.size)
assertTrue(decision.absorbedSurveys.isEmpty())
assertEquals(1, surveys.count { it.source == SatelliteModeSource.SLOTS })
assertEquals(1, surveys.count { it.source == SatelliteModeSource.COMPLAN })
assertFalse(surveys.any { it.source == SatelliteModeSource.MIXED })
}
@Test
fun `different roll conflict resolved by trimming`() {
val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0))
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
+2 -1
View File
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
@@ -7,7 +7,7 @@ spring:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.1.8:8888}
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
@@ -15,7 +15,7 @@ spring:
# max-in-memory-size: 16MB
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.1.8:5432/complan
url: jdbc:postgresql://192.168.60.68:5432/complan
username: postgres
password: password
jpa:
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
+2 -1
View File
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
@@ -7,7 +7,7 @@ plugins {
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
}
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS="-jar"
@@ -5,7 +5,7 @@ plugins {
kotlin("plugin.spring")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
}
@@ -65,11 +65,4 @@ tasks.jacocoTestReport {
}
}
sonar {
properties {
property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}")
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
+1 -10
View File
@@ -7,7 +7,7 @@ plugins {
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
}
@@ -94,12 +94,3 @@ tasks.jacocoTestReport {
csv.required = false
}
}
sonar {
properties {
property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}")
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
+2 -1
View File
@@ -1,4 +1,5 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
+1 -1
View File
@@ -6,7 +6,7 @@ plugins {
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
//id("org.sonarqube")
jacoco
}
@@ -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>

Some files were not shown because too many files have changed in this diff Show More