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: tags:
- nstart - nstart
variables:
INTERNAL_REGISTRY: "$CI_REGISTRY_IMAGE"
stages: stages:
- test - test
- build - build
+2
View File
@@ -14,6 +14,8 @@ variables:
- libs/pcp-types-lib/**/* - libs/pcp-types-lib/**/*
- build.gradle.kts - build.gradle.kts
- settings.gradle.kts - settings.gradle.kts
- gradle.properties
- gradle/**/*
- .gitlab/ci/pcp-request-service.yml - .gitlab/ci/pcp-request-service.yml
- helm/pcp-request-service/**/* - helm/pcp-request-service/**/*
- deploy/**/* - deploy/**/*
+13 -7
View File
@@ -8,7 +8,7 @@ plugins {
kotlin("plugin.lombok") apply false kotlin("plugin.lombok") apply false
id("org.springframework.boot") apply false id("org.springframework.boot") apply false
id("io.spring.dependency-management") 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" group = "space.nstart.pcp"
ext.set("kotlinVersion", "2.2.20") ext.set("kotlinVersion", "2.2.20")
repositories { repositories {
// maven { maven {
// url = uri("https://repo.nstart.cloud/repository/maven-proxy/") url = uri("https://repo.nstart.cloud/repository/maven-proxy/")
// } }
mavenCentral() mavenCentral {
mavenLocal() content {
gradlePluginPortal() includeGroup("org.jacoco")
includeGroup("org.ow2.asm")
}
}
// mavenCentral()
// mavenLocal()
// gradlePluginPortal()
maven { url = uri("https://jitpack.io") } maven { url = uri("https://jitpack.io") }
exclusiveContent { exclusiveContent {
+7 -5
View File
@@ -3,19 +3,21 @@
pcp: pcp:
infra: infra:
postgres: postgres:
host: ${PCP_POSTGRES_HOST:192.168.1.8} # host: ${PCP_POSTGRES_HOST:localhost}
port: ${PCP_POSTGRES_PORT:5432} # port: ${PCP_POSTGRES_PORT:5432}
host: ${PCP_POSTGRES_HOST:192.168.100.160}
port: ${PCP_POSTGRES_PORT:35400}
kafka: kafka:
host: ${PCP_KAFKA_HOST:192.168.1.8} host: ${PCP_KAFKA_HOST:localhost}
port: ${PCP_KAFKA_PORT:29092} port: ${PCP_KAFKA_PORT:29092}
network: network:
host: ${PCP_NETWORK_HOST:192.168.1.8} host: ${PCP_NETWORK_HOST:localhost}
ports: ports:
config: 8888 config: 38888
admin: 7000 admin: 7000
route-processing: 7004 route-processing: 7004
request: 7005 request: 7005
+6 -3
View File
@@ -49,9 +49,12 @@ spring:
default-topic: pcp.tle default-topic: pcp.tle
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_ballistics url: "${SPRING_DATASOURCE_URL}"
username: postgres username: "${SPRING_DATASOURCE_USERNAME}"
password: password password: "${SPRING_DATASOURCE_PASSWORD}"
# url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_ballistics
# username: postgres
# password: password
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
+3 -3
View File
@@ -96,9 +96,9 @@ spring:
on-profile: dev on-profile: dev
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_satellites url: "${SPRING_DATASOURCE_URL}"
username: postgres username: "${SPRING_DATASOURCE_USERNAME}"
password: password password: "${SPRING_DATASOURCE_PASSWORD}"
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
+5 -5
View File
@@ -75,7 +75,7 @@ spring:
camunda: camunda:
client: client:
mode: self-managed mode: self-managed
grpc-address: http://192.168.1.8:26500 grpc-address: http://camunda.k8s.265.nstart.cloud:30901
auth: auth:
method: none method: none
prefer-rest-over-grpc: false prefer-rest-over-grpc: false
@@ -115,9 +115,9 @@ spring:
missing-topics-fatal: false missing-topics-fatal: false
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_missions url: "${SPRING_DATASOURCE_URL}"
username: postgres username: "${SPRING_DATASOURCE_USERNAME}"
password: password password: "${SPRING_DATASOURCE_PASSWORD}"
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
@@ -136,7 +136,7 @@ spring:
camunda: camunda:
client: client:
mode: self-managed mode: self-managed
grpc-address: http://192.168.1.8:26500 grpc-address: http://camunda.k8s.265.nstart.cloud:30901
auth: auth:
method: none method: none
prefer-rest-over-grpc: false prefer-rest-over-grpc: false
+3 -3
View File
@@ -17,9 +17,9 @@ spring:
management-base-url: ${pcp.services.request} management-base-url: ${pcp.services.request}
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_requests url: "${SPRING_DATASOURCE_URL}"
username: postgres username: "${SPRING_DATASOURCE_USERNAME}"
password: password password: "${SPRING_DATASOURCE_PASSWORD}"
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
+3 -3
View File
@@ -68,9 +68,9 @@ spring:
on-profile: dev on-profile: dev
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_stations url: "${SPRING_DATASOURCE_URL}"
username: postgres username: "${SPRING_DATASOURCE_USERNAME}"
password: password password: "${SPRING_DATASOURCE_PASSWORD}"
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
+1
View File
@@ -40,6 +40,7 @@ settings:
tgu-service: ${pcp.services.tgu} tgu-service: ${pcp.services.tgu}
satellite-catalog-service: ${pcp.services.satellite-catalog} satellite-catalog-service: ${pcp.services.satellite-catalog}
ballistics-service: ${pcp.services.ballistics} ballistics-service: ${pcp.services.ballistics}
tle-monitoring-service: ${pcp.services.tle-monitoring}
stations-service: ${pcp.services.stations} stations-service: ${pcp.services.stations}
slots-service: ${pcp.services.slots} slots-service: ${pcp.services.slots}
mission-service: ${pcp.services.mission} mission-service: ${pcp.services.mission}
+3 -3
View File
@@ -108,9 +108,9 @@ spring:
default-topic: pcp.tle default-topic: pcp.tle
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_slots url: "${SPRING_DATASOURCE_URL}"
username: postgres username: "${SPRING_DATASOURCE_USERNAME}"
password: password password: "${SPRING_DATASOURCE_PASSWORD}"
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=60000 networkTimeout=300000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
@@ -9,3 +9,19 @@ service:
ingress: ingress:
enabled: true enabled: true
host: pcp-ballistics-service.dev.k8s.264.nstart.cloud 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: ingress:
enabled: true enabled: true
host: pcp-complex-mission-service.dev.k8s.264.nstart.cloud 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: extraEnv:
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" 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: extraEnv:
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" 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: extraEnv:
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" 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: extraEnv:
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" value: "8080"
- name: SETTINGS_TLE_MONITORING_SERVICE
value: "http://tle-monitoring-service:8080"
+2
View File
@@ -12,3 +12,5 @@ ingress:
extraEnv: extraEnv:
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" value: "8080"
- name: SETTINGS_TLE_MONITORING_SERVICE
value: "http://tle-monitoring-service:8080"
+12
View File
@@ -23,3 +23,15 @@ extraEnv:
value: "10" value: "10"
- name: SPRING_FLYWAY_CONNECT_RETRIES_INTERVAL - name: SPRING_FLYWAY_CONNECT_RETRIES_INTERVAL
value: "5s" 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") kotlin("plugin.lombok")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco jacoco
} }
+34 -95
View File
@@ -1,8 +1,10 @@
import ballistics.Ballistics import ballistics.Ballistics
import ballistics.types.EarthType
import ballistics.types.InitialConditions import ballistics.types.InitialConditions
import ballistics.types.IntegrationType import ballistics.types.IntegrationType
import ballistics.types.ModDVType import ballistics.types.ModDVType
import ballistics.types.OrbitalPoint import ballistics.types.OrbitalPoint
import ballistics.utils.astro.AstronomerJ2000
import ballistics.utils.fromDateTime import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D import ballistics.utils.math.Vector3D
import ballistics.utils.toDateTime import ballistics.utils.toDateTime
@@ -15,106 +17,38 @@ fun main() {
var ts = System.currentTimeMillis() var ts = System.currentTimeMillis()
val r = Ballistics() val r = Ballistics()
r.modDVType = ModDVType.BARS // BARS r.modDVType = ModDVType.KONDOR // BARS
r.integrationType = IntegrationType.ADAMS7 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>() val mnu = mutableListOf<InitialConditions>()
mnu.add( mnu.add(
InitialConditions( InitialConditions(
OrbitalPoint( op,
LocalDateTime.of(2021, 12, 3, 0, 5, 45, 0).toEpochSecond(ZoneOffset.UTC).toDouble(), 0.06 / 9.8,
1,
Vector3D(
-5357933.7872,
4328646.1787,
0.0,
),
Vector3D(
941.8995373,
1150.6919822,
7544.9920840,
),
),
0.0061,
125.0, 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 } mnu.sortBy { it.point.t }
@@ -123,9 +57,14 @@ fun main() {
println("время расчета = ${System.currentTimeMillis() - ts}мс") println("время расчета = ${System.currentTimeMillis() - ts}мс")
// for (p in r.points) val astro = AstronomerJ2000(EarthType.PZ90d02)
// 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}") 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() ts = System.currentTimeMillis()
r.calculateFlightLine(mnu.first().point.t, mnu.first().point.t + 86400.0 * 5) 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") kotlin("plugin.lombok")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco 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( class SatelliteICDTO(
val satelliteId: Long = -1, 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="" ENV JAVA_OPTS=""
@@ -8,7 +8,7 @@ plugins {
kotlin("plugin.lombok") kotlin("plugin.lombok")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco jacoco
// id("org.graalvm.buildtools.native") // id("org.graalvm.buildtools.native")
} }
@@ -102,11 +102,11 @@ tasks.jacocoTestReport {
} }
} }
sonar { //sonar {
properties { // properties {
property("sonar.projectKey", "pcp") // property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample") // property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}") // property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}") // 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 io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.beans.factory.annotation.Autowired 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.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping 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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController 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_request_service.service.SatelliteService
import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException 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.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -23,6 +26,9 @@ class SatelliteController {
@Autowired @Autowired
private lateinit var satelliteService: SatelliteService private lateinit var satelliteService: SatelliteService
@Autowired
private lateinit var satelliteIcEventService: SatelliteIcEventService
@PostMapping("/{satellite_id}/extract-time") @PostMapping("/{satellite_id}/extract-time")
fun extractTime( fun extractTime(
@PathVariable("satellite_id") satelliteId : Long, @PathVariable("satellite_id") satelliteId : Long,
@@ -30,6 +36,20 @@ class SatelliteController {
) = ) =
satelliteService.exactTime(satelliteId, body) 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( @Operation(
@@ -1,15 +1,13 @@
package space.nstart.pcp.pcp_request_service.controller package space.nstart.pcp.pcp_request_service.controller
import ballistics.types.OrbitalPoint
import org.springframework.beans.factory.annotation.Autowired 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.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_request_service.service.TLEService 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.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TlePointRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
@RestController @RestController
@@ -23,6 +21,9 @@ class TLEController {
@PostMapping("/parse") @PostMapping("/parse")
fun orbit(@RequestBody tle : TLEDTO) = tleService.parseTLE(tle) fun orbit(@RequestBody tle : TLEDTO) = tleService.parseTLE(tle)
@PostMapping("/point")
fun point(@RequestBody req: TlePointRequestDTO) = tleService.point(req)
@PostMapping("/rva") @PostMapping("/rva")
fun rva(@RequestBody req : TleRvaRequestDTO) = tleService.rva(req) 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 org.springframework.stereotype.Component
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService 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.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.node.ObjectNode import tools.jackson.databind.node.ObjectNode
@@ -52,6 +53,11 @@ class SatelliteIcRvKafkaListener(
*/ */
private fun normalizePayload(payloadNode: tools.jackson.databind.JsonNode): tools.jackson.databind.JsonNode { private fun normalizePayload(payloadNode: tools.jackson.databind.JsonNode): tools.jackson.databind.JsonNode {
val normalizedPayload = payloadNode.deepCopy() 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"] val initialConditionsNode = normalizedPayload["ic"]
if (initialConditionsNode is ObjectNode) { if (initialConditionsNode is ObjectNode) {
if (initialConditionsNode["sBall"] == null || initialConditionsNode["sBall"].isNull) { if (initialConditionsNode["sBall"] == null || initialConditionsNode["sBall"].isNull) {
@@ -66,5 +72,6 @@ class SatelliteIcRvKafkaListener(
companion object { companion object {
private val DEFAULT_INITIAL_CONDITIONS = InitialConditionsDTO() 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.FleghtLineSector
import ballistics.types.InitialConditions import ballistics.types.InitialConditions
import ballistics.types.KeplerParams import ballistics.types.KeplerParams
import ballistics.types.ModDVType
import ballistics.types.OPKatObj import ballistics.types.OPKatObj
import ballistics.types.OrbitalPoint import ballistics.types.OrbitalPoint
import ballistics.types.PPI 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.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO 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.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.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
@@ -87,6 +89,10 @@ class SatelliteService {
@Autowired @Autowired
private lateinit var pdcmRepository: PDCMRepository private lateinit var pdcmRepository: PDCMRepository
@Autowired
private lateinit var satelliteInitialConditionService: SatelliteInitialConditionService
@Autowired @Autowired
private lateinit var ascNodeRepository: AscNodeRepository private lateinit var ascNodeRepository: AscNodeRepository
@Autowired @Autowired
@@ -183,9 +189,11 @@ class SatelliteService {
) )
suspend fun resieveRV(rv: SatelliteICDTO) { suspend fun resieveRV(rv: SatelliteICDTO) {
val savedInitialCondition = satelliteInitialConditionService.saveReceived(rv)
val bal = Ballistics() 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.rollMax = (45.0 * PI / 180.0)
bal.rollMin = (20.0 * PI / 180.0) bal.rollMin = (20.0 * PI / 180.0)
@@ -227,10 +235,25 @@ class SatelliteService {
prepareEarthCovering(bal, rv.satelliteId) prepareEarthCovering(bal, rv.satelliteId)
satelliteInitialConditionService.markLastCalculated(savedInitialCondition)
bal.clear() bal.clear()
logger.info("Расчет для КА ${rv.satelliteId} завершился успешно") 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> { fun mergeSectors(sectors: List<FleghtLineSector>): List<FleghtLineSector> {
if (sectors.isEmpty()) return emptyList() if (sectors.isEmpty()) return emptyList()
@@ -851,8 +874,6 @@ class SatelliteService {
return res return res
} }
fun getAscNodes(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) : List<AscNodeDTO> { fun getAscNodes(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) : List<AscNodeDTO> {
return ascNodeRepository.findBySatelliteIdAndTimeBetween( return ascNodeRepository.findBySatelliteIdAndTimeBetween(
@@ -881,3 +902,7 @@ data class SatelliteBallisticsDeletionSummary(
val earthCoverageDeleted: Int, val earthCoverageDeleted: Int,
val earthCellViewsDeleted: Int val earthCellViewsDeleted: Int
) )
@@ -1,7 +1,9 @@
package space.nstart.pcp.pcp_request_service.service package space.nstart.pcp.pcp_request_service.service
import ballistics.Ballistics import ballistics.Ballistics
import ballistics.orbitalPoints.timeStepper.TLEStepper
import ballistics.types.BallisticsError import ballistics.types.BallisticsError
import ballistics.types.EarthType
import ballistics.types.PPI import ballistics.types.PPI
import ballistics.types.TLE import ballistics.types.TLE
import ballistics.types.TLEParams 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.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO 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.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.TargetPositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
import kotlin.math.PI 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> { fun rva(req : TleRvaRequestDTO) : Iterable<RadioVisibilityAreaDTO> {
val bal = Ballistics() 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.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.ObjectProvider
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService 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 space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
@@ -46,6 +47,7 @@ class SatelliteIcRvKafkaListenerTest {
assertEquals(56756L, satelliteIcEventService.lastMessage?.satelliteId) assertEquals(56756L, satelliteIcEventService.lastMessage?.satelliteId)
assertEquals(42L, satelliteIcEventService.lastMessage?.ic?.orbPoint?.revolution) assertEquals(42L, satelliteIcEventService.lastMessage?.ic?.orbPoint?.revolution)
assertEquals(140.0, satelliteIcEventService.lastMessage?.ic?.f81) assertEquals(140.0, satelliteIcEventService.lastMessage?.ic?.f81)
assertEquals(MovementModel.FOTO, satelliteIcEventService.lastMessage?.movementModel)
} }
@Test @Test
@@ -56,6 +58,7 @@ class SatelliteIcRvKafkaListenerTest {
"type": "ICRVPlacedEvent", "type": "ICRVPlacedEvent",
"data": { "data": {
"satelliteId": 56756, "satelliteId": 56756,
"movementModel": null,
"ic": { "ic": {
"orbPoint": { "orbPoint": {
"time": "2026-04-16T12:00:00", "time": "2026-04-16T12:00:00",
@@ -77,6 +80,7 @@ class SatelliteIcRvKafkaListenerTest {
assertEquals(0.0, satelliteIcEventService.lastMessage?.ic?.sBall) assertEquals(0.0, satelliteIcEventService.lastMessage?.ic?.sBall)
assertEquals(147.8, satelliteIcEventService.lastMessage?.ic?.f81) assertEquals(147.8, satelliteIcEventService.lastMessage?.ic?.f81)
assertEquals(MovementModel.FOTO, satelliteIcEventService.lastMessage?.movementModel)
} }
private class RecordingSatelliteIcEventService : SatelliteIcEventService() { private class RecordingSatelliteIcEventService : SatelliteIcEventService() {
@@ -14,6 +14,11 @@ import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.listener.adapter.RecordFilterStrategy import org.springframework.kafka.listener.adapter.RecordFilterStrategy
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_request_service.entity.PDCMEntity 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 java.time.LocalDateTime
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -41,6 +46,9 @@ class PDCMRepositoryTest {
@Autowired @Autowired
private lateinit var repository: PDCMRepository private lateinit var repository: PDCMRepository
@Autowired
private lateinit var satelliteInitialConditionRepository: SatelliteInitialConditionRepository
@Test @Test
fun `find satellite orbit availability returns all satellites with points when time is null`() { fun `find satellite orbit availability returns all satellites with points when time is null`() {
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0)) 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) 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) { private fun persistPoint(satelliteId: Long, time: LocalDateTime) {
entityManager.persist( entityManager.persist(
PDCMEntity( PDCMEntity(
@@ -91,6 +127,28 @@ class PDCMRepositoryTest {
entityManager.flush() 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 @TestConfiguration
class KafkaFilterStubConfiguration { class KafkaFilterStubConfiguration {
@Bean("kafkaListenerContainerFactory") @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="" ENV JAVA_OPTS=""
@@ -8,7 +8,7 @@ plugins {
kotlin("plugin.lombok") kotlin("plugin.lombok")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco jacoco
// id("org.graalvm.buildtools.native") // id("org.graalvm.buildtools.native")
} }
@@ -101,11 +101,11 @@ tasks.jacocoTestReport {
} }
} }
sonar { //sonar {
properties { // properties {
property("sonar.projectKey", "pcp") // property("sonar.projectKey", "pcp")
property("sonar.login", "sqp_tokenExample") // property("sonar.login", "sqp_tokenExample")
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}") // property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
property("sonar.host.url", "${property("sonar.host.url")}") // property("sonar.host.url", "${property("sonar.host.url")}")
} // }
} //}
@@ -33,7 +33,6 @@ class SatelliteModel(
val longMission : LongTermMission = LongTermMission(), val longMission : LongTermMission = LongTermMission(),
val bls: BLS = BLS(), val bls: BLS = BLS(),
val scanTLE : Boolean = false val scanTLE : Boolean = false
) { ) {
fun toDTO() = SatelliteInfoDTO( 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.OccupiedIntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy 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.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.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlin.math.abs
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@Service @Service
@@ -36,6 +39,11 @@ class ComplexPlanCalculationService(
private val logger: Logger = LoggerFactory.getLogger(this::class.java) 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( fun process(
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
intervalEnd: LocalDateTime, intervalEnd: LocalDateTime,
@@ -134,7 +142,7 @@ class ComplexPlanCalculationService(
logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId) logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId)
val plannedModesMs = measureTimeMillis { val plannedModesMs = measureTimeMillis {
addPlannedModes(workingSatellites, intervalStart, intervalEnd) addPlannedModes(runId, workingSatellites, intervalStart, intervalEnd)
} }
logger.info( logger.info(
"Stage planned-modes-load: runId={}, satellites={}, durationMs={}", "Stage planned-modes-load: runId={}, satellites={}, durationMs={}",
@@ -161,8 +169,9 @@ class ComplexPlanCalculationService(
} }
val targetsChunk = buildTargetsChunk(chunk) 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) val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites)
logOccupiedIntervalsSnapshot(runId, chunkIndex + 1, chunkCount, occupiedIntervals)
val coverageFetchMs = measureTimeMillis { val coverageFetchMs = measureTimeMillis {
coverageByTargetId = coverageCalculationClient.coverageModes( coverageByTargetId = coverageCalculationClient.coverageModes(
targets = targetsChunk, targets = targetsChunk,
@@ -189,6 +198,7 @@ class ComplexPlanCalculationService(
var acceptedForChunk = 0 var acceptedForChunk = 0
val chunkProcessMs = measureTimeMillis { val chunkProcessMs = measureTimeMillis {
acceptedForChunk = processChunk( acceptedForChunk = processChunk(
runId = runId,
workingSatellites = workingSatellites, workingSatellites = workingSatellites,
cells = chunk, cells = chunk,
coverageByTargetId = coverageByTargetId, coverageByTargetId = coverageByTargetId,
@@ -294,6 +304,7 @@ class ComplexPlanCalculationService(
} }
private fun addPlannedModes( private fun addPlannedModes(
runId: Long?,
workingSatellites: List<SatelliteModel>, workingSatellites: List<SatelliteModel>,
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
intervalEnd: LocalDateTime intervalEnd: LocalDateTime
@@ -309,9 +320,8 @@ class ComplexPlanCalculationService(
workingSatellites.forEach { satellite -> workingSatellites.forEach { satellite ->
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty() val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
plannedModesBySatelliteId[satellite.satelliteId] logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite)
.orEmpty() plannedForSatellite.forEach { slot ->
.forEach { slot ->
surveyPlacementService.mergeSurvey( surveyPlacementService.mergeSurvey(
existingSurveys = satellite.longMission.surveys, existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode( candidateSurvey = SurveyMode(
@@ -332,7 +342,8 @@ class ComplexPlanCalculationService(
) )
} }
logger.info( logger.info(
"Добавлены booked-маршруты для satelliteId={}: count={}", "Добавлены booked-маршруты: runId={}, satelliteId={}, count={}",
runId,
satellite.satelliteId, satellite.satelliteId,
plannedForSatellite.size plannedForSatellite.size
) )
@@ -367,10 +378,59 @@ class ComplexPlanCalculationService(
.sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime }) .sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime })
.toList() .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( private fun processChunk(
runId: Long?,
workingSatellites: List<SatelliteModel>, workingSatellites: List<SatelliteModel>,
cells: List<EarthCellWithRequestsDTO>, cells: List<EarthCellWithRequestsDTO>,
coverageByTargetId: Map<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>, coverageByTargetId: Map<Long, List<SlotDTO>>,
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
intervalEnd: LocalDateTime intervalEnd: LocalDateTime
): Int { ): Int {
@@ -391,6 +451,13 @@ class ComplexPlanCalculationService(
val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId } val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId }
?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван") ?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван")
logComplanCandidateBookedSlotOverlaps(
runId = runId,
cell = cell,
slot = slot,
existingSurveys = satellite.longMission.surveys
)
val decision = surveyPlacementService.tryPlaceSurvey( val decision = surveyPlacementService.tryPlaceSurvey(
existingSurveys = satellite.longMission.surveys, existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode( candidateSurvey = SurveyMode(
@@ -425,6 +492,45 @@ class ComplexPlanCalculationService(
return acceptedModes 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( private fun hasRemainingCapacity(
workingSatellites: List<SatelliteModel>, workingSatellites: List<SatelliteModel>,
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
@@ -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.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO 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.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -161,16 +162,40 @@ 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( 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, targets.size,
result.response.size, filteredResponse.size,
result.response.sumOf { it.slots.size }, rawCandidateSlots,
filteredCandidateSlots,
removedBookedSlots,
result.durationMs, result.durationMs,
result.coverageStrategy 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( private fun requestCoverageModes(
@@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
private val logger = LoggerFactory.getLogger(this::class.java) 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}") @Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}")
val url = "" val url = ""
@@ -55,6 +59,16 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
intervalStart, intervalStart,
intervalEnd 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 result = linkedMapOf<Long, List<SlotDTO>>()
val durationMs = measureTimeMillis { val durationMs = measureTimeMillis {
@@ -226,6 +226,9 @@ class SurveyPlacementService {
if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) { if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) {
return@forEach return@forEach
} }
if (shouldSkipComplanInsideSlotsMerge(existing, mergedSurvey)) {
return@forEach
}
val overlapsInTime = intersectsInTime( val overlapsInTime = intersectsInTime(
existing.time, existing.time,
existing.timeStop, existing.timeStop,
@@ -377,6 +380,21 @@ class SurveyPlacementService {
return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS 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( private fun intersectsInTime(
start1: LocalDateTime, start1: LocalDateTime,
stop1: LocalDateTime, stop1: LocalDateTime,
@@ -121,6 +121,39 @@ class SurveyPlacementServiceTest {
assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON")) 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 @Test
fun `different roll conflict resolved by trimming`() { fun `different roll conflict resolved by trimming`() {
val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0)) 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="" 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="" ENV JAVA_OPTS=""
@@ -7,7 +7,7 @@ spring:
import: "configserver:" import: "configserver:"
cloud: cloud:
config: 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} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master} label: ${SPRING_CLOUD_CONFIG_LABEL:master}
@@ -15,7 +15,7 @@ spring:
# max-in-memory-size: 16MB # max-in-memory-size: 16MB
datasource: datasource:
driver-class-name: org.postgresql.Driver 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 username: postgres
password: password password: password
jpa: 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="" 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="" 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="" ENV JAVA_OPTS=""
@@ -7,7 +7,7 @@ plugins {
kotlin("plugin.lombok") kotlin("plugin.lombok")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco 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" ENV JAVA_OPTS="-jar"
@@ -5,7 +5,7 @@ plugins {
kotlin("plugin.spring") kotlin("plugin.spring")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco 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="" ENV JAVA_OPTS=""
+1 -10
View File
@@ -7,7 +7,7 @@ plugins {
kotlin("plugin.lombok") kotlin("plugin.lombok")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco jacoco
} }
@@ -94,12 +94,3 @@ tasks.jacocoTestReport {
csv.required = false 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="" 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="" ENV JAVA_OPTS=""
+1 -1
View File
@@ -6,7 +6,7 @@ plugins {
kotlin("plugin.lombok") kotlin("plugin.lombok")
id("org.springframework.boot") id("org.springframework.boot")
id("io.spring.dependency-management") id("io.spring.dependency-management")
id("org.sonarqube") //id("org.sonarqube")
jacoco 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") @GetMapping("/satellites/pdcm")
fun satellitesPdcmPage(): String = "satellites-pdcm" fun satellitesPdcmPage(): String = "satellites-pdcm"
@GetMapping("/tle-comparison")
fun tleComparisonPage(): String = "tle-comparison"
@GetMapping("/current-plans") @GetMapping("/current-plans")
fun currentPlansPage(): String = "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 package space.nstart.pcp.slots_service.controller
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping 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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController 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.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.requests.slots.SlotCalculationSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO 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.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO 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.GroupStateService
import space.nstart.pcp.slots_service.service.BallisticsService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SatellitePdcmService import space.nstart.pcp.slots_service.service.SatellitePdcmService
import space.nstart.pcp.slots_service.service.SlotService import space.nstart.pcp.slots_service.service.SlotService
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
@RestController @RestController
@RequestMapping("/api/catalog") @RequestMapping("/api/catalog")
@@ -31,8 +38,11 @@ class CatalogRestController(
private val satelliteCatalogService: SatelliteCatalogService, private val satelliteCatalogService: SatelliteCatalogService,
private val slotService: SlotService, private val slotService: SlotService,
private val groupStateService: GroupStateService, 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") @GetMapping("/satellites")
fun allSatellites() = satelliteCatalogService.allSatellites() fun allSatellites() = satelliteCatalogService.allSatellites()
@@ -56,6 +66,56 @@ class CatalogRestController(
@GetMapping("/satellites/pdcm/{satelliteId}") @GetMapping("/satellites/pdcm/{satelliteId}")
fun satellitePdcm(@PathVariable satelliteId: Long) = satellitePdcmService.satelliteDetails(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") @PostMapping("/satellites")
fun createSatellite(@Valid @RequestBody request: SatelliteCreateDTO) = fun createSatellite(@Valid @RequestBody request: SatelliteCreateDTO) =
satelliteCatalogService.createSatellite(request) 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.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.slots_service.service.MissionService import space.nstart.pcp.slots_service.service.MissionService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SlotService
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/current-plans") @RequestMapping("/api/current-plans")
class CurrentPlansController( class CurrentPlansController(
private val satelliteCatalogService: SatelliteCatalogService, private val satelliteCatalogService: SatelliteCatalogService,
private val missionService: MissionService private val missionService: MissionService,
private val slotService: SlotService
) { ) {
@GetMapping("/satellites") @GetMapping("/satellites")
@@ -36,6 +38,9 @@ class CurrentPlansController(
@GetMapping("/missions/{missionId}/modes") @GetMapping("/missions/{missionId}/modes")
fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId) 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") @PostMapping("/missions/{missionId}/surveys/calculate")
fun calculateSurveys( fun calculateSurveys(
@PathVariable missionId: UUID, @PathVariable missionId: UUID,
@@ -44,4 +49,7 @@ class CurrentPlansController(
@PostMapping("/missions/{missionId}/drops/calculate") @PostMapping("/missions/{missionId}/drops/calculate")
fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId) 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, @PathVariable duration: Long,
@RequestBody stations: List<StationDTO> @RequestBody stations: List<StationDTO>
) = slotService.rvaMergeOnRev(satelliteId, duration, stations) ) = 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}") @PostMapping("/station/{station_id}")
fun sat(@PathVariable("station_id") id : String, @RequestParam view : Boolean) = fun sat(
czmlService.station(id, view) @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.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO 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.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.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 space.nstart.pcp.slots_service.configuration.CustomErrorException
import java.net.URI import java.net.URI
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -68,6 +72,49 @@ class BallisticsService (webClientBuilderProvider: ObjectProvider<WebClient.Buil
?.firstOrNull() ?.firstOrNull()
?: throw CustomErrorException("Не удалось получить положение спутника $id на время $time") ?: 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( private fun withTimeInterval(
path: String, path: String,
timeStart: LocalDateTime?, timeStart: LocalDateTime?,
@@ -50,6 +50,7 @@ class CZMLService {
private val docStationName = "station_" private val docStationName = "station_"
private val entityStationName = "station_" private val entityStationName = "station_"
private val docReqCovName ="req_cov_" private val docReqCovName ="req_cov_"
private val DEFAULT_STATION_ORBIT_HEIGHT_KM = 500.0
@Autowired @Autowired
private lateinit var stationService: StationService 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 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")) val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docStationName+id , "1.1"))
r.add( r.add(
@@ -401,7 +403,7 @@ private val docReqCovName ="req_cov_"
extrudedHeight = 1000.0, extrudedHeight = 1000.0,
height = 1000.0 height = 1000.0
), ),
description = "Станция ${req.name}", description = "Станция ${req.name}<br>Высота орбиты для зоны видимости: ${normalizedOrbitHeightKm} км",
model = CZMLModel( model = CZMLModel(
gltf = "/model/station.glb", gltf = "/model/station.glb",
show = !view show = !view
@@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>
.block() .block()
} }
fun confirmMission(missionId: UUID) {
webClientBuilder.build()
.post()
.uri("$url/api/missions/$missionId/confirm")
.retrieve()
.toBodilessEntity()
.block()
}
fun modesByMission(missionId: UUID): List<ModeResponseDTO> = fun modesByMission(missionId: UUID): List<ModeResponseDTO> =
webClientBuilder.build() webClientBuilder.build()
.get() .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 space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import space.nstart.pcp.slots_service.configuration.CustomErrorException import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @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> = fun bookReq(req: BookingRequestDTO): List<BookedSlotDTO> =
webClientBuilder.build() webClientBuilder.build()
.post() .post()
@@ -244,6 +324,27 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
} }
.block() .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( fun saveSatelliteInitialConditions(
satelliteId: Long, satelliteId: Long,
request: InitialConditionsDTO, 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; 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 { .satellite-pdcm-status {
display: inline-flex; display: inline-flex;
width: 0.75rem; 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: [], filteredSatellites: [],
missions: [], missions: [],
currentModes: [], currentModes: [],
bookedSlotsById: {},
selectedSatelliteId: null, selectedSatelliteId: null,
selectedMissionId: null, selectedMissionId: null,
selectedMission: null, selectedMission: null,
@@ -168,6 +169,10 @@
class="btn btn-outline-success btn-sm current-plans-action-btn" class="btn btn-outline-success btn-sm current-plans-action-btn"
data-action="calculate-drops" data-action="calculate-drops"
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов</button> 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> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -184,6 +189,8 @@
calculateSurveys(missionId, button); calculateSurveys(missionId, button);
} else if (button.dataset.action === 'calculate-drops') { } else if (button.dataset.action === 'calculate-drops') {
calculateDrops(missionId, button); calculateDrops(missionId, button);
} else if (button.dataset.action === 'confirm-mission') {
confirmMission(missionId, button);
} }
}); });
}); });
@@ -208,6 +215,7 @@
try { try {
const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`); const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
state.currentModes = Array.isArray(modes) ? modes : []; state.currentModes = Array.isArray(modes) ? modes : [];
state.bookedSlotsById = await loadBookedSlotsForModes(state.currentModes);
renderModes(state.currentModes); renderModes(state.currentModes);
setExportEnabled(true); setExportEnabled(true);
return state.currentModes; 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) { function renderModes(modes) {
const tbody = el('current-plans-modes-body'); const tbody = el('current-plans-modes-body');
if (!tbody) return; if (!tbody) return;
@@ -227,35 +257,158 @@
return; return;
} }
tbody.innerHTML = modes.map((mode) => { tbody.innerHTML = modes.flatMap((mode) => {
const type = mode.type || '—'; const type = mode.type || '—';
const duration = formatNumber(mode.duration); const duration = formatNumber(mode.duration);
const rowClass = modeRowClass(mode); const rowClass = modeRowClass(mode);
const badgeClass = modeBadgeClass(mode); const badgeClass = modeBadgeClass(mode);
return ` const expandable = isExpandableMode(mode);
<tr class="${rowClass}"> const expandMarker = expandable ? '<span class="current-plans-expand-marker">▸</span>' : '';
<td><span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td> 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>${formatDateTime(mode.timeStart)}</td>
<td>${escapeHtml(mode.revolution ?? '—')}</td> <td>${escapeHtml(mode.revolution ?? '—')}</td>
<td>${duration}</td> <td>${duration}</td>
<td class="current-plans-mode-params">${modeParams(mode)}</td> <td class="current-plans-mode-params">${modeParams(mode)}</td>
<td>${modeStatus(mode)}</td> <td>${modeStatus(mode)}</td>
</tr>`; </tr>`;
const details = expandable ? modeDetailsRow(mode) : null;
return details ? [row, details] : [row];
}).join(''); }).join('');
tbody.querySelectorAll('.current-plans-expandable-row').forEach((row) => {
row.addEventListener('click', () => toggleModeDetails(row.dataset.modeId));
});
} }
function modeRowClass(mode) { function modeRowClass(mode) {
if (mode.type === 'DROP') return 'current-plans-mode-row-drop'; 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 ''; return '';
} }
function modeBadgeClass(mode) { function modeBadgeClass(mode) {
if (mode.type === 'DROP') return 'current-plans-badge-drop'; 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 ''; 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) { function modeParams(mode) {
if (mode.type === 'DROP') { if (mode.type === 'DROP') {
const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0; const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0;
@@ -276,7 +429,7 @@
if (mode.type === 'DROP') { if (mode.type === 'DROP') {
return '<span class="current-plans-badge current-plans-badge-drop">DROP</span>'; 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 ` return `
<div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div> <div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div>
<div><span class="current-plans-badge${sourceBadgeClass}">${escapeHtml(mode.source || '—')}</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() { function exportSelectedMissionCsv() {
if (!state.selectedMissionId) { if (!state.selectedMissionId) {
showAlert('Выберите миссию для сохранения CSV.', 'warning'); showAlert('Выберите миссию для сохранения CSV.', 'warning');
@@ -410,7 +581,7 @@
mode.type || '', mode.type || '',
mode.id ?? '', mode.id ?? '',
mode.planId ?? '', mode.planId ?? '',
mode.timeStart || '', formatDateTime(mode.timeStart),
mode.revolution ?? '', mode.revolution ?? '',
mode.duration ?? '', mode.duration ?? '',
mode.status || '', mode.status || '',
@@ -469,6 +640,7 @@
el('current-plans-modes-body').innerHTML = ''; el('current-plans-modes-body').innerHTML = '';
el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.'; el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.';
state.currentModes = []; state.currentModes = [];
state.bookedSlotsById = {};
setExportEnabled(false); setExportEnabled(false);
} }
@@ -570,6 +742,12 @@
function formatDateTime(value) { function formatDateTime(value) {
if (!value) return '—'; 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); const date = new Date(value);
if (Number.isNaN(date.getTime())) return escapeHtml(value); if (Number.isNaN(date.getTime())) return escapeHtml(value);
return date.toLocaleString('ru-RU', { return date.toLocaleString('ru-RU', {
@@ -588,6 +766,21 @@
return number.toLocaleString('ru-RU', {maximumFractionDigits: 2}); 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) { function toDateTimeLocalValue(date) {
const pad = (number) => String(number).padStart(2, '0'); 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())}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -652,4 +845,11 @@
.replaceAll('"', '&quot;') .replaceAll('"', '&quot;')
.replaceAll("'", '&#039;'); .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(); checkModels();
} else{ } else{
var sceneMode = (viewer.scene.mode == Cesium.SceneMode.SCENE2D); 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, { fetch(u, {
method: "post", method: "post",
headers: { 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){ function drawSat(show, req, startInput, endInput, runId){
if (req){ if (req){
@@ -30,6 +30,17 @@
return date.toLocaleString('ru-RU'); 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() { function selectedMode() {
return document.querySelector('input[name="rva-mode"]:checked')?.value || 'common'; return document.querySelector('input[name="rva-mode"]:checked')?.value || 'common';
} }
@@ -113,7 +124,7 @@
<td>${row.durationSec}</td> <td>${row.durationSec}</td>
</tr> </tr>
`).join(''); `).join('');
} else { } else if (resultState.mode === 'merge-on-rev') {
head.innerHTML = ` head.innerHTML = `
<tr> <tr>
<th>Виток</th> <th>Виток</th>
@@ -123,7 +134,24 @@
body.innerHTML = resultState.rows.map(row => ` body.innerHTML = resultState.rows.map(row => `
<tr> <tr>
<td>${row.revolution}</td> <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> </tr>
`).join(''); `).join('');
} }
@@ -145,8 +173,9 @@
return; return;
} }
const lines = resultState.mode === 'common' let lines;
? [ if (resultState.mode === 'common') {
lines = [
['stationId', 'revolution', 'onStart', 'onMaximum', 'onStop', 'duration'], ['stationId', 'revolution', 'onStart', 'onMaximum', 'onStop', 'duration'],
...resultState.rows.map(row => [ ...resultState.rows.map(row => [
row.stationId, row.stationId,
@@ -156,23 +185,35 @@
row.onStop.time, row.onStop.time,
row.duration row.duration
]) ])
] ];
: resultState.mode === 'merge' } else if (resultState.mode === 'merge') {
? [ lines = [
['begin', 'end', 'durationSec'], ['begin', 'end', 'durationSec'],
...resultState.rows.map(row => [ ...resultState.rows.map(row => [
row.begin, row.begin,
row.end, row.end,
row.durationSec row.durationSec
]) ])
] ];
: [ } else if (resultState.mode === 'merge-on-rev') {
lines = [
['revolution', 'durationSec'], ['revolution', 'durationSec'],
...resultState.rows.map(row => [ ...resultState.rows.map(row => [
row.revolution, row.revolution,
row.durationSec 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 csv = lines.map(line => line.map(csvEscape).join(';')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
@@ -10,6 +10,7 @@
selectedSatelliteId: null, selectedSatelliteId: null,
details: null details: null
}; };
const sendInitialConditionsDateTimePattern = /^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/;
function showAlert(message, type = 'danger') { function showAlert(message, type = 'danger') {
const container = document.getElementById('satellite-pdcm-alert'); const container = document.getElementById('satellite-pdcm-alert');
@@ -58,6 +59,121 @@
return value ? value.replace('T', ' ') : '-'; 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) { function formatNumber(value) {
return Number(value ?? 0).toFixed(3); return Number(value ?? 0).toFixed(3);
} }
@@ -71,6 +187,39 @@
exportButton.disabled = !details || !details.ascNodes || !details.ascNodes.length; 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() { function renderList() {
const tableBody = document.getElementById('satellite-pdcm-list'); const tableBody = document.getElementById('satellite-pdcm-list');
tableBody.innerHTML = ''; tableBody.innerHTML = '';
@@ -110,6 +259,8 @@
if (!details) { if (!details) {
summary.innerHTML = '<div class="catalog-helper">Выберите спутник слева.</div>'; summary.innerHTML = '<div class="catalog-helper">Выберите спутник слева.</div>';
renderExportState(null); renderExportState(null);
renderSendInitialConditionsState(null);
renderSetInitialConditionsState(null);
return; return;
} }
@@ -140,6 +291,8 @@
</div> </div>
`; `;
renderExportState(details); renderExportState(details);
renderSendInitialConditionsState(details);
renderSetInitialConditionsState(details);
} }
function renderTable(details) { function renderTable(details) {
@@ -270,9 +423,149 @@
URL.revokeObjectURL(url); 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) { async function selectSatellite(satelliteId) {
state.selectedSatelliteId = satelliteId; state.selectedSatelliteId = satelliteId;
state.details = null;
renderList(); renderList();
renderSendInitialConditionsState(null);
renderSetInitialConditionsState(null);
showAlert(''); showAlert('');
try { try {
@@ -284,6 +577,7 @@
state.details = null; state.details = null;
renderSummary(null); renderSummary(null);
renderTable(null); renderTable(null);
renderSetInitialConditionsState(null);
showAlert(error.message || 'Не удалось загрузить ПДЦМ спутника'); showAlert(error.message || 'Не удалось загрузить ПДЦМ спутника');
} }
} }
@@ -299,12 +593,14 @@
if (nextSatelliteId) { if (nextSatelliteId) {
await selectSatellite(nextSatelliteId); await selectSatellite(nextSatelliteId);
} else { } else {
state.selectedSatelliteId = null;
state.details = null; state.details = null;
renderSummary(null); renderSummary(null);
renderTable(null); renderTable(null);
} }
} catch (error) { } catch (error) {
state.satellites = []; state.satellites = [];
state.selectedSatelliteId = null;
state.details = null; state.details = null;
renderList(); renderList();
renderSummary(null); renderSummary(null);
@@ -317,6 +613,12 @@
loadSatellites(); loadSatellites();
}); });
document.getElementById('satellite-pdcm-export').addEventListener('click', exportCurrentCsv); 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(); 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"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="/satellites">Управление</a></li> <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="/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> </ul>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
@@ -74,6 +76,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/tgu-planning">Планирование ТГУ</a> <a class="nav-link" href="/tgu-planning">Планирование ТГУ</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="/bookings">Бронирования</a>
</li>
</ul> </ul>
</div> </div>
@@ -292,6 +292,16 @@
<input class="form-check-input" type="checkbox" id="toggleCoverage"> <input class="form-check-input" type="checkbox" id="toggleCoverage">
<label class="form-check-label" for="toggleCoverage">Показать зоны покрытия</label> <label class="form-check-label" for="toggleCoverage">Показать зоны покрытия</label>
</div> </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"> <select class="form-select form-select-sm mb-2">
<option selected>Все типы станций</option> <option selected>Все типы станций</option>
<option value="1">Наземные</option> <option value="1">Наземные</option>
@@ -1712,6 +1722,13 @@
} }
} }
function handleStationOrbitHeightChange() {
document.querySelectorAll('#stationsTableBody input[type="checkbox"]:checked').forEach(function(checkbox) {
checkStation(false, checkbox.value);
checkStation(true, checkbox.value);
});
}
// Функция для обработки запросов // Функция для обработки запросов
function checkOrbit(show, sat_id) { function checkOrbit(show, sat_id) {
@@ -56,6 +56,10 @@
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-merge-on-rev" value="merge-on-rev"> <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> <label class="form-check-label" for="rva-mode-merge-on-rev">Суммарная длительность на витке</label>
</div> </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>
</div> </div>
@@ -15,8 +15,23 @@
<h2 class="mb-1">Параметры движения центра масс</h2> <h2 class="mb-1">Параметры движения центра масс</h2>
<div class="catalog-helper">Табличное представление asc-node по выбранному спутнику.</div> <div class="catalog-helper">Табличное представление asc-node по выбранному спутнику.</div>
</div> </div>
<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> <button id="satellite-pdcm-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
</div> </div>
</div>
<div id="satellite-pdcm-alert"></div> <div id="satellite-pdcm-alert"></div>
@@ -66,6 +81,84 @@
</div> </div>
</div> </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> </div>
<script src="/satellite_pdcm_scripts.js"></script> <script src="/satellite_pdcm_scripts.js"></script>
</th:block> </th:block>
@@ -0,0 +1,100 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.w3.org/1999/xhtml"
layout:decorate="~{layouts/main-layout}">
<head>
<title>Анализ TLE</title>
<link rel="stylesheet" href="/css/catalog.css"/>
<link rel="stylesheet" href="/css/tle-analysis.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="tle-analysis-page" class="catalog-page tle-analysis-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
<div>
<h2 class="mb-1">Анализ TLE</h2>
<div class="catalog-helper">Анализ изменения TLE выбранного спутника по разнице рассчитанных радиус-векторов.</div>
</div>
<div class="d-flex align-items-end gap-2 flex-wrap">
<div>
<label for="tle-analysis-count" class="form-label mb-1 small text-muted">Количество TLE</label>
<input id="tle-analysis-count"
type="number"
class="form-control form-control-sm"
min="1"
step="1"
value="5">
</div>
<button id="tle-analysis-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
</div>
</div>
<div id="tle-analysis-alert"></div>
<div class="row g-3 tle-analysis-layout">
<div class="col-12 col-xl-4">
<div class="card catalog-card tle-analysis-sidebar h-100">
<div class="card-header">
<div class="catalog-section-title">Спутники</div>
<div class="catalog-helper mt-1">Выберите КА для формирования аналитической таблицы TLE.</div>
</div>
<div class="card-body p-2 tle-analysis-filter-body">
<label for="tle-analysis-satellite-filter" class="form-label mb-1 small text-muted">Фильтр</label>
<input id="tle-analysis-satellite-filter"
type="search"
class="form-control form-control-sm"
autocomplete="off"
placeholder="ID, NORAD, код, имя, тип">
</div>
<div class="card-body p-0 tle-analysis-satellites-wrap">
<table class="table table-hover mb-0 tle-analysis-satellites-table">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Спутник</th>
<th>NORAD</th>
</tr>
</thead>
<tbody id="tle-analysis-satellites-body"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card catalog-card tle-analysis-results-card h-100">
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="catalog-section-title">Таблица анализа TLE</div>
<div id="tle-analysis-selected-satellite" class="catalog-helper mt-1">Выберите спутник слева.</div>
</div>
<div id="tle-analysis-summary" class="catalog-helper text-end"></div>
</div>
<div class="card-body p-0 tle-analysis-results-wrap">
<div id="tle-analysis-empty" class="catalog-empty">Выберите спутник для загрузки TLE.</div>
<div id="tle-analysis-table-wrap" class="table-responsive d-none">
<table class="table table-hover table-sm mb-0 tle-analysis-results-table">
<thead class="table-light">
<tr>
<th>#</th>
<th>Эпоха</th>
<th>Виток</th>
<th>NORAD</th>
<th>Радиус-вектор, м</th>
<th>|r|, м</th>
<th>Разница Времени, ч</th>
<th>Расхождение, м</th>
</tr>
</thead>
<tbody id="tle-analysis-results-body"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/tle_analysis_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,127 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.w3.org/1999/xhtml"
layout:decorate="~{layouts/main-layout}">
<head>
<title>Сравнение TLE</title>
<link rel="stylesheet" href="/css/catalog.css"/>
<link rel="stylesheet" href="/css/tle-comparison.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="tle-comparison-page" class="catalog-page tle-comparison-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Сравнение TLE</h2>
<div class="catalog-helper">Вставьте два набора TLE, проверьте эпохи и рассчитайте положение спутника на заданный момент времени.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button id="tle-compare-submit" type="button" class="btn btn-primary">Рассчитать</button>
<button id="tle-compare-clear" type="button" class="btn btn-outline-secondary">Очистить</button>
</div>
</div>
<div id="tle-comparison-alert"></div>
<div class="card catalog-card tle-input-card mb-4">
<div class="card-header">
<div class="catalog-section-title">Ввод данных</div>
<div class="catalog-helper mt-1">Можно вставить две или три строки TLE. Заголовок определяется автоматически, строки 1 и 2 обязательны. Время расчета должно быть не раньше максимальной эпохи двух TLE.</div>
</div>
<div class="card-body">
<div class="row g-3 mb-4">
<div class="col-12 col-md-5 col-xl-4">
<label for="tle-comparison-time" class="form-label">Время расчета</label>
<input id="tle-comparison-time" type="datetime-local" class="form-control" step="1"/>
</div>
<div class="col-12 col-md-7 col-xl-8 d-flex align-items-end">
<div id="tle-comparison-time-helper" class="catalog-helper">Минимальное допустимое время появится после разбора двух TLE.</div>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="tle-input-widget h-100">
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
<label for="tle-first-text" class="form-label mb-0">TLE 1</label>
<span id="tle-first-status" class="tle-parse-status text-muted">Нет данных</span>
</div>
<textarea id="tle-first-text"
class="form-control tle-textarea"
spellcheck="false"
placeholder="ISS (ZARYA)&#10;1 25544U 98067A ...&#10;2 25544 ..."></textarea>
<div class="tle-reference mt-3">
<div class="tle-reference-row">
<span>Эпоха TLE</span>
<strong id="tle-first-epoch"></strong>
</div>
<div class="tle-reference-row">
<span>NORAD</span>
<strong id="tle-first-norad"></strong>
</div>
<div class="tle-reference-row">
<span>Виток</span>
<strong id="tle-first-revolution"></strong>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="tle-input-widget h-100">
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
<label for="tle-second-text" class="form-label mb-0">TLE 2</label>
<span id="tle-second-status" class="tle-parse-status text-muted">Нет данных</span>
</div>
<textarea id="tle-second-text"
class="form-control tle-textarea"
spellcheck="false"
placeholder="ISS (ZARYA)&#10;1 25544U 98067A ...&#10;2 25544 ..."></textarea>
<div class="tle-reference mt-3">
<div class="tle-reference-row">
<span>Эпоха TLE</span>
<strong id="tle-second-epoch"></strong>
</div>
<div class="tle-reference-row">
<span>NORAD</span>
<strong id="tle-second-norad"></strong>
</div>
<div class="tle-reference-row">
<span>Виток</span>
<strong id="tle-second-revolution"></strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card catalog-card tle-result-card">
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="catalog-section-title">Результаты</div>
<div id="tle-comparison-result-meta" class="catalog-helper mt-1">Задайте время не раньше максимальной эпохи TLE и нажмите “Сравнить”.</div>
</div>
</div>
<div class="card-body p-0">
<div id="tle-comparison-empty" class="catalog-empty">Параметры положения и разность радиус-векторов появятся здесь.</div>
<div id="tle-comparison-table-wrap" class="table-responsive tle-result-table-wrap d-none">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Параметр</th>
<th>TLE 1</th>
<th>TLE 2</th>
<th>Разница / значение</th>
</tr>
</thead>
<tbody id="tle-comparison-result-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<script src="/tle_comparison_scripts.js"></script>
</th:block>
</body>
</html>
@@ -1,10 +1,12 @@
package space.nstart.pcp.slots_service.controller package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.eq import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.doReturn import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doThrow import org.mockito.Mockito.doThrow
import org.mockito.Mockito.never
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.boot.test.web.server.LocalServerPort
@@ -14,6 +16,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO 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.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
@@ -34,6 +37,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
import space.nstart.pcp.slots_service.service.BallisticsService
import space.nstart.pcp.slots_service.service.GroupStateService import space.nstart.pcp.slots_service.service.GroupStateService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SatellitePdcmService import space.nstart.pcp.slots_service.service.SatellitePdcmService
@@ -77,6 +81,9 @@ class CatalogControllerTest {
@MockitoBean @MockitoBean
private lateinit var tguPlanningService: TguPlanningService private lateinit var tguPlanningService: TguPlanningService
@MockitoBean
private lateinit var ballisticsService: BallisticsService
@Test @Test
fun `groups page is rendered`() { fun `groups page is rendered`() {
val body = WebClient.create("http://localhost:$port") val body = WebClient.create("http://localhost:$port")
@@ -139,9 +146,31 @@ class CatalogControllerTest {
assertTrue(body.contains("Asc-node")) assertTrue(body.contains("Asc-node"))
assertTrue(body.contains("Спутники")) assertTrue(body.contains("Спутники"))
assertTrue(body.contains("Скачать CSV")) assertTrue(body.contains("Скачать CSV"))
assertTrue(body.contains("satellite-pdcm-send-ic-time"))
assertTrue(body.contains("Задать НУ"))
assertTrue(body.contains("satellite-pdcm-set-ic-modal"))
assertTrue(body.contains("value=\"FOTO\" selected"))
assertTrue(body.contains("dd.mm.yyyy hh:MM:ss.zzz"))
assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css")) assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css"))
} }
@Test
fun `tle analysis page is rendered with page script`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/tle-analysis")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Анализ TLE"))
assertTrue(body.contains("tle-analysis-satellites-body"))
assertTrue(body.contains("tle-analysis-count"))
assertTrue(body.contains("Разница Времени, ч"))
assertTrue(body.contains("value=\"5\""))
assertTrue(body.contains("/tle_analysis_scripts.js"))
}
@Test @Test
fun `stations page is rendered`() { fun `stations page is rendered`() {
val body = WebClient.create("http://localhost:$port") val body = WebClient.create("http://localhost:$port")
@@ -334,6 +363,114 @@ class CatalogControllerTest {
verify(satellitePdcmService).satelliteSummaries() verify(satellitePdcmService).satelliteSummaries()
} }
@Test
fun `satellite pdcm send initial conditions endpoint parses ui time and proxies request`() {
val requestedTime = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000)
val status = WebClient.create("http://localhost:$port")
.post()
.uri { builder ->
builder
.path("/api/catalog/satellites/pdcm/501/send-ic")
.queryParam("time", "24.04.2026 10:15:30.123")
.build()
}
.exchangeToMono { response -> Mono.just(response.statusCode()) }
.block()!!
assertEquals(HttpStatus.ACCEPTED, status)
verify(slotService).sendSatelliteInitialConditions(501L, requestedTime)
}
@Test
fun `satellite pdcm send initial conditions endpoint rejects invalid ui time`() {
val status = WebClient.create("http://localhost:$port")
.post()
.uri { builder ->
builder
.path("/api/catalog/satellites/pdcm/501/send-ic")
.queryParam("time", "2026-04-24T10:15:30")
.build()
}
.exchangeToMono { response -> Mono.just(response.statusCode()) }
.block()!!
assertEquals(HttpStatus.BAD_REQUEST, status)
verify(slotService, never()).sendSatelliteInitialConditions(eq(501L), anyLocalDateTime())
}
@Test
fun `satellite pdcm set initial conditions endpoint proxies request to ballistics`() {
val request = SatelliteICDTO(
satelliteId = 999L,
movementModel = MovementModel.KONDOR,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000),
revolution = 42L,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
),
sBall = 0.07,
f81 = 145.2
)
)
val status = WebClient.create("http://localhost:$port")
.post()
.uri("/api/catalog/satellites/pdcm/501/initial-conditions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.exchangeToMono { response -> Mono.just(response.statusCode()) }
.block()!!
val requestCaptor = ArgumentCaptor.forClass(SatelliteICDTO::class.java)
assertEquals(HttpStatus.ACCEPTED, status)
verify(ballisticsService).receiveRv(captureSatelliteIc(requestCaptor))
assertEquals(501L, requestCaptor.value.satelliteId)
assertEquals(MovementModel.KONDOR, requestCaptor.value.movementModel)
assertEquals(42L, requestCaptor.value.ic.orbPoint.revolution)
}
@Test
fun `satellite pdcm current initial conditions endpoint proxies request to ballistics`() {
val response = SatelliteICDTO(
satelliteId = 501L,
movementModel = MovementModel.KONDOR,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000),
revolution = 42L,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
),
sBall = 0.07,
f81 = 145.2
)
)
doReturn(response).`when`(ballisticsService).currentInitialConditions(501L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/pdcm/501/initial-conditions")
.retrieve()
.bodyToMono(SatelliteICDTO::class.java)
.block()!!
assertEquals(501L, actual.satelliteId)
assertEquals(MovementModel.KONDOR, actual.movementModel)
assertEquals(42L, actual.ic.orbPoint.revolution)
verify(ballisticsService).currentInitialConditions(501L)
}
@Test @Test
fun `satellite create endpoint proxies request`() { fun `satellite create endpoint proxies request`() {
val request = SatelliteCreateDTO( val request = SatelliteCreateDTO(
@@ -568,4 +705,7 @@ class CatalogControllerTest {
private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO() private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO()
private fun anyInitialConditions(): InitialConditionsDTO = any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO() private fun anyInitialConditions(): InitialConditionsDTO = any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO()
private fun anySlotProfile(): SatelliteSlotProfileDTO = any(SatelliteSlotProfileDTO::class.java) ?: SatelliteSlotProfileDTO() private fun anySlotProfile(): SatelliteSlotProfileDTO = any(SatelliteSlotProfileDTO::class.java) ?: SatelliteSlotProfileDTO()
private fun anyLocalDateTime(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN
private fun captureSatelliteIc(captor: ArgumentCaptor<SatelliteICDTO>): SatelliteICDTO =
captor.capture() ?: SatelliteICDTO()
} }

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