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:
@@ -124,6 +124,9 @@ default:
|
||||
tags:
|
||||
- nstart
|
||||
|
||||
variables:
|
||||
INTERNAL_REGISTRY: "$CI_REGISTRY_IMAGE"
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
@@ -14,6 +14,8 @@ variables:
|
||||
- libs/pcp-types-lib/**/*
|
||||
- build.gradle.kts
|
||||
- settings.gradle.kts
|
||||
- gradle.properties
|
||||
- gradle/**/*
|
||||
- .gitlab/ci/pcp-request-service.yml
|
||||
- helm/pcp-request-service/**/*
|
||||
- deploy/**/*
|
||||
|
||||
+13
-7
@@ -8,7 +8,7 @@ plugins {
|
||||
kotlin("plugin.lombok") apply false
|
||||
id("org.springframework.boot") apply false
|
||||
id("io.spring.dependency-management") apply false
|
||||
id("org.sonarqube") apply false
|
||||
//id("org.sonarqube") apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,18 @@ allprojects {
|
||||
group = "space.nstart.pcp"
|
||||
ext.set("kotlinVersion", "2.2.20")
|
||||
repositories {
|
||||
// maven {
|
||||
// url = uri("https://repo.nstart.cloud/repository/maven-proxy/")
|
||||
// }
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
gradlePluginPortal()
|
||||
maven {
|
||||
url = uri("https://repo.nstart.cloud/repository/maven-proxy/")
|
||||
}
|
||||
mavenCentral {
|
||||
content {
|
||||
includeGroup("org.jacoco")
|
||||
includeGroup("org.ow2.asm")
|
||||
}
|
||||
}
|
||||
// mavenCentral()
|
||||
// mavenLocal()
|
||||
// gradlePluginPortal()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
|
||||
exclusiveContent {
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
pcp:
|
||||
infra:
|
||||
postgres:
|
||||
host: ${PCP_POSTGRES_HOST:192.168.1.8}
|
||||
port: ${PCP_POSTGRES_PORT:5432}
|
||||
# host: ${PCP_POSTGRES_HOST:localhost}
|
||||
# port: ${PCP_POSTGRES_PORT:5432}
|
||||
host: ${PCP_POSTGRES_HOST:192.168.100.160}
|
||||
port: ${PCP_POSTGRES_PORT:35400}
|
||||
kafka:
|
||||
host: ${PCP_KAFKA_HOST:192.168.1.8}
|
||||
host: ${PCP_KAFKA_HOST:localhost}
|
||||
port: ${PCP_KAFKA_PORT:29092}
|
||||
|
||||
|
||||
|
||||
network:
|
||||
host: ${PCP_NETWORK_HOST:192.168.1.8}
|
||||
host: ${PCP_NETWORK_HOST:localhost}
|
||||
|
||||
ports:
|
||||
config: 8888
|
||||
config: 38888
|
||||
admin: 7000
|
||||
route-processing: 7004
|
||||
request: 7005
|
||||
|
||||
@@ -49,9 +49,12 @@ spring:
|
||||
default-topic: pcp.tle
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_ballistics
|
||||
username: postgres
|
||||
password: password
|
||||
url: "${SPRING_DATASOURCE_URL}"
|
||||
username: "${SPRING_DATASOURCE_USERNAME}"
|
||||
password: "${SPRING_DATASOURCE_PASSWORD}"
|
||||
# url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_ballistics
|
||||
# username: postgres
|
||||
# password: password
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
@@ -96,9 +96,9 @@ spring:
|
||||
on-profile: dev
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_satellites
|
||||
username: postgres
|
||||
password: password
|
||||
url: "${SPRING_DATASOURCE_URL}"
|
||||
username: "${SPRING_DATASOURCE_USERNAME}"
|
||||
password: "${SPRING_DATASOURCE_PASSWORD}"
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
@@ -75,7 +75,7 @@ spring:
|
||||
camunda:
|
||||
client:
|
||||
mode: self-managed
|
||||
grpc-address: http://192.168.1.8:26500
|
||||
grpc-address: http://camunda.k8s.265.nstart.cloud:30901
|
||||
auth:
|
||||
method: none
|
||||
prefer-rest-over-grpc: false
|
||||
@@ -115,9 +115,9 @@ spring:
|
||||
missing-topics-fatal: false
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_missions
|
||||
username: postgres
|
||||
password: password
|
||||
url: "${SPRING_DATASOURCE_URL}"
|
||||
username: "${SPRING_DATASOURCE_USERNAME}"
|
||||
password: "${SPRING_DATASOURCE_PASSWORD}"
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
@@ -136,7 +136,7 @@ spring:
|
||||
camunda:
|
||||
client:
|
||||
mode: self-managed
|
||||
grpc-address: http://192.168.1.8:26500
|
||||
grpc-address: http://camunda.k8s.265.nstart.cloud:30901
|
||||
auth:
|
||||
method: none
|
||||
prefer-rest-over-grpc: false
|
||||
|
||||
@@ -17,9 +17,9 @@ spring:
|
||||
management-base-url: ${pcp.services.request}
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_requests
|
||||
username: postgres
|
||||
password: password
|
||||
url: "${SPRING_DATASOURCE_URL}"
|
||||
username: "${SPRING_DATASOURCE_USERNAME}"
|
||||
password: "${SPRING_DATASOURCE_PASSWORD}"
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
@@ -68,9 +68,9 @@ spring:
|
||||
on-profile: dev
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_stations
|
||||
username: postgres
|
||||
password: password
|
||||
url: "${SPRING_DATASOURCE_URL}"
|
||||
username: "${SPRING_DATASOURCE_USERNAME}"
|
||||
password: "${SPRING_DATASOURCE_PASSWORD}"
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
@@ -40,6 +40,7 @@ settings:
|
||||
tgu-service: ${pcp.services.tgu}
|
||||
satellite-catalog-service: ${pcp.services.satellite-catalog}
|
||||
ballistics-service: ${pcp.services.ballistics}
|
||||
tle-monitoring-service: ${pcp.services.tle-monitoring}
|
||||
stations-service: ${pcp.services.stations}
|
||||
slots-service: ${pcp.services.slots}
|
||||
mission-service: ${pcp.services.mission}
|
||||
|
||||
@@ -108,9 +108,9 @@ spring:
|
||||
default-topic: pcp.tle
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://${pcp.infra.postgres.host}:${pcp.infra.postgres.port}/pcp_slots
|
||||
username: postgres
|
||||
password: password
|
||||
url: "${SPRING_DATASOURCE_URL}"
|
||||
username: "${SPRING_DATASOURCE_USERNAME}"
|
||||
password: "${SPRING_DATASOURCE_PASSWORD}"
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
Executable
+198
@@ -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
@@ -1,7 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
networkTimeout=60000
|
||||
networkTimeout=300000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -9,3 +9,19 @@ service:
|
||||
ingress:
|
||||
enabled: true
|
||||
host: pcp-ballistics-service.dev.k8s.264.nstart.cloud
|
||||
|
||||
extraEnv:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_ballistics
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-ballistics-db
|
||||
key: SPRING_DATASOURCE_USERNAME
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-ballistics-db
|
||||
key: SPRING_DATASOURCE_PASSWORD
|
||||
@@ -9,3 +9,19 @@ service:
|
||||
ingress:
|
||||
enabled: true
|
||||
host: pcp-complex-mission-service.dev.k8s.264.nstart.cloud
|
||||
|
||||
extraEnv:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_complex_missions
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-complex-missions-db
|
||||
key: SPRING_DATASOURCE_USERNAME
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-complex-missions-db
|
||||
key: SPRING_DATASOURCE_PASSWORD
|
||||
|
||||
@@ -13,3 +13,15 @@ ingress:
|
||||
extraEnv:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_missions
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-missions-db
|
||||
key: SPRING_DATASOURCE_USERNAME
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-missions-db
|
||||
key: SPRING_DATASOURCE_PASSWORD
|
||||
@@ -13,3 +13,15 @@ ingress:
|
||||
extraEnv:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_requests
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-requests-db
|
||||
key: SPRING_DATASOURCE_USERNAME
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-requests-db
|
||||
key: SPRING_DATASOURCE_PASSWORD
|
||||
@@ -13,3 +13,15 @@ ingress:
|
||||
extraEnv:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_stations
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-stations-db
|
||||
key: SPRING_DATASOURCE_USERNAME
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-stations-db
|
||||
key: SPRING_DATASOURCE_PASSWORD
|
||||
@@ -13,3 +13,5 @@ ingress:
|
||||
extraEnv:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SETTINGS_TLE_MONITORING_SERVICE
|
||||
value: "http://tle-monitoring-service:8080"
|
||||
|
||||
@@ -12,3 +12,5 @@ ingress:
|
||||
extraEnv:
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
- name: SETTINGS_TLE_MONITORING_SERVICE
|
||||
value: "http://tle-monitoring-service:8080"
|
||||
|
||||
@@ -23,3 +23,15 @@ extraEnv:
|
||||
value: "10"
|
||||
- name: SPRING_FLYWAY_CONNECT_RETRIES_INTERVAL
|
||||
value: "5s"
|
||||
- name: SPRING_DATASOURCE_URL
|
||||
value: jdbc:postgresql://pcp-postgres.pcp-dev.svc.altum.local:5432/pcp_slots
|
||||
- name: SPRING_DATASOURCE_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-slots-db
|
||||
key: SPRING_DATASOURCE_USERNAME
|
||||
- name: SPRING_DATASOURCE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pcp-slots-db
|
||||
key: SPRING_DATASOURCE_PASSWORD
|
||||
|
||||
@@ -4,7 +4,7 @@ plugins {
|
||||
kotlin("plugin.lombok")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import ballistics.Ballistics
|
||||
import ballistics.types.EarthType
|
||||
import ballistics.types.InitialConditions
|
||||
import ballistics.types.IntegrationType
|
||||
import ballistics.types.ModDVType
|
||||
import ballistics.types.OrbitalPoint
|
||||
import ballistics.utils.astro.AstronomerJ2000
|
||||
import ballistics.utils.fromDateTime
|
||||
import ballistics.utils.math.Vector3D
|
||||
import ballistics.utils.toDateTime
|
||||
@@ -15,106 +17,38 @@ fun main() {
|
||||
var ts = System.currentTimeMillis()
|
||||
val r = Ballistics()
|
||||
|
||||
r.modDVType = ModDVType.BARS // BARS
|
||||
r.integrationType = IntegrationType.ADAMS7
|
||||
r.modDVType = ModDVType.KONDOR // BARS
|
||||
r.integrationType = IntegrationType.RUNG4
|
||||
|
||||
|
||||
|
||||
val tt2 = LocalDateTime.of(2028, 12, 11, 1, 24, 24, 257000000)
|
||||
|
||||
val op = OrbitalPoint(
|
||||
fromDateTime(tt2),
|
||||
1,
|
||||
Vector3D(
|
||||
2610612.004898679,
|
||||
-3507514.947081291,
|
||||
-5291534.374006776,
|
||||
),
|
||||
Vector3D(
|
||||
-4935.515395115775,
|
||||
3497.047795669036,
|
||||
-4742.501356855924,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
val mnu = mutableListOf<InitialConditions>()
|
||||
mnu.add(
|
||||
InitialConditions(
|
||||
OrbitalPoint(
|
||||
LocalDateTime.of(2021, 12, 3, 0, 5, 45, 0).toEpochSecond(ZoneOffset.UTC).toDouble(),
|
||||
1,
|
||||
Vector3D(
|
||||
-5357933.7872,
|
||||
4328646.1787,
|
||||
0.0,
|
||||
),
|
||||
Vector3D(
|
||||
941.8995373,
|
||||
1150.6919822,
|
||||
7544.9920840,
|
||||
),
|
||||
),
|
||||
0.0061,
|
||||
op,
|
||||
0.06 / 9.8,
|
||||
125.0,
|
||||
),
|
||||
)
|
||||
|
||||
/*
|
||||
mnu.add(InitialConditions(OrbitalPoint(1681135417.0,
|
||||
1499,
|
||||
6782429.138044466,
|
||||
-1205586.994919729,
|
||||
0.0,
|
||||
-272.89561674108205,
|
||||
-1460.1016031205972,
|
||||
7539.209413654664),
|
||||
0.0,
|
||||
100.0)
|
||||
)
|
||||
|
||||
|
||||
mnu.add(InitialConditions(OrbitalPoint(1681203547.0,
|
||||
1511,
|
||||
2796871.797932365,
|
||||
6295220.245166648,
|
||||
0.0,
|
||||
1351.432690993002,
|
||||
-615.1950685524682,
|
||||
7539.420044409451),
|
||||
0.0,
|
||||
100.0)
|
||||
)
|
||||
|
||||
mnu.add(InitialConditions(OrbitalPoint(1681288710.0,
|
||||
1526,
|
||||
2219522.8066600505,
|
||||
6520960.574145492,
|
||||
0.0,
|
||||
1401.0525909402313,
|
||||
-491.90052480161194,
|
||||
7539.654233369637),
|
||||
0.0,
|
||||
100.0)
|
||||
)
|
||||
|
||||
mnu.add(InitialConditions(OrbitalPoint(1681373873.0,
|
||||
1541,
|
||||
1624190.3979707498,
|
||||
6693858.330353258,
|
||||
0.0,
|
||||
1439.4621272728175,
|
||||
-364.5959436771924,
|
||||
7539.927000039702),
|
||||
0.0,
|
||||
100.0)
|
||||
)
|
||||
|
||||
mnu.add(InitialConditions(OrbitalPoint(1681464714.0,
|
||||
1557,
|
||||
3663882.4989639986,
|
||||
5832354.67692114,
|
||||
0.0,
|
||||
1249.158953470858,
|
||||
-802.9876366520331,
|
||||
7540.375561571017),
|
||||
0.0,
|
||||
100.0)
|
||||
)
|
||||
|
||||
mnu.add(InitialConditions(OrbitalPoint(1681538522.0,
|
||||
1570,
|
||||
-2393483.7360840607,
|
||||
6458206.838182452,
|
||||
0.0,
|
||||
1398.0568057512764,
|
||||
501.055477122656,
|
||||
7540.680958625912),
|
||||
0.0,
|
||||
100.0)
|
||||
)
|
||||
|
||||
*/
|
||||
|
||||
mnu.sortBy { it.point.t }
|
||||
|
||||
@@ -123,9 +57,14 @@ fun main() {
|
||||
|
||||
println("время расчета = ${System.currentTimeMillis() - ts}мс")
|
||||
|
||||
// for (p in r.points)
|
||||
// println("${p.vit} ${LocalDateTime.ofEpochSecond(p.t.toLong(),0, ZoneOffset.UTC)} ${p.r.x} ${p.r.y} ${p.r.z} ${p.v.x} ${p.v.y} ${p.v.z}")
|
||||
//
|
||||
val astro = AstronomerJ2000(EarthType.PZ90d02)
|
||||
for (p in r.revolutions) {
|
||||
val op = astro.grinvToASK(p.vuz)
|
||||
println(
|
||||
"${op.vit} ${toDateTime(op.t)} " +
|
||||
"${op.r.x} ${op.r.y} ${op.r.z} ${op.v.x} ${op.v.y} ${op.v.z}"
|
||||
)
|
||||
}
|
||||
|
||||
ts = System.currentTimeMillis()
|
||||
r.calculateFlightLine(mnu.first().point.t, mnu.first().point.t + 86400.0 * 5)
|
||||
|
||||
@@ -4,7 +4,7 @@ plugins {
|
||||
kotlin("plugin.lombok")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package space.nstart.pcp.pcp_types_lib.dto.ballistics
|
||||
|
||||
enum class MovementModel {
|
||||
FOTO,
|
||||
KONDOR,
|
||||
METEORM1,
|
||||
METEORM2,
|
||||
BARS,
|
||||
KONDOR_PROGNOZ
|
||||
}
|
||||
+4
-2
@@ -2,6 +2,8 @@ package space.nstart.pcp.pcp_types_lib.dto.ballistics
|
||||
|
||||
class SatelliteICDTO(
|
||||
val satelliteId: Long = -1,
|
||||
val ic : InitialConditionsDTO = InitialConditionsDTO()
|
||||
val ic : InitialConditionsDTO = InitialConditionsDTO(),
|
||||
movementModel: MovementModel? = MovementModel.FOTO
|
||||
) {
|
||||
}
|
||||
val movementModel: MovementModel = movementModel ?: MovementModel.FOTO
|
||||
}
|
||||
|
||||
+8
@@ -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()
|
||||
)
|
||||
+18
@@ -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()
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ plugins {
|
||||
kotlin("plugin.lombok")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
// id("org.graalvm.buildtools.native")
|
||||
}
|
||||
@@ -102,11 +102,11 @@ tasks.jacocoTestReport {
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "pcp")
|
||||
property("sonar.login", "sqp_tokenExample")
|
||||
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
|
||||
property("sonar.host.url", "${property("sonar.host.url")}")
|
||||
}
|
||||
}
|
||||
//sonar {
|
||||
// properties {
|
||||
// property("sonar.projectKey", "pcp")
|
||||
// property("sonar.login", "sqp_tokenExample")
|
||||
// property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
|
||||
// property("sonar.host.url", "${property("sonar.host.url")}")
|
||||
// }
|
||||
//}
|
||||
|
||||
+20
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
@@ -11,9 +12,11 @@ import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
|
||||
import space.nstart.pcp.pcp_request_service.service.SatelliteService
|
||||
import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
|
||||
@@ -23,6 +26,9 @@ class SatelliteController {
|
||||
@Autowired
|
||||
private lateinit var satelliteService: SatelliteService
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteIcEventService: SatelliteIcEventService
|
||||
|
||||
@PostMapping("/{satellite_id}/extract-time")
|
||||
fun extractTime(
|
||||
@PathVariable("satellite_id") satelliteId : Long,
|
||||
@@ -30,6 +36,20 @@ class SatelliteController {
|
||||
) =
|
||||
satelliteService.exactTime(satelliteId, body)
|
||||
|
||||
@PostMapping("/receive-rv")
|
||||
fun receiveRv(@RequestBody body: SatelliteICDTO): ResponseEntity<Void> {
|
||||
satelliteIcEventService.handlePlacedIcRv(body)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@GetMapping("/{satellite_id}/initial-conditions/current")
|
||||
fun currentInitialConditions(
|
||||
@PathVariable("satellite_id") satelliteId: Long
|
||||
): ResponseEntity<SatelliteICDTO> =
|
||||
satelliteService.currentInitialConditions(satelliteId)
|
||||
?.let { ResponseEntity.ok(it) }
|
||||
?: ResponseEntity.noContent().build()
|
||||
|
||||
|
||||
|
||||
@Operation(
|
||||
|
||||
+4
-3
@@ -1,15 +1,13 @@
|
||||
package space.nstart.pcp.pcp_request_service.controller
|
||||
|
||||
import ballistics.types.OrbitalPoint
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_request_service.service.TLEService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TlePointRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
|
||||
|
||||
@RestController
|
||||
@@ -23,6 +21,9 @@ class TLEController {
|
||||
@PostMapping("/parse")
|
||||
fun orbit(@RequestBody tle : TLEDTO) = tleService.parseTLE(tle)
|
||||
|
||||
@PostMapping("/point")
|
||||
fun point(@RequestBody req: TlePointRequestDTO) = tleService.point(req)
|
||||
|
||||
|
||||
@PostMapping("/rva")
|
||||
fun rva(@RequestBody req : TleRvaRequestDTO) = tleService.rva(req)
|
||||
|
||||
+94
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
+7
@@ -6,6 +6,7 @@ import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import tools.jackson.databind.node.ObjectNode
|
||||
@@ -52,6 +53,11 @@ class SatelliteIcRvKafkaListener(
|
||||
*/
|
||||
private fun normalizePayload(payloadNode: tools.jackson.databind.JsonNode): tools.jackson.databind.JsonNode {
|
||||
val normalizedPayload = payloadNode.deepCopy()
|
||||
if (normalizedPayload is ObjectNode &&
|
||||
(normalizedPayload["movementModel"] == null || normalizedPayload["movementModel"].isNull)
|
||||
) {
|
||||
normalizedPayload.put("movementModel", DEFAULT_MOVEMENT_MODEL.name)
|
||||
}
|
||||
val initialConditionsNode = normalizedPayload["ic"]
|
||||
if (initialConditionsNode is ObjectNode) {
|
||||
if (initialConditionsNode["sBall"] == null || initialConditionsNode["sBall"].isNull) {
|
||||
@@ -66,5 +72,6 @@ class SatelliteIcRvKafkaListener(
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_INITIAL_CONDITIONS = InitialConditionsDTO()
|
||||
private val DEFAULT_MOVEMENT_MODEL = MovementModel.FOTO
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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
|
||||
}
|
||||
+30
@@ -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()
|
||||
}
|
||||
+28
-3
@@ -7,6 +7,7 @@ import ballistics.types.EarthType
|
||||
import ballistics.types.FleghtLineSector
|
||||
import ballistics.types.InitialConditions
|
||||
import ballistics.types.KeplerParams
|
||||
import ballistics.types.ModDVType
|
||||
import ballistics.types.OPKatObj
|
||||
import ballistics.types.OrbitalPoint
|
||||
import ballistics.types.PPI
|
||||
@@ -46,6 +47,7 @@ import space.nstart.pcp.pcp_request_service.utils.ContourClipService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
|
||||
@@ -87,6 +89,10 @@ class SatelliteService {
|
||||
|
||||
@Autowired
|
||||
private lateinit var pdcmRepository: PDCMRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteInitialConditionService: SatelliteInitialConditionService
|
||||
|
||||
@Autowired
|
||||
private lateinit var ascNodeRepository: AscNodeRepository
|
||||
@Autowired
|
||||
@@ -183,9 +189,11 @@ class SatelliteService {
|
||||
)
|
||||
|
||||
suspend fun resieveRV(rv: SatelliteICDTO) {
|
||||
val savedInitialCondition = satelliteInitialConditionService.saveReceived(rv)
|
||||
val bal = Ballistics()
|
||||
bal.modDVType = rv.movementModel.toModDVType()
|
||||
|
||||
logger.info("Начало расчета баллистики по RV id = ${rv.satelliteId}")
|
||||
logger.info("Начало расчета баллистики по RV id = ${rv.satelliteId}, model = ${rv.movementModel}")
|
||||
|
||||
bal.rollMax = (45.0 * PI / 180.0)
|
||||
bal.rollMin = (20.0 * PI / 180.0)
|
||||
@@ -227,10 +235,25 @@ class SatelliteService {
|
||||
|
||||
prepareEarthCovering(bal, rv.satelliteId)
|
||||
|
||||
satelliteInitialConditionService.markLastCalculated(savedInitialCondition)
|
||||
|
||||
bal.clear()
|
||||
logger.info("Расчет для КА ${rv.satelliteId} завершился успешно")
|
||||
}
|
||||
|
||||
fun currentInitialConditions(satelliteId: Long): SatelliteICDTO? =
|
||||
satelliteInitialConditionService.current(satelliteId)
|
||||
|
||||
private fun MovementModel.toModDVType(): ModDVType =
|
||||
when (this) {
|
||||
MovementModel.FOTO -> ModDVType.FOTO
|
||||
MovementModel.KONDOR -> ModDVType.KONDOR
|
||||
MovementModel.METEORM1 -> ModDVType.METEORM1
|
||||
MovementModel.METEORM2 -> ModDVType.METEORM2
|
||||
MovementModel.BARS -> ModDVType.BARS
|
||||
MovementModel.KONDOR_PROGNOZ -> ModDVType.KONDOR_PROGNOZ
|
||||
}
|
||||
|
||||
|
||||
fun mergeSectors(sectors: List<FleghtLineSector>): List<FleghtLineSector> {
|
||||
if (sectors.isEmpty()) return emptyList()
|
||||
@@ -851,8 +874,6 @@ class SatelliteService {
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun getAscNodes(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) : List<AscNodeDTO> {
|
||||
|
||||
return ascNodeRepository.findBySatelliteIdAndTimeBetween(
|
||||
@@ -881,3 +902,7 @@ data class SatelliteBallisticsDeletionSummary(
|
||||
val earthCoverageDeleted: Int,
|
||||
val earthCellViewsDeleted: Int
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+34
@@ -1,7 +1,9 @@
|
||||
package space.nstart.pcp.pcp_request_service.service
|
||||
|
||||
import ballistics.Ballistics
|
||||
import ballistics.orbitalPoints.timeStepper.TLEStepper
|
||||
import ballistics.types.BallisticsError
|
||||
import ballistics.types.EarthType
|
||||
import ballistics.types.PPI
|
||||
import ballistics.types.TLE
|
||||
import ballistics.types.TLEParams
|
||||
@@ -15,6 +17,7 @@ import space.nstart.pcp.pcp_request_service.configuration.CustomValidationExcept
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TlePointRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TleRvaRequestDTO
|
||||
import kotlin.math.PI
|
||||
@@ -47,6 +50,37 @@ class TLEService() {
|
||||
|
||||
|
||||
|
||||
|
||||
fun point(req: TlePointRequestDTO): OrbPointDTO {
|
||||
val bal = Ballistics()
|
||||
val tleParams = try {
|
||||
bal.getTLEParams(TLE(req.tle.first, req.tle.second), req.tle.header ?: "empty")
|
||||
} catch (ex: Exception) {
|
||||
throw CustomValidationException("Ошибка формата TLE : ${ex.message}")
|
||||
}
|
||||
if (req.time < tleParams.time) {
|
||||
throw CustomValidationException("Время расчета должно быть не раньше эпохи TLE ${tleParams.time}")
|
||||
}
|
||||
|
||||
val point = try {
|
||||
val stepper = TLEStepper(req.tle.first, req.tle.second, EarthType.PZ90d02)
|
||||
stepper.calculate(fromDateTime(req.time))
|
||||
} catch (ex: Exception) {
|
||||
throw CustomErrorException("Ошибка расчета положения по TLE : ${ex.message}")
|
||||
}
|
||||
|
||||
return OrbPointDTO(
|
||||
toDateTime(point.t),
|
||||
point.vit.toLong(),
|
||||
point.v.x,
|
||||
point.v.y,
|
||||
point.v.z,
|
||||
point.r.x,
|
||||
point.r.y,
|
||||
point.r.z
|
||||
)
|
||||
}
|
||||
|
||||
fun rva(req : TleRvaRequestDTO) : Iterable<RadioVisibilityAreaDTO> {
|
||||
|
||||
val bal = Ballistics()
|
||||
|
||||
+21
@@ -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);
|
||||
+5
-1
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
|
||||
@@ -46,6 +47,7 @@ class SatelliteIcRvKafkaListenerTest {
|
||||
assertEquals(56756L, satelliteIcEventService.lastMessage?.satelliteId)
|
||||
assertEquals(42L, satelliteIcEventService.lastMessage?.ic?.orbPoint?.revolution)
|
||||
assertEquals(140.0, satelliteIcEventService.lastMessage?.ic?.f81)
|
||||
assertEquals(MovementModel.FOTO, satelliteIcEventService.lastMessage?.movementModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,8 +56,9 @@ class SatelliteIcRvKafkaListenerTest {
|
||||
"""
|
||||
{
|
||||
"type": "ICRVPlacedEvent",
|
||||
"data": {
|
||||
"data": {
|
||||
"satelliteId": 56756,
|
||||
"movementModel": null,
|
||||
"ic": {
|
||||
"orbPoint": {
|
||||
"time": "2026-04-16T12:00:00",
|
||||
@@ -77,6 +80,7 @@ class SatelliteIcRvKafkaListenerTest {
|
||||
|
||||
assertEquals(0.0, satelliteIcEventService.lastMessage?.ic?.sBall)
|
||||
assertEquals(147.8, satelliteIcEventService.lastMessage?.ic?.f81)
|
||||
assertEquals(MovementModel.FOTO, satelliteIcEventService.lastMessage?.movementModel)
|
||||
}
|
||||
|
||||
private class RecordingSatelliteIcEventService : SatelliteIcEventService() {
|
||||
|
||||
+58
@@ -14,6 +14,11 @@ import org.springframework.kafka.core.DefaultKafkaConsumerFactory
|
||||
import org.springframework.kafka.listener.adapter.RecordFilterStrategy
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.nstart.pcp.pcp_request_service.entity.PDCMEntity
|
||||
import space.nstart.pcp.pcp_request_service.entity.SatelliteInitialConditionEntity
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -41,6 +46,9 @@ class PDCMRepositoryTest {
|
||||
@Autowired
|
||||
private lateinit var repository: PDCMRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var satelliteInitialConditionRepository: SatelliteInitialConditionRepository
|
||||
|
||||
@Test
|
||||
fun `find satellite orbit availability returns all satellites with points when time is null`() {
|
||||
persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0))
|
||||
@@ -74,6 +82,34 @@ class PDCMRepositoryTest {
|
||||
assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual.single().timeStop)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite initial conditions repository returns last calculated condition`() {
|
||||
val oldCondition = satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 1L))
|
||||
oldCondition.markLastCalculated(LocalDateTime.of(2026, 4, 14, 10, 0))
|
||||
satelliteInitialConditionRepository.save(oldCondition)
|
||||
|
||||
val newCondition = satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 2L))
|
||||
satelliteInitialConditionRepository.clearLastCalculated(11L)
|
||||
newCondition.markLastCalculated(LocalDateTime.of(2026, 4, 14, 12, 0))
|
||||
satelliteInitialConditionRepository.save(newCondition)
|
||||
satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 3L))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val actual = satelliteInitialConditionRepository
|
||||
.findFirstBySatelliteIdAndLastCalculatedTrueOrderByCalculatedAtDescIdDesc(11L)
|
||||
|
||||
assertEquals(2L, actual?.toDto()?.ic?.orbPoint?.revolution)
|
||||
assertEquals(MovementModel.KONDOR, actual?.movementModel)
|
||||
assertEquals(
|
||||
1,
|
||||
satelliteInitialConditionRepository.findAll().count { entity ->
|
||||
entity.satelliteId == 11L && entity.lastCalculated
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistPoint(satelliteId: Long, time: LocalDateTime) {
|
||||
entityManager.persist(
|
||||
PDCMEntity(
|
||||
@@ -91,6 +127,28 @@ class PDCMRepositoryTest {
|
||||
entityManager.flush()
|
||||
}
|
||||
|
||||
private fun sampleInitialCondition(satelliteId: Long, revolution: Long) =
|
||||
SatelliteInitialConditionEntity(
|
||||
SatelliteICDTO(
|
||||
satelliteId = satelliteId,
|
||||
movementModel = MovementModel.KONDOR,
|
||||
ic = InitialConditionsDTO(
|
||||
orbPoint = OrbPointDTO(
|
||||
time = LocalDateTime.of(2026, 4, 14, 10, 0).plusHours(revolution),
|
||||
revolution = revolution,
|
||||
vx = 1.0,
|
||||
vy = 2.0,
|
||||
vz = 3.0,
|
||||
x = 4.0,
|
||||
y = 5.0,
|
||||
z = 6.0
|
||||
),
|
||||
sBall = 0.2,
|
||||
f81 = 141.0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@TestConfiguration
|
||||
class KafkaFilterStubConfiguration {
|
||||
@Bean("kafkaListenerContainerFactory")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ plugins {
|
||||
kotlin("plugin.lombok")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
// id("org.graalvm.buildtools.native")
|
||||
}
|
||||
@@ -101,11 +101,11 @@ tasks.jacocoTestReport {
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "pcp")
|
||||
property("sonar.login", "sqp_tokenExample")
|
||||
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
|
||||
property("sonar.host.url", "${property("sonar.host.url")}")
|
||||
}
|
||||
}
|
||||
//sonar {
|
||||
// properties {
|
||||
// property("sonar.projectKey", "pcp")
|
||||
// property("sonar.login", "sqp_tokenExample")
|
||||
// property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
|
||||
// property("sonar.host.url", "${property("sonar.host.url")}")
|
||||
// }
|
||||
//}
|
||||
|
||||
-1
@@ -33,7 +33,6 @@ class SatelliteModel(
|
||||
val longMission : LongTermMission = LongTermMission(),
|
||||
val bls: BLS = BLS(),
|
||||
val scanTLE : Boolean = false
|
||||
|
||||
) {
|
||||
|
||||
fun toDTO() = SatelliteInfoDTO(
|
||||
|
||||
+136
-30
@@ -18,8 +18,11 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.abs
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Service
|
||||
@@ -36,6 +39,11 @@ class ComplexPlanCalculationService(
|
||||
|
||||
private val logger: Logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
companion object {
|
||||
private const val LOG_INTERVAL_SAMPLE_LIMIT = 10
|
||||
private const val ROLL_EPSILON = 0.01
|
||||
}
|
||||
|
||||
fun process(
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime,
|
||||
@@ -134,7 +142,7 @@ class ComplexPlanCalculationService(
|
||||
|
||||
logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId)
|
||||
val plannedModesMs = measureTimeMillis {
|
||||
addPlannedModes(workingSatellites, intervalStart, intervalEnd)
|
||||
addPlannedModes(runId, workingSatellites, intervalStart, intervalEnd)
|
||||
}
|
||||
logger.info(
|
||||
"Stage planned-modes-load: runId={}, satellites={}, durationMs={}",
|
||||
@@ -161,8 +169,9 @@ class ComplexPlanCalculationService(
|
||||
}
|
||||
|
||||
val targetsChunk = buildTargetsChunk(chunk)
|
||||
var coverageByTargetId = emptyMap<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>()
|
||||
var coverageByTargetId = emptyMap<Long, List<SlotDTO>>()
|
||||
val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites)
|
||||
logOccupiedIntervalsSnapshot(runId, chunkIndex + 1, chunkCount, occupiedIntervals)
|
||||
val coverageFetchMs = measureTimeMillis {
|
||||
coverageByTargetId = coverageCalculationClient.coverageModes(
|
||||
targets = targetsChunk,
|
||||
@@ -189,6 +198,7 @@ class ComplexPlanCalculationService(
|
||||
var acceptedForChunk = 0
|
||||
val chunkProcessMs = measureTimeMillis {
|
||||
acceptedForChunk = processChunk(
|
||||
runId = runId,
|
||||
workingSatellites = workingSatellites,
|
||||
cells = chunk,
|
||||
coverageByTargetId = coverageByTargetId,
|
||||
@@ -294,6 +304,7 @@ class ComplexPlanCalculationService(
|
||||
}
|
||||
|
||||
private fun addPlannedModes(
|
||||
runId: Long?,
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
@@ -308,35 +319,35 @@ class ComplexPlanCalculationService(
|
||||
)
|
||||
|
||||
workingSatellites.forEach { satellite ->
|
||||
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
|
||||
plannedModesBySatelliteId[satellite.satelliteId]
|
||||
.orEmpty()
|
||||
.forEach { slot ->
|
||||
surveyPlacementService.mergeSurvey(
|
||||
existingSurveys = satellite.longMission.surveys,
|
||||
candidateSurvey = SurveyMode(
|
||||
revolution = slot.revolution,
|
||||
time = slot.tn,
|
||||
timeStop = slot.tk,
|
||||
roll = slot.roll,
|
||||
latitude = slot.latitude,
|
||||
longitude = slot.longitude,
|
||||
duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
|
||||
contourWKT = slot.contour,
|
||||
cellNum = -1,
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
bookedSlotIds = slot.slotIds,
|
||||
cellNums = emptyList()
|
||||
),
|
||||
satellite = satellite
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Добавлены booked-маршруты для satelliteId={}: count={}",
|
||||
satellite.satelliteId,
|
||||
plannedForSatellite.size
|
||||
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
|
||||
logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite)
|
||||
plannedForSatellite.forEach { slot ->
|
||||
surveyPlacementService.mergeSurvey(
|
||||
existingSurveys = satellite.longMission.surveys,
|
||||
candidateSurvey = SurveyMode(
|
||||
revolution = slot.revolution,
|
||||
time = slot.tn,
|
||||
timeStop = slot.tk,
|
||||
roll = slot.roll,
|
||||
latitude = slot.latitude,
|
||||
longitude = slot.longitude,
|
||||
duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
|
||||
contourWKT = slot.contour,
|
||||
cellNum = -1,
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
bookedSlotIds = slot.slotIds,
|
||||
cellNums = emptyList()
|
||||
),
|
||||
satellite = satellite
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
"Добавлены booked-маршруты: runId={}, satelliteId={}, count={}",
|
||||
runId,
|
||||
satellite.satelliteId,
|
||||
plannedForSatellite.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCoverageTargets(cells: List<EarthCellWithRequestsDTO>): List<SlotCoverageTargetDTO> =
|
||||
@@ -367,10 +378,59 @@ class ComplexPlanCalculationService(
|
||||
.sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime })
|
||||
.toList()
|
||||
|
||||
private fun logBookedSlotsLoaded(runId: Long?, satelliteId: Long, slots: List<SurveySlotDTO>) {
|
||||
if (slots.isEmpty()) {
|
||||
logger.info("Booked slots loaded: runId={}, satelliteId={}, count=0", runId, satelliteId)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Booked slots loaded: runId={}, satelliteId={}, count={}, interval=[{} - {}], sample={}",
|
||||
runId,
|
||||
satelliteId,
|
||||
slots.size,
|
||||
slots.minOf { it.tn },
|
||||
slots.maxOf { it.tk },
|
||||
slots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { slot ->
|
||||
"start=${slot.tn},end=${slot.tk},roll=${slot.roll},revolution=${slot.revolution},slotIds=${slot.slotIds}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun logOccupiedIntervalsSnapshot(
|
||||
runId: Long?,
|
||||
chunkNumber: Int,
|
||||
chunkCount: Int,
|
||||
occupiedIntervals: List<OccupiedIntervalDTO>
|
||||
) {
|
||||
val bySource = occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount()
|
||||
val slotsBySatellite = occupiedIntervals
|
||||
.filter { it.source == SatelliteModeSource.SLOTS.name }
|
||||
.groupingBy { it.satelliteId }
|
||||
.eachCount()
|
||||
|
||||
logger.info(
|
||||
"Occupied intervals snapshot before coverage request: runId={}, chunk={}/{}, total={}, bySource={}, slotsBySatellite={}, slotsSample={}",
|
||||
runId,
|
||||
chunkNumber,
|
||||
chunkCount,
|
||||
occupiedIntervals.size,
|
||||
bySource,
|
||||
slotsBySatellite,
|
||||
occupiedIntervals
|
||||
.filter { it.source == SatelliteModeSource.SLOTS.name }
|
||||
.take(LOG_INTERVAL_SAMPLE_LIMIT)
|
||||
.map { interval ->
|
||||
"satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun processChunk(
|
||||
runId: Long?,
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
cells: List<EarthCellWithRequestsDTO>,
|
||||
coverageByTargetId: Map<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>,
|
||||
coverageByTargetId: Map<Long, List<SlotDTO>>,
|
||||
intervalStart: LocalDateTime,
|
||||
intervalEnd: LocalDateTime
|
||||
): Int {
|
||||
@@ -391,6 +451,13 @@ class ComplexPlanCalculationService(
|
||||
val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId }
|
||||
?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван")
|
||||
|
||||
logComplanCandidateBookedSlotOverlaps(
|
||||
runId = runId,
|
||||
cell = cell,
|
||||
slot = slot,
|
||||
existingSurveys = satellite.longMission.surveys
|
||||
)
|
||||
|
||||
val decision = surveyPlacementService.tryPlaceSurvey(
|
||||
existingSurveys = satellite.longMission.surveys,
|
||||
candidateSurvey = SurveyMode(
|
||||
@@ -425,6 +492,45 @@ class ComplexPlanCalculationService(
|
||||
return acceptedModes
|
||||
}
|
||||
|
||||
private fun logComplanCandidateBookedSlotOverlaps(
|
||||
runId: Long?,
|
||||
cell: EarthCellWithRequestsDTO,
|
||||
slot: SlotDTO,
|
||||
existingSurveys: List<SurveyMode>
|
||||
) {
|
||||
val overlappingBookedSlots = existingSurveys
|
||||
.filter { survey -> survey.source == SatelliteModeSource.SLOTS }
|
||||
.filter { survey -> intersectsInTime(survey.time, survey.timeStop, slot.tn, slot.tk) }
|
||||
|
||||
if (overlappingBookedSlots.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
"COMPLAN candidate overlaps booked SLOTS before placement: runId={}, satelliteId={}, targetId={}, cellNum={}, candidateInterval=[{} - {}], candidateRoll={}, candidateRevolution={}, overlaps={}, sameRollOverlaps={}, bookedSlotsSample={}",
|
||||
runId,
|
||||
slot.satelliteId,
|
||||
cell.targetId(),
|
||||
cell.num,
|
||||
slot.tn,
|
||||
slot.tk,
|
||||
slot.roll,
|
||||
slot.revolution,
|
||||
overlappingBookedSlots.size,
|
||||
overlappingBookedSlots.count { survey -> abs(survey.roll - slot.roll) <= ROLL_EPSILON },
|
||||
overlappingBookedSlots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { survey ->
|
||||
"start=${survey.time},end=${survey.timeStop},roll=${survey.roll},source=${survey.source},bookedSlotIds=${survey.bookedSlotIds}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun intersectsInTime(
|
||||
start1: LocalDateTime,
|
||||
stop1: LocalDateTime,
|
||||
start2: LocalDateTime,
|
||||
stop2: LocalDateTime
|
||||
): Boolean = start1 < stop2 && start2 < stop1
|
||||
|
||||
private fun hasRemainingCapacity(
|
||||
workingSatellites: List<SatelliteModel>,
|
||||
intervalStart: LocalDateTime,
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ class ComplexPlanRunOrchestrationService(
|
||||
)
|
||||
|
||||
lateinit var createdRun: ComplexPlanRunEntity
|
||||
val createRunMs = measureTimeMillis {
|
||||
val createRunMs = measureTimeMillis {
|
||||
createdRun = complexPlanRunPersistenceService.createRun(
|
||||
ComplexPlanRunCreateDTO(
|
||||
intervalStart = request.intervalStart,
|
||||
|
||||
+29
-4
@@ -18,6 +18,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRespon
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.time.LocalDateTime
|
||||
@@ -161,18 +162,42 @@ class CoverageBatchClient(webClientBuilderProvider: ObjectProvider<WebClient.Bui
|
||||
}
|
||||
}
|
||||
|
||||
val rawCandidateSlots = result.response.sumOf { it.slots.size }
|
||||
val filteredResponse = removeBookedSlotsFromCoverageResponse(result.response)
|
||||
val filteredCandidateSlots = filteredResponse.sumOf { it.slots.size }
|
||||
val removedBookedSlots = rawCandidateSlots - filteredCandidateSlots
|
||||
|
||||
logger.info(
|
||||
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}, effectiveCoverageStrategy={}",
|
||||
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlotsRaw={}, candidateSlotsAfterBookedFilter={}, removedBookedSlots={}, durationMs={}, effectiveCoverageStrategy={}",
|
||||
targets.size,
|
||||
result.response.size,
|
||||
result.response.sumOf { it.slots.size },
|
||||
filteredResponse.size,
|
||||
rawCandidateSlots,
|
||||
filteredCandidateSlots,
|
||||
removedBookedSlots,
|
||||
result.durationMs,
|
||||
result.coverageStrategy
|
||||
)
|
||||
|
||||
return result.response.associate { it.targetId to it.slots }
|
||||
return filteredResponse.associate { it.targetId to it.slots }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Complex plan loads booked slots before asking slots-service for coverage candidates.
|
||||
* slots-service still has to see booked slots internally: it marks them as BOOKED and the
|
||||
* coverage solver subtracts their geometry from the uncovered target area. However these
|
||||
* BOOKED slots are already present in the working complex plan and must not be returned
|
||||
* to chunk processing again as COMPLAN candidates.
|
||||
*/
|
||||
private fun removeBookedSlotsFromCoverageResponse(
|
||||
response: List<SlotCoverageBatchResponseDTO>
|
||||
): List<SlotCoverageBatchResponseDTO> =
|
||||
response.map { targetResponse ->
|
||||
targetResponse.copy(
|
||||
slots = targetResponse.slots.filterNot { slot -> slot.state != SlotStatus.AVAILABLE }
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestCoverageModes(
|
||||
targets: List<SlotCoverageTargetDTO>,
|
||||
intervalStart: LocalDateTime,
|
||||
|
||||
+14
@@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
companion object {
|
||||
private const val LOG_INTERVAL_SAMPLE_LIMIT = 10
|
||||
}
|
||||
|
||||
@Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}")
|
||||
val url = ""
|
||||
|
||||
@@ -55,6 +59,16 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
|
||||
intervalStart,
|
||||
intervalEnd
|
||||
)
|
||||
if (occupiedIntervals.isNotEmpty()) {
|
||||
logger.warn(
|
||||
"coverage-scheme-service client received occupiedIntervals but does not pass them to coverage-scheme-service request: occupiedIntervals={}, bySource={}, sample={}",
|
||||
occupiedIntervals.size,
|
||||
occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount(),
|
||||
occupiedIntervals.take(LOG_INTERVAL_SAMPLE_LIMIT).map { interval ->
|
||||
"satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val result = linkedMapOf<Long, List<SlotDTO>>()
|
||||
val durationMs = measureTimeMillis {
|
||||
|
||||
+18
@@ -226,6 +226,9 @@ class SurveyPlacementService {
|
||||
if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) {
|
||||
return@forEach
|
||||
}
|
||||
if (shouldSkipComplanInsideSlotsMerge(existing, mergedSurvey)) {
|
||||
return@forEach
|
||||
}
|
||||
val overlapsInTime = intersectsInTime(
|
||||
existing.time,
|
||||
existing.timeStop,
|
||||
@@ -377,6 +380,21 @@ class SurveyPlacementService {
|
||||
return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS
|
||||
}
|
||||
|
||||
private fun shouldSkipComplanInsideSlotsMerge(
|
||||
left: SurveyMode,
|
||||
right: SurveyMode
|
||||
): Boolean {
|
||||
val complanSurvey = when {
|
||||
left.source == SatelliteModeSource.COMPLAN && right.source == SatelliteModeSource.SLOTS -> left
|
||||
right.source == SatelliteModeSource.COMPLAN && left.source == SatelliteModeSource.SLOTS -> right
|
||||
else -> return false
|
||||
}
|
||||
val slotsSurvey = if (complanSurvey === left) right else left
|
||||
|
||||
return !complanSurvey.time.isBefore(slotsSurvey.time) &&
|
||||
!complanSurvey.timeStop.isAfter(slotsSurvey.timeStop)
|
||||
}
|
||||
|
||||
private fun intersectsInTime(
|
||||
start1: LocalDateTime,
|
||||
stop1: LocalDateTime,
|
||||
|
||||
+33
@@ -121,6 +121,39 @@ class SurveyPlacementServiceTest {
|
||||
assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complan inside booked slot is not merged to mixed`() {
|
||||
val surveys = mutableListOf(
|
||||
survey(
|
||||
start = 0,
|
||||
end = 100,
|
||||
roll = 10.0,
|
||||
source = SatelliteModeSource.SLOTS,
|
||||
contour = "POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0))"
|
||||
)
|
||||
)
|
||||
val satellite = satellite()
|
||||
|
||||
val decision = service.tryPlaceSurvey(
|
||||
surveys,
|
||||
survey(
|
||||
start = 0,
|
||||
end = 40,
|
||||
roll = 10.0,
|
||||
source = SatelliteModeSource.COMPLAN,
|
||||
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
|
||||
),
|
||||
satellite
|
||||
)
|
||||
|
||||
assertTrue(decision.accepted)
|
||||
assertEquals(2, surveys.size)
|
||||
assertTrue(decision.absorbedSurveys.isEmpty())
|
||||
assertEquals(1, surveys.count { it.source == SatelliteModeSource.SLOTS })
|
||||
assertEquals(1, surveys.count { it.source == SatelliteModeSource.COMPLAN })
|
||||
assertFalse(surveys.any { it.source == SatelliteModeSource.MIXED })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different roll conflict resolved by trimming`() {
|
||||
val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ spring:
|
||||
import: "configserver:"
|
||||
cloud:
|
||||
config:
|
||||
uri: ${CONFIG_SERVER_URI:http://192.168.1.8:8888}
|
||||
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
|
||||
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
|
||||
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
|
||||
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
|
||||
@@ -15,7 +15,7 @@ spring:
|
||||
# max-in-memory-size: 16MB
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://192.168.1.8:5432/complan
|
||||
url: jdbc:postgresql://192.168.60.68:5432/complan
|
||||
username: postgres
|
||||
password: password
|
||||
jpa:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
kotlin("plugin.lombok")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS="-jar"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
}
|
||||
|
||||
@@ -65,11 +65,4 @@ tasks.jacocoTestReport {
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "pcp")
|
||||
property("sonar.login", "sqp_tokenExample")
|
||||
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
|
||||
property("sonar.host.url", "${property("sonar.host.url")}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
kotlin("plugin.lombok")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
}
|
||||
|
||||
@@ -94,12 +94,3 @@ tasks.jacocoTestReport {
|
||||
csv.required = false
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "pcp")
|
||||
property("sonar.login", "sqp_tokenExample")
|
||||
property("sonar.qualitygate.wait", "${property("sonar.qualitygate.wait")}")
|
||||
property("sonar.host.url", "${property("sonar.host.url")}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM bellsoft/liberica-openjre-alpine:21.0.5
|
||||
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
kotlin("plugin.lombok")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
id("org.sonarqube")
|
||||
//id("org.sonarqube")
|
||||
jacoco
|
||||
}
|
||||
|
||||
|
||||
+88
@@ -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,
|
||||
)
|
||||
}
|
||||
+9
@@ -18,6 +18,15 @@ class CatalogPageController {
|
||||
@GetMapping("/satellites/pdcm")
|
||||
fun satellitesPdcmPage(): String = "satellites-pdcm"
|
||||
|
||||
@GetMapping("/tle-comparison")
|
||||
fun tleComparisonPage(): String = "tle-comparison"
|
||||
|
||||
@GetMapping("/current-plans")
|
||||
fun currentPlansPage(): String = "current-plans"
|
||||
|
||||
@GetMapping("/bookings")
|
||||
fun bookingsPage(): String = "bookings"
|
||||
|
||||
@GetMapping("/tle-analysis")
|
||||
fun tleAnalysisPage(): String = "tle-analysis"
|
||||
}
|
||||
|
||||
+61
-1
@@ -1,6 +1,7 @@
|
||||
package space.nstart.pcp.slots_service.controller
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@@ -11,7 +12,9 @@ import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
|
||||
@@ -20,10 +23,14 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileD
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO
|
||||
import space.nstart.pcp.slots_service.service.GroupStateService
|
||||
import space.nstart.pcp.slots_service.service.BallisticsService
|
||||
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
|
||||
import space.nstart.pcp.slots_service.service.SatellitePdcmService
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.format.ResolverStyle
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/catalog")
|
||||
@@ -31,8 +38,11 @@ class CatalogRestController(
|
||||
private val satelliteCatalogService: SatelliteCatalogService,
|
||||
private val slotService: SlotService,
|
||||
private val groupStateService: GroupStateService,
|
||||
private val satellitePdcmService: SatellitePdcmService
|
||||
private val satellitePdcmService: SatellitePdcmService,
|
||||
private val ballisticsService: BallisticsService
|
||||
) {
|
||||
private val sendInitialConditionsTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm:ss.SSS").withResolverStyle(ResolverStyle.STRICT)
|
||||
|
||||
@GetMapping("/satellites")
|
||||
fun allSatellites() = satelliteCatalogService.allSatellites()
|
||||
@@ -56,6 +66,56 @@ class CatalogRestController(
|
||||
@GetMapping("/satellites/pdcm/{satelliteId}")
|
||||
fun satellitePdcm(@PathVariable satelliteId: Long) = satellitePdcmService.satelliteDetails(satelliteId)
|
||||
|
||||
@PostMapping("/satellites/pdcm/{satelliteId}/send-ic")
|
||||
fun sendSatelliteInitialConditions(
|
||||
@PathVariable satelliteId: Long,
|
||||
@RequestParam(required = false) time: String?
|
||||
): ResponseEntity<Void> {
|
||||
val requestedTime = parseSendInitialConditionsTime(time)
|
||||
slotService.sendSatelliteInitialConditions(satelliteId, requestedTime)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@PostMapping("/satellites/pdcm/{satelliteId}/initial-conditions")
|
||||
fun setSatelliteInitialConditions(
|
||||
@PathVariable satelliteId: Long,
|
||||
@RequestBody request: SatelliteICDTO
|
||||
): ResponseEntity<Void> {
|
||||
ballisticsService.receiveRv(
|
||||
SatelliteICDTO(
|
||||
satelliteId = satelliteId,
|
||||
ic = request.ic,
|
||||
movementModel = request.movementModel
|
||||
)
|
||||
)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@GetMapping("/satellites/pdcm/{satelliteId}/initial-conditions")
|
||||
fun currentPdcmInitialConditions(@PathVariable satelliteId: Long): ResponseEntity<Any> {
|
||||
val response = ballisticsService.currentInitialConditions(satelliteId)
|
||||
return if (response == null) {
|
||||
ResponseEntity.noContent().build()
|
||||
} else {
|
||||
ResponseEntity.ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSendInitialConditionsTime(time: String?): LocalDateTime {
|
||||
val normalizedTime = time?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: return LocalDateTime.now()
|
||||
|
||||
return try {
|
||||
LocalDateTime.parse(normalizedTime, sendInitialConditionsTimeFormatter)
|
||||
} catch (exception: DateTimeParseException) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Параметр time должен быть в формате dd.mm.yyyy hh:MM:ss.zzz",
|
||||
exception
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/satellites")
|
||||
fun createSatellite(@Valid @RequestBody request: SatelliteCreateDTO) =
|
||||
satelliteCatalogService.createSatellite(request)
|
||||
|
||||
+9
-1
@@ -11,13 +11,15 @@ import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
|
||||
import space.nstart.pcp.slots_service.service.MissionService
|
||||
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
|
||||
import space.nstart.pcp.slots_service.service.SlotService
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/current-plans")
|
||||
class CurrentPlansController(
|
||||
private val satelliteCatalogService: SatelliteCatalogService,
|
||||
private val missionService: MissionService
|
||||
private val missionService: MissionService,
|
||||
private val slotService: SlotService
|
||||
) {
|
||||
|
||||
@GetMapping("/satellites")
|
||||
@@ -36,6 +38,9 @@ class CurrentPlansController(
|
||||
@GetMapping("/missions/{missionId}/modes")
|
||||
fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId)
|
||||
|
||||
@GetMapping("/booked-slots")
|
||||
fun bookedSlots(@RequestParam ids: List<Long>) = slotService.bookedByIds(ids)
|
||||
|
||||
@PostMapping("/missions/{missionId}/surveys/calculate")
|
||||
fun calculateSurveys(
|
||||
@PathVariable missionId: UUID,
|
||||
@@ -44,4 +49,7 @@ class CurrentPlansController(
|
||||
|
||||
@PostMapping("/missions/{missionId}/drops/calculate")
|
||||
fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId)
|
||||
|
||||
@PostMapping("/missions/{missionId}/confirm")
|
||||
fun confirmMission(@PathVariable missionId: UUID) = missionService.confirmMission(missionId)
|
||||
}
|
||||
|
||||
+33
@@ -57,4 +57,37 @@ class RvaRestController(
|
||||
@PathVariable duration: Long,
|
||||
@RequestBody stations: List<StationDTO>
|
||||
) = slotService.rvaMergeOnRev(satelliteId, duration, stations)
|
||||
|
||||
@PostMapping("/average/{satelliteId}/{duration}")
|
||||
@ResponseBody
|
||||
fun rvaAverage(
|
||||
@PathVariable satelliteId: Long,
|
||||
@PathVariable duration: Long,
|
||||
@RequestBody stations: List<StationDTO>
|
||||
): List<RvaAverageDTO> {
|
||||
val days = duration.coerceAtLeast(1).toDouble()
|
||||
val zonesByStation = slotService.rvaCommon(satelliteId, duration, stations)
|
||||
.groupBy { it.stationId }
|
||||
|
||||
return stations
|
||||
.sortedBy { it.number }
|
||||
.map { station ->
|
||||
val zones = zonesByStation[station.number.toLong()].orEmpty()
|
||||
val zoneCount = zones.size
|
||||
val totalDurationSec = zones.sumOf { it.duration }
|
||||
RvaAverageDTO(
|
||||
ppiNumber = station.number.toLong(),
|
||||
ppiLatitude = station.position.lat,
|
||||
averageCount = zoneCount / days,
|
||||
averageDurationSec = if (zoneCount == 0) 0.0 else totalDurationSec / zoneCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RvaAverageDTO(
|
||||
val ppiNumber: Long,
|
||||
val ppiLatitude: Double,
|
||||
val averageCount: Double,
|
||||
val averageDurationSec: Double
|
||||
)
|
||||
|
||||
+42
@@ -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)
|
||||
}
|
||||
+183
@@ -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}с"
|
||||
}
|
||||
}
|
||||
+36
@@ -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(),
|
||||
)
|
||||
+46
@@ -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
|
||||
)
|
||||
+72
@@ -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
|
||||
)
|
||||
+5
-2
@@ -65,8 +65,11 @@ class RequestRestController {
|
||||
|
||||
|
||||
@PostMapping("/station/{station_id}")
|
||||
fun sat(@PathVariable("station_id") id : String, @RequestParam view : Boolean) =
|
||||
czmlService.station(id, view)
|
||||
fun sat(
|
||||
@PathVariable("station_id") id : String,
|
||||
@RequestParam view : Boolean,
|
||||
@RequestParam(required = false) orbitHeightKm : Double?
|
||||
) = czmlService.station(id, view, orbitHeightKm)
|
||||
|
||||
|
||||
|
||||
|
||||
+47
@@ -9,7 +9,11 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TlePointRequestDTO
|
||||
import space.nstart.pcp.slots_service.dto.tlecomparison.TleParsedDTO
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import java.net.URI
|
||||
import java.time.LocalDateTime
|
||||
@@ -68,6 +72,49 @@ class BallisticsService (webClientBuilderProvider: ObjectProvider<WebClient.Buil
|
||||
?.firstOrNull()
|
||||
?: throw CustomErrorException("Не удалось получить положение спутника $id на время $time")
|
||||
|
||||
fun receiveRv(request: SatelliteICDTO) {
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/satellites/receive-rv")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
}
|
||||
|
||||
fun currentInitialConditions(satelliteId: Long): SatelliteICDTO? =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/api/satellites/$satelliteId/initial-conditions/current")
|
||||
.exchangeToMono { response ->
|
||||
when {
|
||||
response.statusCode().is2xxSuccessful -> response.bodyToMono(SatelliteICDTO::class.java)
|
||||
response.statusCode().value() == 404 || response.statusCode().value() == 204 -> reactor.core.publisher.Mono.empty()
|
||||
else -> response.createException().flatMap { reactor.core.publisher.Mono.error(it) }
|
||||
}
|
||||
}
|
||||
.block()
|
||||
|
||||
fun parseTle(tle: TLEDTO): TleParsedDTO =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/tle/parse")
|
||||
.bodyValue(tle)
|
||||
.retrieve()
|
||||
.bodyToMono(TleParsedDTO::class.java)
|
||||
.block()
|
||||
?: throw CustomErrorException("Не удалось разобрать TLE")
|
||||
|
||||
fun tlePoint(tle: TLEDTO, time: LocalDateTime): OrbPointDTO =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/tle/point")
|
||||
.bodyValue(TlePointRequestDTO(tle = tle, time = time))
|
||||
.retrieve()
|
||||
.bodyToMono(OrbPointDTO::class.java)
|
||||
.block()
|
||||
?: throw CustomErrorException("Не удалось рассчитать положение по TLE на время $time")
|
||||
|
||||
private fun withTimeInterval(
|
||||
path: String,
|
||||
timeStart: LocalDateTime?,
|
||||
|
||||
+5
-3
@@ -50,6 +50,7 @@ class CZMLService {
|
||||
private val docStationName = "station_"
|
||||
private val entityStationName = "station_"
|
||||
private val docReqCovName ="req_cov_"
|
||||
private val DEFAULT_STATION_ORBIT_HEIGHT_KM = 500.0
|
||||
|
||||
@Autowired
|
||||
private lateinit var stationService: StationService
|
||||
@@ -378,9 +379,10 @@ private val docReqCovName ="req_cov_"
|
||||
}
|
||||
|
||||
|
||||
fun station(id : String, view : Boolean) : Iterable<CZMLEntity>{
|
||||
fun station(id : String, view : Boolean, orbitHeightKm: Double? = null) : Iterable<CZMLEntity>{
|
||||
val req = stationService.station(id).block()?:return listOf()
|
||||
val radius = ellipseAx( (500.0 * 1000.0), req.elevationMin * PI / 180)
|
||||
val normalizedOrbitHeightKm = orbitHeightKm?.takeIf { it > 0 } ?: DEFAULT_STATION_ORBIT_HEIGHT_KM
|
||||
val radius = ellipseAx((normalizedOrbitHeightKm * 1000.0), req.elevationMin * PI / 180)
|
||||
|
||||
val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docStationName+id , "1.1"))
|
||||
r.add(
|
||||
@@ -401,7 +403,7 @@ private val docReqCovName ="req_cov_"
|
||||
extrudedHeight = 1000.0,
|
||||
height = 1000.0
|
||||
),
|
||||
description = "Станция ${req.name}",
|
||||
description = "Станция ${req.name}<br>Высота орбиты для зоны видимости: ${normalizedOrbitHeightKm} км",
|
||||
model = CZMLModel(
|
||||
gltf = "/model/station.glb",
|
||||
show = !view
|
||||
|
||||
+9
@@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>
|
||||
.block()
|
||||
}
|
||||
|
||||
fun confirmMission(missionId: UUID) {
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri("$url/api/missions/$missionId/confirm")
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
}
|
||||
|
||||
fun modesByMission(missionId: UUID): List<ModeResponseDTO> =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
|
||||
+101
@@ -22,6 +22,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
|
||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import space.nstart.pcp.slots_service.configuration.CustomErrorException
|
||||
import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
@@ -148,6 +149,85 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun allBooked(): List<BookedSlotDTO> =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/api/slots/booking")
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux(BookedSlotDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
fun bookedByIds(bookedSlotIds: List<Long>): List<BookedSlotDetailsDTO> =
|
||||
bookedDetailsByIds(bookedSlotIds)
|
||||
|
||||
fun bookedDetailsByIds(bookedSlotIds: List<Long>): List<BookedSlotDetailsDTO> {
|
||||
val ids = bookedSlotIds.filter { it > 0 }.distinct().toSet()
|
||||
if (ids.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return searchBooked(
|
||||
timeStart = null,
|
||||
timeStop = null,
|
||||
requestId = null,
|
||||
satelliteIds = null
|
||||
).filter { it.bookedSlotId in ids }
|
||||
}
|
||||
|
||||
|
||||
fun searchBooked(
|
||||
timeStart: LocalDateTime?,
|
||||
timeStop: LocalDateTime?,
|
||||
requestId: String?,
|
||||
satelliteIds: List<Long>?
|
||||
): List<BookedSlotDetailsDTO> = webClientBuilder.build()
|
||||
.get()
|
||||
.uri(
|
||||
UriComponentsBuilder
|
||||
.fromUriString("$url/api/slots/booking/search")
|
||||
.queryParamIfPresent("timeStart", java.util.Optional.ofNullable(timeStart))
|
||||
.queryParamIfPresent("timeStop", java.util.Optional.ofNullable(timeStop))
|
||||
.queryParamIfPresent("requestId", java.util.Optional.ofNullable(requestId?.trim()?.takeIf { it.isNotEmpty() }))
|
||||
.apply {
|
||||
satelliteIds.orEmpty().distinct().forEach { queryParam("satelliteIds", it) }
|
||||
}
|
||||
.build()
|
||||
.toUriString()
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux(BookedSlotDetailsDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
fun bookedByRequest(requestId: String): List<SlotDTO> =
|
||||
webClientBuilder.build()
|
||||
.get()
|
||||
.uri("$url/api/slots/booking/by-request/$requestId")
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.bodyToFlux(SlotDTO::class.java)
|
||||
.collectList()
|
||||
.block()
|
||||
?: emptyList()
|
||||
|
||||
fun bookReq(req: BookingRequestDTO): List<BookedSlotDTO> =
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
@@ -244,6 +324,27 @@ class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
|
||||
}
|
||||
.block()
|
||||
|
||||
|
||||
fun sendSatelliteInitialConditions(satelliteId: Long, time: LocalDateTime) {
|
||||
webClientBuilder.build()
|
||||
.post()
|
||||
.uri(
|
||||
UriComponentsBuilder
|
||||
.fromUriString("$url/api/satellite/{satelliteId}/send-ic")
|
||||
.queryParam("time", time)
|
||||
.buildAndExpand(satelliteId)
|
||||
.toUriString()
|
||||
)
|
||||
.retrieve()
|
||||
.onStatus({ status -> status.isError }) { response ->
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("error")
|
||||
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
|
||||
}
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
}
|
||||
|
||||
fun saveSatelliteInitialConditions(
|
||||
satelliteId: Long,
|
||||
request: InitialConditionsDTO,
|
||||
|
||||
+253
@@ -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
|
||||
}
|
||||
}
|
||||
+105
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,164 @@
|
||||
.bookings-page {
|
||||
height: calc(100vh - 4.25rem);
|
||||
max-height: calc(100vh - 4.25rem);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.bookings-page > .d-flex:first-child,
|
||||
#bookings-alert,
|
||||
.bookings-filter-card {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bookings-layout {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bookings-layout > [class*="col-"] {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookings-layout > [class*="col-"] > .catalog-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bookings-satellites-card,
|
||||
.bookings-table-card {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 17rem);
|
||||
}
|
||||
|
||||
.bookings-satellites-card .card-header,
|
||||
.bookings-table-card .card-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bookings-satellites-wrap,
|
||||
.bookings-table-card .card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookings-satellites-wrap {
|
||||
height: calc(100vh - 23rem);
|
||||
max-height: calc(100vh - 23rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bookings-table-card .card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookings-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 21rem);
|
||||
max-height: calc(100vh - 21rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookings-check-col {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.bookings-satellites-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bookings-selected-row > td {
|
||||
background: rgba(13, 110, 253, 0.12) !important;
|
||||
}
|
||||
|
||||
.bookings-id {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.bookings-main-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bookings-sub-text {
|
||||
color: #6c757d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.bookings-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(108, 117, 125, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
color: #495057;
|
||||
background: rgba(248, 249, 250, 0.9);
|
||||
}
|
||||
|
||||
.bookings-time-cell {
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.bookings-status-booked {
|
||||
border-color: rgba(13, 110, 253, 0.35);
|
||||
color: #084298;
|
||||
background: rgba(13, 110, 253, 0.12);
|
||||
}
|
||||
|
||||
.bookings-status-processed,
|
||||
.bookings-status-finished {
|
||||
border-color: rgba(25, 135, 84, 0.4);
|
||||
color: #0f5132;
|
||||
background: rgba(25, 135, 84, 0.14);
|
||||
}
|
||||
|
||||
.bookings-status-failed,
|
||||
.bookings-status-canceled,
|
||||
.bookings-status-cancelled {
|
||||
border-color: rgba(220, 53, 69, 0.4);
|
||||
color: #842029;
|
||||
background: rgba(220, 53, 69, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.bookings-page {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.bookings-layout,
|
||||
.bookings-layout > [class*="col-"],
|
||||
.bookings-layout > [class*="col-"] > .catalog-card,
|
||||
.bookings-satellites-card,
|
||||
.bookings-table-card,
|
||||
.bookings-satellites-wrap,
|
||||
.bookings-table-wrap,
|
||||
.bookings-table-card .card-body {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
min-height: initial;
|
||||
}
|
||||
|
||||
.bookings-layout > [class*="col-"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,16 @@
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-send-time {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-ic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-status {
|
||||
display: inline-flex;
|
||||
width: 0.75rem;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
.tle-analysis-page {
|
||||
height: calc(100vh - 4.75rem);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tle-analysis-layout {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-analysis-layout > [class*="col-"] {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-analysis-sidebar,
|
||||
.tle-analysis-results-card {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-analysis-filter-body {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tle-analysis-filter-body .form-control {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-wrap,
|
||||
.tle-analysis-results-wrap {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-wrap {
|
||||
flex: 1 1 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.tle-analysis-results-wrap {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
#tle-analysis-table-wrap {
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tle-analysis-results-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-table tbody tr,
|
||||
.tle-analysis-results-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-table tbody tr.active {
|
||||
--bs-table-bg: rgba(13, 110, 253, 0.12);
|
||||
--bs-table-hover-bg: rgba(13, 110, 253, 0.18);
|
||||
}
|
||||
|
||||
.tle-analysis-vector {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tle-analysis-discrepancy {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.tle-analysis-page {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tle-analysis-satellites-wrap,
|
||||
.tle-analysis-results-wrap {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.tle-comparison-page {
|
||||
min-height: calc(100vh - 5.5rem);
|
||||
}
|
||||
|
||||
.tle-input-card,
|
||||
.tle-result-card {
|
||||
border: 0;
|
||||
box-shadow: 0 0.5rem 1.25rem rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.tle-input-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tle-textarea {
|
||||
min-height: 11rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tle-reference {
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tle-reference-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.tle-reference-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.tle-reference-row span {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tle-reference-row strong {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tle-parse-status {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tle-parse-status.tle-parse-ok {
|
||||
color: #198754 !important;
|
||||
}
|
||||
|
||||
.tle-parse-status.tle-parse-error {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.tle-result-table-wrap {
|
||||
max-height: calc(100vh - 30rem);
|
||||
min-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tle-result-table-wrap thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tle-delta-positive {
|
||||
color: #0d6efd;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tle-delta-negative {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.tle-result-table-wrap {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
filteredSatellites: [],
|
||||
missions: [],
|
||||
currentModes: [],
|
||||
bookedSlotsById: {},
|
||||
selectedSatelliteId: null,
|
||||
selectedMissionId: null,
|
||||
selectedMission: null,
|
||||
@@ -168,6 +169,10 @@
|
||||
class="btn btn-outline-success btn-sm current-plans-action-btn"
|
||||
data-action="calculate-drops"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов</button>
|
||||
<button type="button"
|
||||
class="btn btn-success btn-sm current-plans-action-btn"
|
||||
data-action="confirm-mission"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Утвердить</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -184,6 +189,8 @@
|
||||
calculateSurveys(missionId, button);
|
||||
} else if (button.dataset.action === 'calculate-drops') {
|
||||
calculateDrops(missionId, button);
|
||||
} else if (button.dataset.action === 'confirm-mission') {
|
||||
confirmMission(missionId, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -208,6 +215,7 @@
|
||||
try {
|
||||
const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
|
||||
state.currentModes = Array.isArray(modes) ? modes : [];
|
||||
state.bookedSlotsById = await loadBookedSlotsForModes(state.currentModes);
|
||||
renderModes(state.currentModes);
|
||||
setExportEnabled(true);
|
||||
return state.currentModes;
|
||||
@@ -218,6 +226,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookedSlotsForModes(modes) {
|
||||
const bookedSlotIds = [...new Set((modes || []).flatMap((mode) => mode.bookedSlotIds || []))]
|
||||
.filter((slotId) => Number(slotId) > 0);
|
||||
if (!bookedSlotIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
bookedSlotIds.forEach((slotId) => params.append('ids', slotId));
|
||||
|
||||
try {
|
||||
const bookedSlots = await fetchJson(`/api/current-plans/booked-slots?${params.toString()}`);
|
||||
return (bookedSlots || []).reduce((acc, slot) => {
|
||||
acc[slot.bookedSlotId] = slot;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось загрузить детали забронированных слотов.', 'warning');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderModes(modes) {
|
||||
const tbody = el('current-plans-modes-body');
|
||||
if (!tbody) return;
|
||||
@@ -227,35 +257,158 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = modes.map((mode) => {
|
||||
tbody.innerHTML = modes.flatMap((mode) => {
|
||||
const type = mode.type || '—';
|
||||
const duration = formatNumber(mode.duration);
|
||||
const rowClass = modeRowClass(mode);
|
||||
const badgeClass = modeBadgeClass(mode);
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td><span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
|
||||
const expandable = isExpandableMode(mode);
|
||||
const expandMarker = expandable ? '<span class="current-plans-expand-marker">▸</span>' : '';
|
||||
const row = `
|
||||
<tr class="${rowClass}${expandable ? ' current-plans-expandable-row' : ''}" data-mode-id="${escapeHtml(mode.id ?? '')}">
|
||||
<td>${expandMarker}<span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
|
||||
<td>${formatDateTime(mode.timeStart)}</td>
|
||||
<td>${escapeHtml(mode.revolution ?? '—')}</td>
|
||||
<td>${duration}</td>
|
||||
<td class="current-plans-mode-params">${modeParams(mode)}</td>
|
||||
<td>${modeStatus(mode)}</td>
|
||||
</tr>`;
|
||||
const details = expandable ? modeDetailsRow(mode) : null;
|
||||
return details ? [row, details] : [row];
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('.current-plans-expandable-row').forEach((row) => {
|
||||
row.addEventListener('click', () => toggleModeDetails(row.dataset.modeId));
|
||||
});
|
||||
}
|
||||
|
||||
function modeRowClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-mode-row-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-mode-row-slots';
|
||||
if (isSlotsLikeSource(mode.source)) return 'current-plans-mode-row-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function modeBadgeClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-badge-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-badge-slots';
|
||||
if (isSlotsLikeSource(mode.source)) return 'current-plans-badge-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function isSlotsLikeSource(source) {
|
||||
return source === 'SLOTS' || source === 'MIXED';
|
||||
}
|
||||
|
||||
function isExpandableMode(mode) {
|
||||
return (Array.isArray(mode.bookedSlotIds) && mode.bookedSlotIds.length > 0)
|
||||
|| (mode.type === 'DROP' && Array.isArray(mode.surveys) && mode.surveys.length > 0);
|
||||
}
|
||||
|
||||
function toggleModeDetails(modeId) {
|
||||
const row = document.querySelector(`#current-plans-modes-body tr[data-mode-id="${cssEscape(modeId)}"]`);
|
||||
const details = document.querySelector(`#current-plans-modes-body tr[data-parent-mode-id="${cssEscape(modeId)}"]`);
|
||||
if (!row || !details) return;
|
||||
|
||||
const collapsed = details.classList.toggle('d-none');
|
||||
row.classList.toggle('current-plans-expanded-row', !collapsed);
|
||||
const marker = row.querySelector('.current-plans-expand-marker');
|
||||
if (marker) marker.textContent = collapsed ? '▸' : '▾';
|
||||
}
|
||||
|
||||
function modeDetailsRow(mode) {
|
||||
const content = mode.type === 'DROP'
|
||||
? dropDetails(mode)
|
||||
: bookedSlotDetails(mode);
|
||||
|
||||
return `
|
||||
<tr class="current-plans-mode-details-row d-none" data-parent-mode-id="${escapeHtml(mode.id ?? '')}">
|
||||
<td colspan="6">
|
||||
<div class="current-plans-mode-details">${content}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function bookedSlotDetails(mode) {
|
||||
const slotIds = Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds : [];
|
||||
const rows = slotIds.map((slotId) => {
|
||||
const slot = state.bookedSlotsById[slotId];
|
||||
if (!slot) {
|
||||
return `<tr><td class="current-plans-id">#${escapeHtml(slotId)}</td><td colspan="6">Детали слота не найдены</td></tr>`;
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="current-plans-id">#${escapeHtml(slot.bookedSlotId)}</td>
|
||||
<td>${formatDateTime(slot.timeStart)}</td>
|
||||
<td>${formatDuration(slot.durationSeconds)}</td>
|
||||
<td>${escapeHtml(slot.slotNum ?? '—')}</td>
|
||||
<td>${escapeHtml(slot.cycle ?? '—')}</td>
|
||||
<td>${escapeHtml(slot.satelliteId ?? '—')}</td>
|
||||
<td>${escapeHtml(slot.status || '—')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="current-plans-detail-title">Забронированные слоты (${slotIds.length})</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 current-plans-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Начало</th>
|
||||
<th>Длительность</th>
|
||||
<th>Слот</th>
|
||||
<th>Цикл</th>
|
||||
<th>Спутник</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function dropDetails(mode) {
|
||||
const surveyIds = Array.isArray(mode.surveys) ? mode.surveys : [];
|
||||
const surveysById = new Map((state.currentModes || []).map((item) => [Number(item.id), item]));
|
||||
const rows = surveyIds.map((surveyId) => {
|
||||
const survey = surveysById.get(Number(surveyId));
|
||||
if (!survey) {
|
||||
return `<tr><td class="current-plans-id">#${escapeHtml(surveyId)}</td><td colspan="4">Съёмка не найдена в текущем списке режимов</td></tr>`;
|
||||
}
|
||||
|
||||
const booked = Array.isArray(survey.bookedSlotIds) && survey.bookedSlotIds.length
|
||||
? survey.bookedSlotIds.map((slotId) => {
|
||||
const slot = state.bookedSlotsById[slotId];
|
||||
return slot ? `#${slot.bookedSlotId} slot ${slot.slotNum}/cycle ${slot.cycle}` : `#${slotId}`;
|
||||
}).join(', ')
|
||||
: '—';
|
||||
return `
|
||||
<tr>
|
||||
<td class="current-plans-id">#${escapeHtml(survey.id)}</td>
|
||||
<td>${formatDateTime(survey.timeStart)}</td>
|
||||
<td>${escapeHtml(survey.revolution ?? '—')}</td>
|
||||
<td>${escapeHtml(survey.source || '—')}</td>
|
||||
<td>${escapeHtml(booked)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="current-plans-detail-title">Сбрасываемые съёмки (${surveyIds.length})</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 current-plans-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Начало</th>
|
||||
<th>Виток</th>
|
||||
<th>Источник</th>
|
||||
<th>Booked slots</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function modeParams(mode) {
|
||||
if (mode.type === 'DROP') {
|
||||
const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0;
|
||||
@@ -276,7 +429,7 @@
|
||||
if (mode.type === 'DROP') {
|
||||
return '<span class="current-plans-badge current-plans-badge-drop">DROP</span>';
|
||||
}
|
||||
const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : '';
|
||||
const sourceBadgeClass = isSlotsLikeSource(mode.source) ? ' current-plans-badge-slots' : '';
|
||||
return `
|
||||
<div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div>
|
||||
<div><span class="current-plans-badge${sourceBadgeClass}">${escapeHtml(mode.source || '—')}</span></div>`;
|
||||
@@ -379,6 +532,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMission(missionId, button) {
|
||||
setButtonLoading(button, true, 'Утверждение...');
|
||||
try {
|
||||
await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
showAlert('План утверждён.', 'success');
|
||||
if (state.selectedSatelliteId) {
|
||||
await loadMissions(state.selectedSatelliteId, state.missionsPage, missionId);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось утвердить план.', 'danger');
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function exportSelectedMissionCsv() {
|
||||
if (!state.selectedMissionId) {
|
||||
showAlert('Выберите миссию для сохранения CSV.', 'warning');
|
||||
@@ -410,7 +581,7 @@
|
||||
mode.type || '',
|
||||
mode.id ?? '',
|
||||
mode.planId ?? '',
|
||||
mode.timeStart || '',
|
||||
formatDateTime(mode.timeStart),
|
||||
mode.revolution ?? '',
|
||||
mode.duration ?? '',
|
||||
mode.status || '',
|
||||
@@ -469,6 +640,7 @@
|
||||
el('current-plans-modes-body').innerHTML = '';
|
||||
el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.';
|
||||
state.currentModes = [];
|
||||
state.bookedSlotsById = {};
|
||||
setExportEnabled(false);
|
||||
}
|
||||
|
||||
@@ -570,6 +742,12 @@
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—';
|
||||
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (match) {
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
return `${day}.${month}.${year.slice(2)}, ${hour}:${minute}:${second || '00'}`;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return escapeHtml(value);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
@@ -588,6 +766,21 @@
|
||||
return number.toLocaleString('ru-RU', {maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const totalSeconds = Number(seconds);
|
||||
if (!Number.isFinite(totalSeconds)) return '—';
|
||||
|
||||
const rounded = Math.max(0, Math.round(totalSeconds));
|
||||
const hours = Math.floor(rounded / 3600);
|
||||
const minutes = Math.floor((rounded % 3600) / 60);
|
||||
const remainingSeconds = rounded % 60;
|
||||
const pad = (number) => String(number).padStart(2, '0');
|
||||
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(remainingSeconds)}`
|
||||
: `${minutes}:${pad(remainingSeconds)}`;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(date) {
|
||||
const pad = (number) => String(number).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
@@ -652,4 +845,11 @@
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') {
|
||||
return window.CSS.escape(String(value ?? ''));
|
||||
}
|
||||
return String(value ?? '').replaceAll('"', '\\"');
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
checkModels();
|
||||
} else{
|
||||
var sceneMode = (viewer.scene.mode == Cesium.SceneMode.SCENE2D);
|
||||
var u = '/requests/station/'+req.id+'?view='+sceneMode
|
||||
var u = '/requests/station/'+req.id+'?view='+sceneMode+
|
||||
'&orbitHeightKm='+encodeURIComponent(selectedStationOrbitHeightKm())
|
||||
fetch(u, {
|
||||
method: "post",
|
||||
headers: {
|
||||
@@ -115,6 +116,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function selectedStationOrbitHeightKm() {
|
||||
const input = document.getElementById('stationOrbitHeightKm');
|
||||
const value = Number(input?.value || 500);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
if (input) input.value = '500';
|
||||
return 500;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
function drawSat(show, req, startInput, endInput, runId){
|
||||
if (req){
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
return date.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 3) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return '';
|
||||
}
|
||||
return number.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: digits
|
||||
});
|
||||
}
|
||||
|
||||
function selectedMode() {
|
||||
return document.querySelector('input[name="rva-mode"]:checked')?.value || 'common';
|
||||
}
|
||||
@@ -113,7 +124,7 @@
|
||||
<td>${row.durationSec}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else {
|
||||
} else if (resultState.mode === 'merge-on-rev') {
|
||||
head.innerHTML = `
|
||||
<tr>
|
||||
<th>Виток</th>
|
||||
@@ -123,7 +134,24 @@
|
||||
body.innerHTML = resultState.rows.map(row => `
|
||||
<tr>
|
||||
<td>${row.revolution}</td>
|
||||
<td>${row.durationSec}</td>
|
||||
<td>${formatNumber(row.durationSec, 1)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else if (resultState.mode === 'average') {
|
||||
head.innerHTML = `
|
||||
<tr>
|
||||
<th>№ППИ</th>
|
||||
<th>Широта ППИ</th>
|
||||
<th>Сред.кол-во</th>
|
||||
<th>Сред.длит., с</th>
|
||||
</tr>
|
||||
`;
|
||||
body.innerHTML = resultState.rows.map(row => `
|
||||
<tr>
|
||||
<td>${row.ppiNumber}</td>
|
||||
<td>${formatNumber(row.ppiLatitude, 6)}</td>
|
||||
<td>${formatNumber(row.averageCount, 3)}</td>
|
||||
<td>${formatNumber(row.averageDurationSec, 1)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
@@ -145,8 +173,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = resultState.mode === 'common'
|
||||
? [
|
||||
let lines;
|
||||
if (resultState.mode === 'common') {
|
||||
lines = [
|
||||
['stationId', 'revolution', 'onStart', 'onMaximum', 'onStop', 'duration'],
|
||||
...resultState.rows.map(row => [
|
||||
row.stationId,
|
||||
@@ -156,23 +185,35 @@
|
||||
row.onStop.time,
|
||||
row.duration
|
||||
])
|
||||
]
|
||||
: resultState.mode === 'merge'
|
||||
? [
|
||||
];
|
||||
} else if (resultState.mode === 'merge') {
|
||||
lines = [
|
||||
['begin', 'end', 'durationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.begin,
|
||||
row.end,
|
||||
row.durationSec
|
||||
])
|
||||
]
|
||||
: [
|
||||
];
|
||||
} else if (resultState.mode === 'merge-on-rev') {
|
||||
lines = [
|
||||
['revolution', 'durationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.revolution,
|
||||
row.durationSec
|
||||
])
|
||||
];
|
||||
} else {
|
||||
lines = [
|
||||
['ppiNumber', 'ppiLatitude', 'averageCount', 'averageDurationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.ppiNumber,
|
||||
row.ppiLatitude,
|
||||
row.averageCount,
|
||||
row.averageDurationSec
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
const csv = lines.map(line => line.map(csvEscape).join(';')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
selectedSatelliteId: null,
|
||||
details: null
|
||||
};
|
||||
const sendInitialConditionsDateTimePattern = /^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/;
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('satellite-pdcm-alert');
|
||||
@@ -58,6 +59,121 @@
|
||||
return value ? value.replace('T', ' ') : '-';
|
||||
}
|
||||
|
||||
function pad(value, length = 2) {
|
||||
return String(value).padStart(length, '0');
|
||||
}
|
||||
|
||||
function currentDateTimeInputValue() {
|
||||
const now = new Date();
|
||||
return `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
|
||||
}
|
||||
|
||||
function formatDateTimeInput(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const match = String(value).match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?/
|
||||
);
|
||||
if (!match) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second = '00', millis = '000'] = match;
|
||||
return `${day}.${month}.${year} ${hour}:${minute}:${second}.${millis.slice(0, 3).padEnd(3, '0')}`;
|
||||
}
|
||||
|
||||
function parseSendInitialConditionsTime() {
|
||||
const input = document.getElementById('satellite-pdcm-send-ic-time');
|
||||
const value = input?.value.trim() || '';
|
||||
const match = value.match(sendInitialConditionsDateTimePattern);
|
||||
if (!match) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Введите время НУ в формате dd.mm.yyyy hh:MM:ss.zzz'
|
||||
};
|
||||
}
|
||||
|
||||
const [, day, month, year, hour, minute, second, millisecond] = match;
|
||||
const parsed = new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second),
|
||||
Number(millisecond)
|
||||
);
|
||||
const validDate = parsed.getFullYear() === Number(year)
|
||||
&& parsed.getMonth() === Number(month) - 1
|
||||
&& parsed.getDate() === Number(day)
|
||||
&& parsed.getHours() === Number(hour)
|
||||
&& parsed.getMinutes() === Number(minute)
|
||||
&& parsed.getSeconds() === Number(second)
|
||||
&& parsed.getMilliseconds() === Number(millisecond);
|
||||
|
||||
if (!validDate) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Время НУ содержит некорректную дату или время'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
text: value
|
||||
};
|
||||
}
|
||||
|
||||
function parseDateTimeForPayload(value, fieldName) {
|
||||
const text = value?.trim() || '';
|
||||
const match = text.match(sendInitialConditionsDateTimePattern);
|
||||
if (!match) {
|
||||
throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:MM:ss.zzz`);
|
||||
}
|
||||
|
||||
const [, day, month, year, hour, minute, second, millisecond] = match;
|
||||
const parsed = new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second),
|
||||
Number(millisecond)
|
||||
);
|
||||
const validDate = parsed.getFullYear() === Number(year)
|
||||
&& parsed.getMonth() === Number(month) - 1
|
||||
&& parsed.getDate() === Number(day)
|
||||
&& parsed.getHours() === Number(hour)
|
||||
&& parsed.getMinutes() === Number(minute)
|
||||
&& parsed.getSeconds() === Number(second)
|
||||
&& parsed.getMilliseconds() === Number(millisecond);
|
||||
|
||||
if (!validDate) {
|
||||
throw new Error(`${fieldName}: некорректная дата или время`);
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}`;
|
||||
}
|
||||
|
||||
function parseRequiredNumber(inputId, fieldName) {
|
||||
const value = document.getElementById(inputId)?.value;
|
||||
const number = Number(value);
|
||||
if (value === '' || !Number.isFinite(number)) {
|
||||
throw new Error(`${fieldName}: укажите число`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseRequiredInteger(inputId, fieldName) {
|
||||
const number = parseRequiredNumber(inputId, fieldName);
|
||||
if (!Number.isInteger(number)) {
|
||||
throw new Error(`${fieldName}: укажите целое число`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value ?? 0).toFixed(3);
|
||||
}
|
||||
@@ -71,6 +187,39 @@
|
||||
exportButton.disabled = !details || !details.ascNodes || !details.ascNodes.length;
|
||||
}
|
||||
|
||||
function renderSendInitialConditionsState(details, loading = false) {
|
||||
const button = document.getElementById('satellite-pdcm-send-ic');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = parseSendInitialConditionsTime();
|
||||
const enabled = !!details && time.valid && !loading;
|
||||
button.disabled = !enabled;
|
||||
button.textContent = loading ? 'Передача...' : 'Передать НУ в slot-service';
|
||||
if (!details) {
|
||||
button.title = 'Выберите спутник';
|
||||
} else if (!time.valid) {
|
||||
button.title = time.message;
|
||||
} else {
|
||||
button.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSetInitialConditionsState(details, loading = false) {
|
||||
const openButton = document.getElementById('satellite-pdcm-open-set-ic');
|
||||
const submitButton = document.getElementById('satellite-pdcm-set-ic-submit');
|
||||
const satelliteSelected = !!state.selectedSatelliteId;
|
||||
if (openButton) {
|
||||
openButton.disabled = !satelliteSelected || loading;
|
||||
openButton.title = satelliteSelected ? '' : 'Выберите спутник';
|
||||
}
|
||||
if (submitButton) {
|
||||
submitButton.disabled = loading;
|
||||
submitButton.textContent = loading ? 'Запуск...' : 'Запустить расчет';
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const tableBody = document.getElementById('satellite-pdcm-list');
|
||||
tableBody.innerHTML = '';
|
||||
@@ -110,6 +259,8 @@
|
||||
if (!details) {
|
||||
summary.innerHTML = '<div class="catalog-helper">Выберите спутник слева.</div>';
|
||||
renderExportState(null);
|
||||
renderSendInitialConditionsState(null);
|
||||
renderSetInitialConditionsState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,6 +291,8 @@
|
||||
</div>
|
||||
`;
|
||||
renderExportState(details);
|
||||
renderSendInitialConditionsState(details);
|
||||
renderSetInitialConditionsState(details);
|
||||
}
|
||||
|
||||
function renderTable(details) {
|
||||
@@ -270,9 +423,149 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function sendInitialConditions() {
|
||||
if (!state.selectedSatelliteId || !state.details) {
|
||||
showAlert('Выберите спутник для передачи начальных условий');
|
||||
return;
|
||||
}
|
||||
const time = parseSendInitialConditionsTime();
|
||||
if (!time.valid) {
|
||||
showAlert(time.message);
|
||||
return;
|
||||
}
|
||||
renderSendInitialConditionsState(state.details, true);
|
||||
showAlert('');
|
||||
try {
|
||||
await requestJson(`${apiBase}/${state.selectedSatelliteId}/send-ic?time=${encodeURIComponent(time.text)}`, {method: 'POST'});
|
||||
showAlert('Начальные условия спутника переданы в slot-service', 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось передать начальные условия в slot-service');
|
||||
} finally {
|
||||
renderSendInitialConditionsState(state.details);
|
||||
}
|
||||
}
|
||||
|
||||
function resetInitialConditionsForm() {
|
||||
document.getElementById('satellite-pdcm-ic-time').value = currentDateTimeInputValue();
|
||||
document.getElementById('satellite-pdcm-ic-revolution').value = '';
|
||||
document.getElementById('satellite-pdcm-ic-movement-model').value = 'FOTO';
|
||||
document.getElementById('satellite-pdcm-ic-s-ball').value = '0';
|
||||
document.getElementById('satellite-pdcm-ic-f81').value = '147.8';
|
||||
['x', 'y', 'z', 'vx', 'vy', 'vz'].forEach(field => {
|
||||
document.getElementById(`satellite-pdcm-ic-${field}`).value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function fillInitialConditionsForm(initialConditions) {
|
||||
document.getElementById('satellite-pdcm-ic-time').value =
|
||||
formatDateTimeInput(initialConditions?.ic?.orbPoint?.time) || currentDateTimeInputValue();
|
||||
document.getElementById('satellite-pdcm-ic-revolution').value =
|
||||
initialConditions?.ic?.orbPoint?.revolution ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-movement-model').value =
|
||||
initialConditions?.movementModel || 'FOTO';
|
||||
document.getElementById('satellite-pdcm-ic-s-ball').value =
|
||||
initialConditions?.ic?.sBall ?? '0';
|
||||
document.getElementById('satellite-pdcm-ic-f81').value =
|
||||
initialConditions?.ic?.f81 ?? '147.8';
|
||||
document.getElementById('satellite-pdcm-ic-x').value = initialConditions?.ic?.orbPoint?.x ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-y').value = initialConditions?.ic?.orbPoint?.y ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-z').value = initialConditions?.ic?.orbPoint?.z ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-vx').value = initialConditions?.ic?.orbPoint?.vx ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-vy').value = initialConditions?.ic?.orbPoint?.vy ?? '';
|
||||
document.getElementById('satellite-pdcm-ic-vz').value = initialConditions?.ic?.orbPoint?.vz ?? '';
|
||||
}
|
||||
|
||||
async function openSetInitialConditionsModal() {
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('Выберите спутник для задания начальных условий');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedSatellite = state.details?.satelliteId === state.selectedSatelliteId
|
||||
? state.details
|
||||
: state.satellites.find(satellite => satellite.satelliteId === state.selectedSatelliteId);
|
||||
document.getElementById('satellite-pdcm-set-ic-target').textContent =
|
||||
selectedSatellite
|
||||
? `${selectedSatellite.name} · ID ${selectedSatellite.satelliteId}`
|
||||
: `ID ${state.selectedSatelliteId}`;
|
||||
resetInitialConditionsForm();
|
||||
|
||||
if (window.bootstrap) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(document.getElementById('satellite-pdcm-set-ic-modal')).show();
|
||||
}
|
||||
|
||||
try {
|
||||
const currentInitialConditions = await requestJson(`${apiBase}/${state.selectedSatelliteId}/initial-conditions`);
|
||||
if (currentInitialConditions) {
|
||||
fillInitialConditionsForm(currentInitialConditions);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось загрузить актуальные начальные условия');
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialConditionsPayload() {
|
||||
const movementModel = document.getElementById('satellite-pdcm-ic-movement-model').value || 'FOTO';
|
||||
return {
|
||||
satelliteId: state.selectedSatelliteId,
|
||||
movementModel,
|
||||
ic: {
|
||||
orbPoint: {
|
||||
time: parseDateTimeForPayload(document.getElementById('satellite-pdcm-ic-time').value, 'Time'),
|
||||
revolution: parseRequiredInteger('satellite-pdcm-ic-revolution', 'Revolution'),
|
||||
vx: parseRequiredNumber('satellite-pdcm-ic-vx', 'Vx'),
|
||||
vy: parseRequiredNumber('satellite-pdcm-ic-vy', 'Vy'),
|
||||
vz: parseRequiredNumber('satellite-pdcm-ic-vz', 'Vz'),
|
||||
x: parseRequiredNumber('satellite-pdcm-ic-x', 'X'),
|
||||
y: parseRequiredNumber('satellite-pdcm-ic-y', 'Y'),
|
||||
z: parseRequiredNumber('satellite-pdcm-ic-z', 'Z')
|
||||
},
|
||||
sBall: parseRequiredNumber('satellite-pdcm-ic-s-ball', 'S-ball'),
|
||||
f81: parseRequiredNumber('satellite-pdcm-ic-f81', 'F81')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function submitInitialConditions(event) {
|
||||
event.preventDefault();
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('Выберите спутник для задания начальных условий');
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = buildInitialConditionsPayload();
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Проверьте параметры начальных условий');
|
||||
return;
|
||||
}
|
||||
|
||||
renderSetInitialConditionsState(state.details, true);
|
||||
showAlert('');
|
||||
try {
|
||||
await requestJson(`${apiBase}/${state.selectedSatelliteId}/initial-conditions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const modalElement = document.getElementById('satellite-pdcm-set-ic-modal');
|
||||
if (window.bootstrap && modalElement) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modalElement).hide();
|
||||
}
|
||||
showAlert('Расчет ПДЦМ по начальным условиям запущен', 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось запустить расчет ПДЦМ по начальным условиям');
|
||||
} finally {
|
||||
renderSetInitialConditionsState(state.details);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSatellite(satelliteId) {
|
||||
state.selectedSatelliteId = satelliteId;
|
||||
state.details = null;
|
||||
renderList();
|
||||
renderSendInitialConditionsState(null);
|
||||
renderSetInitialConditionsState(null);
|
||||
showAlert('');
|
||||
|
||||
try {
|
||||
@@ -284,6 +577,7 @@
|
||||
state.details = null;
|
||||
renderSummary(null);
|
||||
renderTable(null);
|
||||
renderSetInitialConditionsState(null);
|
||||
showAlert(error.message || 'Не удалось загрузить ПДЦМ спутника');
|
||||
}
|
||||
}
|
||||
@@ -299,12 +593,14 @@
|
||||
if (nextSatelliteId) {
|
||||
await selectSatellite(nextSatelliteId);
|
||||
} else {
|
||||
state.selectedSatelliteId = null;
|
||||
state.details = null;
|
||||
renderSummary(null);
|
||||
renderTable(null);
|
||||
}
|
||||
} catch (error) {
|
||||
state.satellites = [];
|
||||
state.selectedSatelliteId = null;
|
||||
state.details = null;
|
||||
renderList();
|
||||
renderSummary(null);
|
||||
@@ -317,6 +613,12 @@
|
||||
loadSatellites();
|
||||
});
|
||||
document.getElementById('satellite-pdcm-export').addEventListener('click', exportCurrentCsv);
|
||||
document.getElementById('satellite-pdcm-send-ic').addEventListener('click', sendInitialConditions);
|
||||
document.getElementById('satellite-pdcm-open-set-ic').addEventListener('click', openSetInitialConditionsModal);
|
||||
document.getElementById('satellite-pdcm-set-ic-form').addEventListener('submit', submitInitialConditions);
|
||||
const sendInitialConditionsTimeInput = document.getElementById('satellite-pdcm-send-ic-time');
|
||||
sendInitialConditionsTimeInput.value = currentDateTimeInputValue();
|
||||
sendInitialConditionsTimeInput.addEventListener('input', () => renderSendInitialConditionsState(state.details));
|
||||
|
||||
loadSatellites();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
(function () {
|
||||
const state = {
|
||||
satellites: [],
|
||||
selectedSatelliteId: null,
|
||||
analysis: null,
|
||||
loading: false
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
async function init() {
|
||||
bindEvents();
|
||||
await loadSatellites();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById('tle-analysis-refresh')?.addEventListener('click', async () => {
|
||||
state.selectedSatelliteId = null;
|
||||
state.analysis = null;
|
||||
state.loading = false;
|
||||
renderResults();
|
||||
await loadSatellites();
|
||||
});
|
||||
document.getElementById('tle-analysis-satellite-filter')?.addEventListener('input', renderSatellites);
|
||||
document.getElementById('tle-analysis-count')?.addEventListener('change', async () => {
|
||||
if (state.selectedSatelliteId) {
|
||||
await loadAnalysis(state.selectedSatelliteId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSatellites() {
|
||||
showAlert('Загрузка списка спутников...', 'info');
|
||||
try {
|
||||
const response = await fetch('/api/tle-analysis/satellites');
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorText(response));
|
||||
}
|
||||
state.satellites = normalizeSatelliteResponse(await response.json());
|
||||
renderSatellites();
|
||||
renderResults();
|
||||
clearAlert();
|
||||
} catch (error) {
|
||||
showAlert(`Не удалось загрузить спутники: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnalysis(satelliteId) {
|
||||
state.loading = true;
|
||||
state.analysis = null;
|
||||
renderResults();
|
||||
showAlert('Формирование анализа TLE...', 'info');
|
||||
try {
|
||||
const params = new URLSearchParams({ tleCount: String(selectedTleCount()) });
|
||||
const response = await fetch(`/api/tle-analysis/satellites/${encodeURIComponent(satelliteId)}/analysis?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorText(response));
|
||||
}
|
||||
state.analysis = await response.json();
|
||||
renderResults();
|
||||
clearAlert();
|
||||
} catch (error) {
|
||||
state.analysis = null;
|
||||
showAlert(`Не удалось сформировать анализ TLE: ${error.message}`, 'danger');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSatellites() {
|
||||
const tbody = document.getElementById('tle-analysis-satellites-body');
|
||||
if (!tbody) return;
|
||||
|
||||
const filter = normalize(document.getElementById('tle-analysis-satellite-filter')?.value || '');
|
||||
const satellites = state.satellites.filter((satellite) => {
|
||||
if (!filter) return true;
|
||||
return normalize([
|
||||
satellite.id,
|
||||
satellite.catalogId,
|
||||
satellite.noradId,
|
||||
satellite.code,
|
||||
satellite.name,
|
||||
satellite.typeCode,
|
||||
satellite.tleRecordsCount
|
||||
].join(' ')).includes(filter);
|
||||
});
|
||||
|
||||
if (satellites.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="3" class="catalog-empty">Спутники не найдены</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = satellites.map((satellite) => {
|
||||
const active = Number(satellite.id) === Number(state.selectedSatelliteId) ? 'active' : '';
|
||||
const title = escapeHtml(satellite.name || satellite.code || `КА ${satellite.id}`);
|
||||
const catalogId = satellite.catalogId == null ? satellite.id : satellite.catalogId;
|
||||
const tleCount = Number(satellite.tleRecordsCount || 0);
|
||||
return `
|
||||
<tr class="${active}" data-satellite-id="${escapeHtml(satellite.id)}">
|
||||
<td>${escapeHtml(catalogId)}</td>
|
||||
<td>
|
||||
<div class="catalog-main-text">${title}</div>
|
||||
<div class="catalog-helper">${escapeHtml(satellite.code || '')} ${satellite.typeCode ? ' / ' + escapeHtml(satellite.typeCode) : ''}</div>
|
||||
<div class="catalog-helper">TLE-записей: ${escapeHtml(tleCount)}</div>
|
||||
</td>
|
||||
<td>${satellite.noradId == null ? '—' : escapeHtml(satellite.noradId)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('tr[data-satellite-id]').forEach((row) => {
|
||||
row.addEventListener('click', async () => {
|
||||
const satelliteId = Number(row.dataset.satelliteId);
|
||||
state.selectedSatelliteId = satelliteId;
|
||||
state.analysis = null;
|
||||
renderSatellites();
|
||||
renderSelectedSatellite();
|
||||
await loadAnalysis(satelliteId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedSatellite() {
|
||||
const label = document.getElementById('tle-analysis-selected-satellite');
|
||||
if (!label) return;
|
||||
const satellite = state.satellites.find((item) => Number(item.id) === Number(state.selectedSatelliteId));
|
||||
if (!satellite) {
|
||||
label.textContent = 'Выберите спутник слева.';
|
||||
return;
|
||||
}
|
||||
const name = satellite.name || satellite.code || `КА ${satellite.id}`;
|
||||
const catalogId = satellite.catalogId == null ? satellite.id : satellite.catalogId;
|
||||
label.textContent = `КА ${catalogId}: ${name}${satellite.noradId == null ? '' : `, NORAD ${satellite.noradId}`}`;
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
renderSelectedSatellite();
|
||||
const empty = document.getElementById('tle-analysis-empty');
|
||||
const wrap = document.getElementById('tle-analysis-table-wrap');
|
||||
const tbody = document.getElementById('tle-analysis-results-body');
|
||||
const summary = document.getElementById('tle-analysis-summary');
|
||||
if (!empty || !wrap || !tbody || !summary) return;
|
||||
|
||||
if (state.loading) {
|
||||
empty.textContent = 'Формирование анализа TLE...';
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
summary.textContent = '';
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = state.analysis?.rows || [];
|
||||
summary.textContent = rows.length ? `Записей TLE: ${rows.length}` : '';
|
||||
|
||||
if (!state.selectedSatelliteId) {
|
||||
empty.textContent = 'Выберите спутник для загрузки TLE.';
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = 'Для выбранного спутника TLE не найдены.';
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.classList.add('d-none');
|
||||
wrap.classList.remove('d-none');
|
||||
tbody.innerHTML = rows.map(renderAnalysisRow).join('');
|
||||
}
|
||||
|
||||
function renderAnalysisRow(row) {
|
||||
const r = row.radiusVector || {};
|
||||
const discrepancy = row.discrepancy;
|
||||
const diffText = discrepancy
|
||||
? `<div class="tle-analysis-discrepancy">${formatNumber(discrepancy.norm, 3)}</div>
|
||||
<div class="catalog-helper tle-analysis-vector">Δx=${formatNumber(discrepancy.dx, 3)}, Δy=${formatNumber(discrepancy.dy, 3)}, Δz=${formatNumber(discrepancy.dz, 3)}</div>
|
||||
<div class="catalog-helper">к предыдущей эпохе ${escapeHtml(formatDateTime(discrepancy.previousEpoch))}</div>`
|
||||
: '<span class="catalog-helper">не рассчитывается</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(row.index)}</td>
|
||||
<td>
|
||||
<div>${escapeHtml(formatDateTime(row.epoch))}</div>
|
||||
${row.tleHeader ? `<div class="catalog-helper">${escapeHtml(row.tleHeader)}</div>` : ''}
|
||||
</td>
|
||||
<td>${escapeHtml(row.revolution)}</td>
|
||||
<td>${escapeHtml(row.noradId)}</td>
|
||||
<td class="tle-analysis-vector">
|
||||
x=${formatNumber(r.x, 3)}<br>
|
||||
y=${formatNumber(r.y, 3)}<br>
|
||||
z=${formatNumber(r.z, 3)}
|
||||
</td>
|
||||
<td>${formatNumber(row.radiusNorm, 3)}</td>
|
||||
<td>${discrepancy ? formatNumber(discrepancy.epochDifferenceHours, 3) : '—'}</td>
|
||||
<td>${diffText}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function normalizeSatelliteResponse(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items;
|
||||
}
|
||||
if (Array.isArray(payload?.content)) {
|
||||
return payload.content;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function selectedTleCount() {
|
||||
const input = document.getElementById('tle-analysis-count');
|
||||
const value = Number(input?.value || 5);
|
||||
if (!Number.isFinite(value) || value < 1) {
|
||||
if (input) input.value = '5';
|
||||
return 5;
|
||||
}
|
||||
const count = Math.floor(value);
|
||||
if (input && String(count) !== input.value) {
|
||||
input.value = String(count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—';
|
||||
return String(value).replace('T', ' ');
|
||||
}
|
||||
|
||||
function formatNumber(value, fractionDigits) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '—';
|
||||
return number.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits
|
||||
});
|
||||
}
|
||||
|
||||
async function errorText(response) {
|
||||
const text = await response.text();
|
||||
if (!text) return `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json.message || json.error || text;
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alert = document.getElementById('tle-analysis-alert');
|
||||
if (!alert) return;
|
||||
alert.innerHTML = `<div class="alert alert-${type} py-2" role="alert">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function clearAlert() {
|
||||
const alert = document.getElementById('tle-analysis-alert');
|
||||
if (alert) alert.innerHTML = '';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Бронирования</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/bookings.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="bookings-page" class="catalog-page bookings-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Бронирования</h2>
|
||||
<div class="catalog-helper">Табличный просмотр забронированных слотов с фильтрацией по времени, заявке и спутникам.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button id="bookings-reset" type="button" class="btn btn-outline-secondary">Сбросить</button>
|
||||
<button id="bookings-refresh" type="button" class="btn btn-primary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bookings-alert"></div>
|
||||
|
||||
<div class="card catalog-card bookings-filter-card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-time-start" class="form-label">Начало интервала</label>
|
||||
<input id="bookings-time-start" type="datetime-local" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-time-stop" class="form-label">Конец интервала</label>
|
||||
<input id="bookings-time-stop" type="datetime-local" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-request" class="form-label">Заявка</label>
|
||||
<select id="bookings-request" class="form-select form-select-sm">
|
||||
<option value="">Все заявки</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bookings-satellite-search" class="form-label">Фильтр спутников</label>
|
||||
<input id="bookings-satellite-search"
|
||||
type="search"
|
||||
class="form-control form-control-sm"
|
||||
autocomplete="off"
|
||||
placeholder="ID, NORAD, код, имя, тип">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 bookings-layout">
|
||||
<div class="col-12 col-xl-3">
|
||||
<div class="card catalog-card bookings-satellites-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<div class="catalog-section-title">Спутники</div>
|
||||
<div class="catalog-helper mt-1">Выберите один или несколько КА.</div>
|
||||
</div>
|
||||
<button id="bookings-satellites-clear" type="button" class="btn btn-outline-secondary btn-sm">Выбрать все</button>
|
||||
</div>
|
||||
<div class="card-body p-0 bookings-satellites-wrap">
|
||||
<table class="table table-hover mb-0 bookings-satellites-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="bookings-check-col"></th>
|
||||
<th>ID</th>
|
||||
<th>Спутник</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookings-satellites-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-9">
|
||||
<div class="card catalog-card bookings-table-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Забронированные слоты</div>
|
||||
<div id="bookings-summary" class="catalog-helper mt-1">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="bookings-empty" class="catalog-empty d-none">Забронированные слоты не найдены.</div>
|
||||
<div id="bookings-table-wrap" class="table-responsive bookings-table-wrap">
|
||||
<table class="table table-hover mb-0 bookings-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Бронь</th>
|
||||
<th>КА</th>
|
||||
<th>Слот / цикл</th>
|
||||
<th>Время</th>
|
||||
<th>Виток</th>
|
||||
<th>Крен</th>
|
||||
<th>Статус</th>
|
||||
<th>Заявки</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bookings-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/bookings_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,6 +46,8 @@
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="/satellites">Управление</a></li>
|
||||
<li><a class="dropdown-item" href="/satellites/pdcm">ПДЦМ</a></li>
|
||||
<li><a class="dropdown-item" href="/tle-comparison">Сравнение TLE</a></li>
|
||||
<li><a class="dropdown-item" href="/tle-analysis">Анализ TLE</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -74,6 +76,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/tgu-planning">Планирование ТГУ</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/bookings">Бронирования</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -288,13 +288,23 @@
|
||||
<input class="form-check-input" type="checkbox" id="toggleStations" checked>
|
||||
<label class="form-check-label" for="toggleStations">Показать станции</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="toggleCoverage">
|
||||
<label class="form-check-label" for="toggleCoverage">Показать зоны покрытия</label>
|
||||
</div>
|
||||
<select class="form-select form-select-sm mb-2">
|
||||
<option selected>Все типы станций</option>
|
||||
<option value="1">Наземные</option>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="toggleCoverage">
|
||||
<label class="form-check-label" for="toggleCoverage">Показать зоны покрытия</label>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="stationOrbitHeightKm" class="form-label form-label-sm mb-1">Высота орбиты, км</label>
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
id="stationOrbitHeightKm"
|
||||
min="1"
|
||||
step="1"
|
||||
value="500"
|
||||
onchange="handleStationOrbitHeightChange()">
|
||||
</div>
|
||||
<select class="form-select form-select-sm mb-2">
|
||||
<option selected>Все типы станций</option>
|
||||
<option value="1">Наземные</option>
|
||||
<option value="2">Мобильные</option>
|
||||
<option value="3">Контрольные</option>
|
||||
</select>
|
||||
@@ -1701,16 +1711,23 @@
|
||||
}
|
||||
|
||||
|
||||
function checkStation(show, st_id) {
|
||||
const sts = [[${stations}]];
|
||||
const req = sts.find(function(element) {
|
||||
return element.id == st_id;
|
||||
});
|
||||
function checkStation(show, st_id) {
|
||||
const sts = [[${stations}]];
|
||||
const req = sts.find(function(element) {
|
||||
return element.id == st_id;
|
||||
});
|
||||
if (req) {
|
||||
console.log('изменить видимость станции ' + req.id)
|
||||
drawSt(show, req);
|
||||
}
|
||||
}
|
||||
drawSt(show, req);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStationOrbitHeightChange() {
|
||||
document.querySelectorAll('#stationsTableBody input[type="checkbox"]:checked').forEach(function(checkbox) {
|
||||
checkStation(false, checkbox.value);
|
||||
checkStation(true, checkbox.value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Функция для обработки запросов
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-merge-on-rev" value="merge-on-rev">
|
||||
<label class="form-check-label" for="rva-mode-merge-on-rev">Суммарная длительность на витке</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-average" value="average">
|
||||
<label class="form-check-label" for="rva-mode-average">Средняя длительность</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,22 @@
|
||||
<h2 class="mb-1">Параметры движения центра масс</h2>
|
||||
<div class="catalog-helper">Табличное представление asc-node по выбранному спутнику.</div>
|
||||
</div>
|
||||
<button id="satellite-pdcm-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
<div class="d-flex gap-2 flex-wrap justify-content-end align-items-end satellite-pdcm-send-controls">
|
||||
<div>
|
||||
<label for="satellite-pdcm-send-ic-time" class="form-label catalog-helper mb-1">Время НУ</label>
|
||||
<input
|
||||
id="satellite-pdcm-send-ic-time"
|
||||
type="text"
|
||||
class="form-control satellite-pdcm-send-time"
|
||||
inputmode="numeric"
|
||||
placeholder="dd.mm.yyyy hh:MM:ss.zzz"
|
||||
aria-label="Время НУ"
|
||||
/>
|
||||
</div>
|
||||
<button id="satellite-pdcm-send-ic" type="button" class="btn btn-outline-primary" disabled>Передать НУ в slot-service</button>
|
||||
<button id="satellite-pdcm-open-set-ic" type="button" class="btn btn-primary" disabled>Задать НУ</button>
|
||||
<button id="satellite-pdcm-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="satellite-pdcm-alert"></div>
|
||||
@@ -66,6 +81,84 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="satellite-pdcm-set-ic-modal" tabindex="-1" aria-labelledby="satellite-pdcm-set-ic-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<form id="satellite-pdcm-set-ic-form" class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="satellite-pdcm-set-ic-title">Задать начальные условия</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="satellite-pdcm-set-ic-target" class="catalog-helper mb-3">Выберите спутник слева.</div>
|
||||
|
||||
<div class="satellite-pdcm-ic-grid">
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-time" class="form-label">Time</label>
|
||||
<input id="satellite-pdcm-ic-time" type="text" class="form-control" placeholder="dd.mm.yyyy hh:mm:ss.zzz" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-revolution" class="form-label">Revolution</label>
|
||||
<input id="satellite-pdcm-ic-revolution" type="number" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-movement-model" class="form-label">Модель движения</label>
|
||||
<select id="satellite-pdcm-ic-movement-model" class="form-select" required>
|
||||
<option value="FOTO" selected>FOTO</option>
|
||||
<option value="KONDOR">KONDOR</option>
|
||||
<option value="METEORM1">METEORM1</option>
|
||||
<option value="METEORM2">METEORM2</option>
|
||||
<option value="BARS">BARS</option>
|
||||
<option value="KONDOR_PROGNOZ">KONDOR_PROGNOZ</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-s-ball" class="form-label">S-ball</label>
|
||||
<input id="satellite-pdcm-ic-s-ball" type="number" step="any" class="form-control" value="0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-f81" class="form-label">F81</label>
|
||||
<input id="satellite-pdcm-ic-f81" type="number" step="any" class="form-control" value="147.8" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="satellite-pdcm-ic-grid mt-3">
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-x" class="form-label">X</label>
|
||||
<input id="satellite-pdcm-ic-x" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-y" class="form-label">Y</label>
|
||||
<input id="satellite-pdcm-ic-y" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-z" class="form-label">Z</label>
|
||||
<input id="satellite-pdcm-ic-z" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="satellite-pdcm-ic-grid mt-3">
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-vx" class="form-label">Vx</label>
|
||||
<input id="satellite-pdcm-ic-vx" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-vy" class="form-label">Vy</label>
|
||||
<input id="satellite-pdcm-ic-vy" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-pdcm-ic-vz" class="form-label">Vz</label>
|
||||
<input id="satellite-pdcm-ic-vz" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button id="satellite-pdcm-set-ic-submit" type="submit" class="btn btn-primary">Запустить расчет</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/satellite_pdcm_scripts.js"></script>
|
||||
</th:block>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Анализ TLE</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/tle-analysis.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="tle-analysis-page" class="catalog-page tle-analysis-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Анализ TLE</h2>
|
||||
<div class="catalog-helper">Анализ изменения TLE выбранного спутника по разнице рассчитанных радиус-векторов.</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-end gap-2 flex-wrap">
|
||||
<div>
|
||||
<label for="tle-analysis-count" class="form-label mb-1 small text-muted">Количество TLE</label>
|
||||
<input id="tle-analysis-count"
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
min="1"
|
||||
step="1"
|
||||
value="5">
|
||||
</div>
|
||||
<button id="tle-analysis-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tle-analysis-alert"></div>
|
||||
|
||||
<div class="row g-3 tle-analysis-layout">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card catalog-card tle-analysis-sidebar h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Спутники</div>
|
||||
<div class="catalog-helper mt-1">Выберите КА для формирования аналитической таблицы TLE.</div>
|
||||
</div>
|
||||
<div class="card-body p-2 tle-analysis-filter-body">
|
||||
<label for="tle-analysis-satellite-filter" class="form-label mb-1 small text-muted">Фильтр</label>
|
||||
<input id="tle-analysis-satellite-filter"
|
||||
type="search"
|
||||
class="form-control form-control-sm"
|
||||
autocomplete="off"
|
||||
placeholder="ID, NORAD, код, имя, тип">
|
||||
</div>
|
||||
<div class="card-body p-0 tle-analysis-satellites-wrap">
|
||||
<table class="table table-hover mb-0 tle-analysis-satellites-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Спутник</th>
|
||||
<th>NORAD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tle-analysis-satellites-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card catalog-card tle-analysis-results-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Таблица анализа TLE</div>
|
||||
<div id="tle-analysis-selected-satellite" class="catalog-helper mt-1">Выберите спутник слева.</div>
|
||||
</div>
|
||||
<div id="tle-analysis-summary" class="catalog-helper text-end"></div>
|
||||
</div>
|
||||
<div class="card-body p-0 tle-analysis-results-wrap">
|
||||
<div id="tle-analysis-empty" class="catalog-empty">Выберите спутник для загрузки TLE.</div>
|
||||
<div id="tle-analysis-table-wrap" class="table-responsive d-none">
|
||||
<table class="table table-hover table-sm mb-0 tle-analysis-results-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Эпоха</th>
|
||||
<th>Виток</th>
|
||||
<th>NORAD</th>
|
||||
<th>Радиус-вектор, м</th>
|
||||
<th>|r|, м</th>
|
||||
<th>Разница Времени, ч</th>
|
||||
<th>Расхождение, м</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tle-analysis-results-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/tle_analysis_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Сравнение TLE</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/tle-comparison.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="tle-comparison-page" class="catalog-page tle-comparison-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Сравнение TLE</h2>
|
||||
<div class="catalog-helper">Вставьте два набора TLE, проверьте эпохи и рассчитайте положение спутника на заданный момент времени.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button id="tle-compare-submit" type="button" class="btn btn-primary">Рассчитать</button>
|
||||
<button id="tle-compare-clear" type="button" class="btn btn-outline-secondary">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tle-comparison-alert"></div>
|
||||
|
||||
<div class="card catalog-card tle-input-card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Ввод данных</div>
|
||||
<div class="catalog-helper mt-1">Можно вставить две или три строки TLE. Заголовок определяется автоматически, строки 1 и 2 обязательны. Время расчета должно быть не раньше максимальной эпохи двух TLE.</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-5 col-xl-4">
|
||||
<label for="tle-comparison-time" class="form-label">Время расчета</label>
|
||||
<input id="tle-comparison-time" type="datetime-local" class="form-control" step="1"/>
|
||||
</div>
|
||||
<div class="col-12 col-md-7 col-xl-8 d-flex align-items-end">
|
||||
<div id="tle-comparison-time-helper" class="catalog-helper">Минимальное допустимое время появится после разбора двух TLE.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="tle-input-widget h-100">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
|
||||
<label for="tle-first-text" class="form-label mb-0">TLE 1</label>
|
||||
<span id="tle-first-status" class="tle-parse-status text-muted">Нет данных</span>
|
||||
</div>
|
||||
<textarea id="tle-first-text"
|
||||
class="form-control tle-textarea"
|
||||
spellcheck="false"
|
||||
placeholder="ISS (ZARYA) 1 25544U 98067A ... 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) 1 25544U 98067A ... 2 25544 ..."></textarea>
|
||||
<div class="tle-reference mt-3">
|
||||
<div class="tle-reference-row">
|
||||
<span>Эпоха TLE</span>
|
||||
<strong id="tle-second-epoch">—</strong>
|
||||
</div>
|
||||
<div class="tle-reference-row">
|
||||
<span>NORAD</span>
|
||||
<strong id="tle-second-norad">—</strong>
|
||||
</div>
|
||||
<div class="tle-reference-row">
|
||||
<span>Виток</span>
|
||||
<strong id="tle-second-revolution">—</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card tle-result-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Результаты</div>
|
||||
<div id="tle-comparison-result-meta" class="catalog-helper mt-1">Задайте время не раньше максимальной эпохи TLE и нажмите “Сравнить”.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="tle-comparison-empty" class="catalog-empty">Параметры положения и разность радиус-векторов появятся здесь.</div>
|
||||
<div id="tle-comparison-table-wrap" class="table-responsive tle-result-table-wrap d-none">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Параметр</th>
|
||||
<th>TLE 1</th>
|
||||
<th>TLE 2</th>
|
||||
<th>Разница / значение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tle-comparison-result-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/tle_comparison_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user