diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e5389b1..518fa1f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -124,6 +124,9 @@ default: tags: - nstart +variables: + INTERNAL_REGISTRY: "$CI_REGISTRY_IMAGE" + stages: - test - build diff --git a/.gitlab/ci/pcp-request-service.yml b/.gitlab/ci/pcp-request-service.yml index efdfa9d..649ff0c 100644 --- a/.gitlab/ci/pcp-request-service.yml +++ b/.gitlab/ci/pcp-request-service.yml @@ -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/**/* diff --git a/build.gradle.kts b/build.gradle.kts index 8f863d8..333cb2d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { diff --git a/config-repo/application-local.yaml b/config-repo/application-local.yaml index 3629593..5fc6b17 100644 --- a/config-repo/application-local.yaml +++ b/config-repo/application-local.yaml @@ -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 diff --git a/config-repo/pcp-ballistics-service.yaml b/config-repo/pcp-ballistics-service.yaml index 79f9b8a..2be7d2f 100644 --- a/config-repo/pcp-ballistics-service.yaml +++ b/config-repo/pcp-ballistics-service.yaml @@ -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 diff --git a/config-repo/pcp-complex-mission-service.yaml b/config-repo/pcp-complex-mission-service.yaml index f69530e..65d94fa 100644 --- a/config-repo/pcp-complex-mission-service.yaml +++ b/config-repo/pcp-complex-mission-service.yaml @@ -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 diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml index 6a6b003..b3c6805 100644 --- a/config-repo/pcp-mission-planing-service.yaml +++ b/config-repo/pcp-mission-planing-service.yaml @@ -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 diff --git a/config-repo/pcp-request-service.yaml b/config-repo/pcp-request-service.yaml index ef094a1..a176980 100644 --- a/config-repo/pcp-request-service.yaml +++ b/config-repo/pcp-request-service.yaml @@ -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 diff --git a/config-repo/pcp-stations-service.yaml b/config-repo/pcp-stations-service.yaml index 3dbc099..e3184ca 100644 --- a/config-repo/pcp-stations-service.yaml +++ b/config-repo/pcp-stations-service.yaml @@ -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 diff --git a/config-repo/pcp-ui-service.yaml b/config-repo/pcp-ui-service.yaml index 2d544d3..655d390 100644 --- a/config-repo/pcp-ui-service.yaml +++ b/config-repo/pcp-ui-service.yaml @@ -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} diff --git a/config-repo/slots-service.yaml b/config-repo/slots-service.yaml index cf2debc..8021e3f 100644 --- a/config-repo/slots-service.yaml +++ b/config-repo/slots-service.yaml @@ -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 diff --git a/create-chatgpt-archive.sh b/create-chatgpt-archive.sh new file mode 100755 index 0000000..ccb3e32 --- /dev/null +++ b/create-chatgpt-archive.sh @@ -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" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b2f5206..69a42de 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/helm/pcp-ballistics-service/values-dev.yaml b/helm/pcp-ballistics-service/values-dev.yaml index c10c9a1..70faebe 100644 --- a/helm/pcp-ballistics-service/values-dev.yaml +++ b/helm/pcp-ballistics-service/values-dev.yaml @@ -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 \ No newline at end of file diff --git a/helm/pcp-complex-mission-service/values-dev.yaml b/helm/pcp-complex-mission-service/values-dev.yaml index 272c203..5f60b2f 100644 --- a/helm/pcp-complex-mission-service/values-dev.yaml +++ b/helm/pcp-complex-mission-service/values-dev.yaml @@ -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 diff --git a/helm/pcp-mission-planing-service/values-dev.yaml b/helm/pcp-mission-planing-service/values-dev.yaml index f9729c4..cab8c86 100644 --- a/helm/pcp-mission-planing-service/values-dev.yaml +++ b/helm/pcp-mission-planing-service/values-dev.yaml @@ -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 \ No newline at end of file diff --git a/helm/pcp-request-service/values-dev.yaml b/helm/pcp-request-service/values-dev.yaml index 62d37b1..83a447a 100644 --- a/helm/pcp-request-service/values-dev.yaml +++ b/helm/pcp-request-service/values-dev.yaml @@ -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 \ No newline at end of file diff --git a/helm/pcp-stations-service/values-dev.yaml b/helm/pcp-stations-service/values-dev.yaml index db9b249..b7464d0 100644 --- a/helm/pcp-stations-service/values-dev.yaml +++ b/helm/pcp-stations-service/values-dev.yaml @@ -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 \ No newline at end of file diff --git a/helm/pcp-ui-service/values-dev.yaml b/helm/pcp-ui-service/values-dev.yaml index af09cc6..b8c233f 100644 --- a/helm/pcp-ui-service/values-dev.yaml +++ b/helm/pcp-ui-service/values-dev.yaml @@ -13,3 +13,5 @@ ingress: extraEnv: - name: SERVER_PORT value: "8080" + - name: SETTINGS_TLE_MONITORING_SERVICE + value: "http://tle-monitoring-service:8080" diff --git a/helm/pcp-ui-service/values-master.yaml b/helm/pcp-ui-service/values-master.yaml index 75f7c6e..0f91eb0 100644 --- a/helm/pcp-ui-service/values-master.yaml +++ b/helm/pcp-ui-service/values-master.yaml @@ -12,3 +12,5 @@ ingress: extraEnv: - name: SERVER_PORT value: "8080" + - name: SETTINGS_TLE_MONITORING_SERVICE + value: "http://tle-monitoring-service:8080" diff --git a/helm/slots-service/values-dev.yaml b/helm/slots-service/values-dev.yaml index 1148206..721aed4 100644 --- a/helm/slots-service/values-dev.yaml +++ b/helm/slots-service/values-dev.yaml @@ -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 diff --git a/libs/ballistics-lib/build.gradle.kts b/libs/ballistics-lib/build.gradle.kts index 4ddd8eb..29c0112 100644 --- a/libs/ballistics-lib/build.gradle.kts +++ b/libs/ballistics-lib/build.gradle.kts @@ -4,7 +4,7 @@ plugins { kotlin("plugin.lombok") id("org.springframework.boot") id("io.spring.dependency-management") - id("org.sonarqube") + //id("org.sonarqube") jacoco } diff --git a/libs/ballistics-lib/src/test/kotlin/Test.kt b/libs/ballistics-lib/src/test/kotlin/Test.kt index 757851d..f119bbf 100644 --- a/libs/ballistics-lib/src/test/kotlin/Test.kt +++ b/libs/ballistics-lib/src/test/kotlin/Test.kt @@ -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() 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) diff --git a/libs/pcp-types-lib/build.gradle.kts b/libs/pcp-types-lib/build.gradle.kts index 82bb6fd..4452115 100644 --- a/libs/pcp-types-lib/build.gradle.kts +++ b/libs/pcp-types-lib/build.gradle.kts @@ -4,7 +4,7 @@ plugins { kotlin("plugin.lombok") id("org.springframework.boot") id("io.spring.dependency-management") - id("org.sonarqube") + //id("org.sonarqube") jacoco } diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/BookedSlotDetailsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/BookedSlotDetailsDTO.kt new file mode 100644 index 0000000..c96d4a7 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/BookedSlotDetailsDTO.kt @@ -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 = emptyList(), +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/MovementModel.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/MovementModel.kt new file mode 100644 index 0000000..4f42263 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/MovementModel.kt @@ -0,0 +1,10 @@ +package space.nstart.pcp.pcp_types_lib.dto.ballistics + +enum class MovementModel { + FOTO, + KONDOR, + METEORM1, + METEORM2, + BARS, + KONDOR_PROGNOZ +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteICDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteICDTO.kt index 8481b4c..2dbe138 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteICDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/SatelliteICDTO.kt @@ -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 ) { -} \ No newline at end of file + val movementModel: MovementModel = movementModel ?: MovementModel.FOTO +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TlePointRequestDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TlePointRequestDTO.kt new file mode 100644 index 0000000..bdb30e3 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/ballistics/TlePointRequestDTO.kt @@ -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() +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDetailsDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDetailsDTO.kt new file mode 100644 index 0000000..f98a1c1 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotDetailsDTO.kt @@ -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 = emptyList() +) \ No newline at end of file diff --git a/services/pcp-ballistics-service/Dockerfile b/services/pcp-ballistics-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-ballistics-service/Dockerfile +++ b/services/pcp-ballistics-service/Dockerfile @@ -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="" diff --git a/services/pcp-ballistics-service/build.gradle.kts b/services/pcp-ballistics-service/build.gradle.kts index c35f0a9..d6bdbac 100644 --- a/services/pcp-ballistics-service/build.gradle.kts +++ b/services/pcp-ballistics-service/build.gradle.kts @@ -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")}") +// } +//} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt index 925231e..be2be88 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt @@ -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 { + satelliteIcEventService.handlePlacedIcRv(body) + return ResponseEntity.accepted().build() + } + + @GetMapping("/{satellite_id}/initial-conditions/current") + fun currentInitialConditions( + @PathVariable("satellite_id") satelliteId: Long + ): ResponseEntity = + satelliteService.currentInitialConditions(satelliteId) + ?.let { ResponseEntity.ok(it) } + ?: ResponseEntity.noContent().build() + @Operation( diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/TLEController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/TLEController.kt index 7a6c6d8..c7e06e5 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/TLEController.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/TLEController.kt @@ -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) diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt new file mode 100644 index 0000000..044f7de --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt @@ -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 + ) + ) +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListener.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListener.kt index f56e06e..22e6590 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListener.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListener.kt @@ -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 } } diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/SatelliteInitialConditionRepository.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/SatelliteInitialConditionRepository.kt new file mode 100644 index 0000000..68b5e00 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/SatelliteInitialConditionRepository.kt @@ -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 { + 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 +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteInitialConditionService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteInitialConditionService.kt new file mode 100644 index 0000000..8d480e1 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteInitialConditionService.kt @@ -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() +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt index 27d17d8..996f783 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt @@ -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): List { if (sectors.isEmpty()) return emptyList() @@ -851,8 +874,6 @@ class SatelliteService { return res } - - fun getAscNodes(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) : List { return ascNodeRepository.findBySatelliteIdAndTimeBetween( @@ -881,3 +902,7 @@ data class SatelliteBallisticsDeletionSummary( val earthCoverageDeleted: Int, val earthCellViewsDeleted: Int ) + + + + diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/TLEService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/TLEService.kt index e44b004..5bd8fc7 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/TLEService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/TLEService.kt @@ -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 { val bal = Ballistics() diff --git a/services/pcp-ballistics-service/src/main/resources/db/migration/V4__create_satellite_initial_conditions.sql b/services/pcp-ballistics-service/src/main/resources/db/migration/V4__create_satellite_initial_conditions.sql new file mode 100644 index 0000000..4918c9f --- /dev/null +++ b/services/pcp-ballistics-service/src/main/resources/db/migration/V4__create_satellite_initial_conditions.sql @@ -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); diff --git a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListenerTest.kt b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListenerTest.kt index 0d712a7..be3e009 100644 --- a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListenerTest.kt +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/message/SatelliteIcRvKafkaListenerTest.kt @@ -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() { diff --git a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt index f77a7c6..6e6d2fc 100644 --- a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt @@ -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") diff --git a/services/pcp-complex-mission-service/Dockerfile b/services/pcp-complex-mission-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-complex-mission-service/Dockerfile +++ b/services/pcp-complex-mission-service/Dockerfile @@ -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="" diff --git a/services/pcp-complex-mission-service/build.gradle.kts b/services/pcp-complex-mission-service/build.gradle.kts index 4ccfa11..9676fe0 100644 --- a/services/pcp-complex-mission-service/build.gradle.kts +++ b/services/pcp-complex-mission-service/build.gradle.kts @@ -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")}") +// } +//} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/SatelliteModel.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/SatelliteModel.kt index 9ea9535..d0894d1 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/SatelliteModel.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/SatelliteModel.kt @@ -33,7 +33,6 @@ class SatelliteModel( val longMission : LongTermMission = LongTermMission(), val bls: BLS = BLS(), val scanTLE : Boolean = false - ) { fun toDTO() = SatelliteInfoDTO( diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt index 4abe92b..61dc722 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt @@ -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>() + var coverageByTargetId = emptyMap>() 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, 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): List = @@ -367,10 +378,59 @@ class ComplexPlanCalculationService( .sortedWith(compareBy { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime }) .toList() + private fun logBookedSlotsLoaded(runId: Long?, satelliteId: Long, slots: List) { + 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 + ) { + 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, cells: List, - coverageByTargetId: Map>, + coverageByTargetId: Map>, 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 + ) { + 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, intervalStart: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt index 32665c6..634b419 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt @@ -36,7 +36,7 @@ class ComplexPlanRunOrchestrationService( ) lateinit var createdRun: ComplexPlanRunEntity - val createRunMs = measureTimeMillis { + val createRunMs = measureTimeMillis { createdRun = complexPlanRunPersistenceService.createRun( ComplexPlanRunCreateDTO( intervalStart = request.intervalStart, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt index cb741ab..8d34c2c 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt @@ -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 + ): List = + response.map { targetResponse -> + targetResponse.copy( + slots = targetResponse.slots.filterNot { slot -> slot.state != SlotStatus.AVAILABLE } + ) + } + private fun requestCoverageModes( targets: List, intervalStart: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt index 7527735..8ae27d1 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt @@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider + "satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}" + } + ) + } val result = linkedMapOf>() val durationMs = measureTimeMillis { diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt index b58ce9f..dcf343d 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt @@ -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, diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt index a2dfaa5..51f46bf 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt @@ -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)) diff --git a/services/pcp-coverage-scheme-service/Dockerfile b/services/pcp-coverage-scheme-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-coverage-scheme-service/Dockerfile +++ b/services/pcp-coverage-scheme-service/Dockerfile @@ -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="" diff --git a/services/pcp-dynamic-plan-service/Dockerfile b/services/pcp-dynamic-plan-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-dynamic-plan-service/Dockerfile +++ b/services/pcp-dynamic-plan-service/Dockerfile @@ -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="" diff --git a/services/pcp-dynamic-plan-service/src/main/resources/application.yaml b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml index 9128b10..442fbcd 100644 --- a/services/pcp-dynamic-plan-service/src/main/resources/application.yaml +++ b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml @@ -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: diff --git a/services/pcp-mission-planing-service/Dockerfile b/services/pcp-mission-planing-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-mission-planing-service/Dockerfile +++ b/services/pcp-mission-planing-service/Dockerfile @@ -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="" diff --git a/services/pcp-request-service/Dockerfile b/services/pcp-request-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-request-service/Dockerfile +++ b/services/pcp-request-service/Dockerfile @@ -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="" diff --git a/services/pcp-satellite-catalog-service/Dockerfile b/services/pcp-satellite-catalog-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-satellite-catalog-service/Dockerfile +++ b/services/pcp-satellite-catalog-service/Dockerfile @@ -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="" diff --git a/services/pcp-satellite-catalog-service/build.gradle.kts b/services/pcp-satellite-catalog-service/build.gradle.kts index b8e315f..1a210b4 100644 --- a/services/pcp-satellite-catalog-service/build.gradle.kts +++ b/services/pcp-satellite-catalog-service/build.gradle.kts @@ -7,7 +7,7 @@ plugins { kotlin("plugin.lombok") id("org.springframework.boot") id("io.spring.dependency-management") - id("org.sonarqube") + //id("org.sonarqube") jacoco } diff --git a/services/pcp-srpring-boot-admin-server/Dockerfile b/services/pcp-srpring-boot-admin-server/Dockerfile index e5ffd01..2c36b44 100644 --- a/services/pcp-srpring-boot-admin-server/Dockerfile +++ b/services/pcp-srpring-boot-admin-server/Dockerfile @@ -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" diff --git a/services/pcp-srpring-boot-admin-server/build.gradle.kts b/services/pcp-srpring-boot-admin-server/build.gradle.kts index a6df2bf..1c29802 100644 --- a/services/pcp-srpring-boot-admin-server/build.gradle.kts +++ b/services/pcp-srpring-boot-admin-server/build.gradle.kts @@ -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")}") - } -} + diff --git a/services/pcp-stations-service/Dockerfile b/services/pcp-stations-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-stations-service/Dockerfile +++ b/services/pcp-stations-service/Dockerfile @@ -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="" diff --git a/services/pcp-stations-service/build.gradle.kts b/services/pcp-stations-service/build.gradle.kts index 695765b..2b03a06 100644 --- a/services/pcp-stations-service/build.gradle.kts +++ b/services/pcp-stations-service/build.gradle.kts @@ -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")}") - } -} diff --git a/services/pcp-tgu-service/Dockerfile b/services/pcp-tgu-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-tgu-service/Dockerfile +++ b/services/pcp-tgu-service/Dockerfile @@ -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="" diff --git a/services/pcp-ui-service/Dockerfile b/services/pcp-ui-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/pcp-ui-service/Dockerfile +++ b/services/pcp-ui-service/Dockerfile @@ -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="" diff --git a/services/pcp-ui-service/build.gradle.kts b/services/pcp-ui-service/build.gradle.kts index 333ab92..1e4309e 100644 --- a/services/pcp-ui-service/build.gradle.kts +++ b/services/pcp-ui-service/build.gradle.kts @@ -6,7 +6,7 @@ plugins { kotlin("plugin.lombok") id("org.springframework.boot") id("io.spring.dependency-management") - id("org.sonarqube") + //id("org.sonarqube") jacoco } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingsController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingsController.kt new file mode 100644 index 0000000..f9cf81b --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingsController.kt @@ -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 { 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?, + ): List { + 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 { 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, + ) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt index 106d8b7..ae68fed 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt @@ -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" } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt index 8a6d82d..fba882f 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt @@ -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 { + 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 { + 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 { + 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) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt index dc8a956..5ac91f3 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CurrentPlansController.kt @@ -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) = 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) } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/RvaController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/RvaController.kt index 44a2c0f..7c3cf93 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/RvaController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/RvaController.kt @@ -57,4 +57,37 @@ class RvaRestController( @PathVariable duration: Long, @RequestBody stations: List ) = slotService.rvaMergeOnRev(satelliteId, duration, stations) + + @PostMapping("/average/{satelliteId}/{duration}") + @ResponseBody + fun rvaAverage( + @PathVariable satelliteId: Long, + @PathVariable duration: Long, + @RequestBody stations: List + ): List { + 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 +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleAnalysisController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleAnalysisController.kt new file mode 100644 index 0000000..48477a1 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleAnalysisController.kt @@ -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) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt new file mode 100644 index 0000000..0cf22ed --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt @@ -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 = 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}с" + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt new file mode 100644 index 0000000..e7e3e96 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt @@ -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 = emptyList(), +) + +data class BookingRequestOptionDTO( + val id: String, + val name: String = "", +) + +data class BookingFilterOptionsDTO( + val satellites: List = emptyList(), + val requests: List = emptyList(), +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt new file mode 100644 index 0000000..1d55f43 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt @@ -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 +) + +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 +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt new file mode 100644 index 0000000..238b312 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt @@ -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 +) + +data class TlePositionDeltaDTO( + val dx: Double, + val dy: Double, + val dz: Double, + val norm: Double +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/rest/RequestRestController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/rest/RequestRestController.kt index 6f53aa9..ef03223 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/rest/RequestRestController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/rest/RequestRestController.kt @@ -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) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt index ac49885..1812c3e 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt @@ -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 + 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?, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt index b884714..cd97520 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt @@ -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{ + fun station(id : String, view : Boolean, orbitHeightKm: Double? = null) : Iterable{ 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 = 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}
Высота орбиты для зоны видимости: ${normalizedOrbitHeightKm} км", model = CZMLModel( gltf = "/model/station.glb", show = !view diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt index b6802de..f3fa13a 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/MissionService.kt @@ -103,6 +103,15 @@ class MissionService(webClientBuilderProvider: ObjectProvider .block() } + fun confirmMission(missionId: UUID) { + webClientBuilder.build() + .post() + .uri("$url/api/missions/$missionId/confirm") + .retrieve() + .toBodilessEntity() + .block() + } + fun modesByMission(missionId: UUID): List = webClientBuilder.build() .get() diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index d86b4f0..18a6ce6 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -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) { } + + fun allBooked(): List = + 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): List = + bookedDetailsByIds(bookedSlotIds) + + fun bookedDetailsByIds(bookedSlotIds: List): List { + 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? + ): List = 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 = + 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 = webClientBuilder.build() .post() @@ -244,6 +324,27 @@ class SlotService(webClientBuilderProvider: ObjectProvider) { } .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, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisService.kt new file mode 100644 index 0000000..ae8eeb6 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisService.kt @@ -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 { + logger.info("TLE analysis satellites list stage started") + var catalogSatellites = emptyList() + val catalogLoadMs = measureTimeMillis { + catalogSatellites = runCatching { satelliteCatalogService.allSatellites() } + .onFailure { exception -> + logger.warn("Не удалось загрузить спутники из satellite-catalog-service для анализа TLE: {}", exception.message) + } + .getOrDefault(emptyList()) + } + var tleRecords = emptyList() + 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() + 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() + 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 + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleMonitoringService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleMonitoringService.kt new file mode 100644 index 0000000..bcf30fc --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TleMonitoringService.kt @@ -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) { + 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 { + val baseUrl = tleMonitoringUrl.trimEnd('/') + logger.info("TLE monitoring client all TLE request started: baseUrl={}", baseUrl) + return runCatching { + var records = emptyList() + 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 { + val baseUrl = tleMonitoringUrl.trimEnd('/') + logger.info( + "TLE monitoring client satellite TLE request started: satelliteId={}, baseUrl={}", + satelliteId, + baseUrl + ) + return runCatching { + var records = emptyList() + 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 = + 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) + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/bookings_scripts.js b/services/pcp-ui-service/src/main/resources/static/bookings_scripts.js new file mode 100644 index 0000000..c21d5fd --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/bookings_scripts.js @@ -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 = '' + state.requests.map((request) => { + const title = request.name ? `${request.name} / ${request.id}` : request.id; + return ``; + }).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 ` + + + ${escapeHtml(satellite.id ?? '—')} + +
${escapeHtml(satellite.name || satellite.code || 'КА')}
+
${escapeHtml([satellite.code, satellite.typeCode, satellite.noradId ? `NORAD ${satellite.noradId}` : null].filter(Boolean).join(' / ') || '—')}
+ + `; + }).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) => ` + + ${escapeHtml(booking.bookedSlotId ?? '—')} + +
${escapeHtml(booking.satelliteName || booking.satelliteCode || `КА ${booking.satelliteId}`)}
+
ID ${escapeHtml(booking.satelliteId)}${booking.satelliteTypeCode ? ` / ${escapeHtml(booking.satelliteTypeCode)}` : ''}
+ + +
Слот ${escapeHtml(booking.slotNum ?? '—')}
+
Цикл ${escapeHtml(booking.cycle ?? '—')}
+ + +
${formatDateTime(booking.timeStart)}
+
${formatDateTime(booking.timeStop)}${booking.durationSeconds != null ? ` / ${formatNumber(booking.durationSeconds)} c` : ''}
+ + +
${escapeHtml(booking.revolution ?? '—')}
+
${escapeHtml(booking.revolutionSign || '—')}
+ + ${formatNumber(booking.roll)} + ${escapeHtml(booking.status || '—')} + ${formatRequestIds(booking.requestIds)} + `).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 `${escapeHtml(text)}`; + } + + function formatRequestIds(ids) { + if (!Array.isArray(ids) || !ids.length) return '—'; + return ids.map((id) => `${escapeHtml(id)}`).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 = ``; + } + + 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, '''); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/css/bookings.css b/services/pcp-ui-service/src/main/resources/static/css/bookings.css new file mode 100644 index 0000000..7a52ecd --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/bookings.css @@ -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; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css b/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css index 077b9cf..a806ba7 100644 --- a/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css +++ b/services/pcp-ui-service/src/main/resources/static/css/satellite-pdcm.css @@ -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; diff --git a/services/pcp-ui-service/src/main/resources/static/css/tle-analysis.css b/services/pcp-ui-service/src/main/resources/static/css/tle-analysis.css new file mode 100644 index 0000000..ffa613a --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/tle-analysis.css @@ -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; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/tle-comparison.css b/services/pcp-ui-service/src/main/resources/static/css/tle-comparison.css new file mode 100644 index 0000000..d735764 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/tle-comparison.css @@ -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; + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js index 29138a6..962ed3b 100644 --- a/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/current_plans_scripts.js @@ -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)}">Расчёт сбросов + `; }).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 ` - - ${escapeHtml(type)} + const expandable = isExpandableMode(mode); + const expandMarker = expandable ? '' : ''; + const row = ` + + ${expandMarker}${escapeHtml(type)} ${formatDateTime(mode.timeStart)} ${escapeHtml(mode.revolution ?? '—')} ${duration} ${modeParams(mode)} ${modeStatus(mode)} `; + 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 ` + + +
${content}
+ + `; + } + + function bookedSlotDetails(mode) { + const slotIds = Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds : []; + const rows = slotIds.map((slotId) => { + const slot = state.bookedSlotsById[slotId]; + if (!slot) { + return `#${escapeHtml(slotId)}Детали слота не найдены`; + } + return ` + + #${escapeHtml(slot.bookedSlotId)} + ${formatDateTime(slot.timeStart)} + ${formatDuration(slot.durationSeconds)} + ${escapeHtml(slot.slotNum ?? '—')} + ${escapeHtml(slot.cycle ?? '—')} + ${escapeHtml(slot.satelliteId ?? '—')} + ${escapeHtml(slot.status || '—')} + `; + }).join(''); + + return ` +
Забронированные слоты (${slotIds.length})
+
+ + + + + + + + + + + + + ${rows} +
IDНачалоДлительностьСлотЦиклСпутникСтатус
+
`; + } + + 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 `#${escapeHtml(surveyId)}Съёмка не найдена в текущем списке режимов`; + } + + 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 ` + + #${escapeHtml(survey.id)} + ${formatDateTime(survey.timeStart)} + ${escapeHtml(survey.revolution ?? '—')} + ${escapeHtml(survey.source || '—')} + ${escapeHtml(booked)} + `; + }).join(''); + + return ` +
Сбрасываемые съёмки (${surveyIds.length})
+
+ + + + + + + + + + + ${rows} +
IDНачалоВитокИсточникBooked slots
+
`; + } + 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 'DROP'; } - const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : ''; + const sourceBadgeClass = isSlotsLikeSource(mode.source) ? ' current-plans-badge-slots' : ''; return `
${escapeHtml(mode.status || '—')}
${escapeHtml(mode.source || '—')}
`; @@ -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('"', '\\"'); + } })(); diff --git a/services/pcp-ui-service/src/main/resources/static/requests_scripts.js b/services/pcp-ui-service/src/main/resources/static/requests_scripts.js index 4e56978..0fa3bf7 100644 --- a/services/pcp-ui-service/src/main/resources/static/requests_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/requests_scripts.js @@ -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){ diff --git a/services/pcp-ui-service/src/main/resources/static/rva_scripts.js b/services/pcp-ui-service/src/main/resources/static/rva_scripts.js index a78a545..21ea05c 100644 --- a/services/pcp-ui-service/src/main/resources/static/rva_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/rva_scripts.js @@ -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 @@ ${row.durationSec} `).join(''); - } else { + } else if (resultState.mode === 'merge-on-rev') { head.innerHTML = ` Виток @@ -123,7 +134,24 @@ body.innerHTML = resultState.rows.map(row => ` ${row.revolution} - ${row.durationSec} + ${formatNumber(row.durationSec, 1)} + + `).join(''); + } else if (resultState.mode === 'average') { + head.innerHTML = ` + + №ППИ + Широта ППИ + Сред.кол-во + Сред.длит., с + + `; + body.innerHTML = resultState.rows.map(row => ` + + ${row.ppiNumber} + ${formatNumber(row.ppiLatitude, 6)} + ${formatNumber(row.averageCount, 3)} + ${formatNumber(row.averageDurationSec, 1)} `).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;' }); diff --git a/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js b/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js index afa23eb..6bcbf02 100644 --- a/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/satellite_pdcm_scripts.js @@ -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 = '
Выберите спутник слева.
'; renderExportState(null); + renderSendInitialConditionsState(null); + renderSetInitialConditionsState(null); return; } @@ -140,6 +291,8 @@ `; 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(); })(); diff --git a/services/pcp-ui-service/src/main/resources/static/tle_analysis_scripts.js b/services/pcp-ui-service/src/main/resources/static/tle_analysis_scripts.js new file mode 100644 index 0000000..47c5eb9 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/tle_analysis_scripts.js @@ -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 = `Спутники не найдены`; + 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 ` + + ${escapeHtml(catalogId)} + +
${title}
+
${escapeHtml(satellite.code || '')} ${satellite.typeCode ? ' / ' + escapeHtml(satellite.typeCode) : ''}
+
TLE-записей: ${escapeHtml(tleCount)}
+ + ${satellite.noradId == null ? '—' : escapeHtml(satellite.noradId)} + `; + }).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 + ? `
${formatNumber(discrepancy.norm, 3)}
+
Δx=${formatNumber(discrepancy.dx, 3)}, Δy=${formatNumber(discrepancy.dy, 3)}, Δz=${formatNumber(discrepancy.dz, 3)}
+
к предыдущей эпохе ${escapeHtml(formatDateTime(discrepancy.previousEpoch))}
` + : 'не рассчитывается'; + + return ` + + ${escapeHtml(row.index)} + +
${escapeHtml(formatDateTime(row.epoch))}
+ ${row.tleHeader ? `
${escapeHtml(row.tleHeader)}
` : ''} + + ${escapeHtml(row.revolution)} + ${escapeHtml(row.noradId)} + + x=${formatNumber(r.x, 3)}
+ y=${formatNumber(r.y, 3)}
+ z=${formatNumber(r.z, 3)} + + ${formatNumber(row.radiusNorm, 3)} + ${discrepancy ? formatNumber(discrepancy.epochDifferenceHours, 3) : '—'} + ${diffText} + `; + } + + 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 = ``; + } + + function clearAlert() { + const alert = document.getElementById('tle-analysis-alert'); + if (alert) alert.innerHTML = ''; + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/static/tle_comparison_scripts.js b/services/pcp-ui-service/src/main/resources/static/tle_comparison_scripts.js new file mode 100644 index 0000000..8fdb0a2 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/tle_comparison_scripts.js @@ -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) => ` + + ${escapeHtml(row.name || row.code || '')} + ${escapeHtml(emptyToDash(row.first))} + ${escapeHtml(emptyToDash(row.second))} + ${escapeHtml(emptyToDash(row.delta))} + + `).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 + ? `` + : ''; + } + + 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); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/templates/bookings.html b/services/pcp-ui-service/src/main/resources/templates/bookings.html new file mode 100644 index 0000000..3eb16b1 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/bookings.html @@ -0,0 +1,115 @@ + + + + Бронирования + + + + + +
+
+
+

Бронирования

+
Табличный просмотр забронированных слотов с фильтрацией по времени, заявке и спутникам.
+
+
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+
Спутники
+
Выберите один или несколько КА.
+
+ +
+
+ + + + + + + + + +
IDСпутник
+
+
+
+
+
+
+
+
Забронированные слоты
+
Загрузка данных...
+
+
+
+
Забронированные слоты не найдены.
+
+ + + + + + + + + + + + + + +
БроньКАСлот / циклВремяВитокКренСтатусЗаявки
+
+
+
+
+
+
+ + +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html index 7a26ad9..496e029 100644 --- a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -46,6 +46,8 @@ + diff --git a/services/pcp-ui-service/src/main/resources/templates/map.html b/services/pcp-ui-service/src/main/resources/templates/map.html index d41cf2c..1193fe8 100644 --- a/services/pcp-ui-service/src/main/resources/templates/map.html +++ b/services/pcp-ui-service/src/main/resources/templates/map.html @@ -288,13 +288,23 @@ -
- - -
- + + +
+ + +
+ @@ -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); + }); + } // Функция для обработки запросов diff --git a/services/pcp-ui-service/src/main/resources/templates/rva.html b/services/pcp-ui-service/src/main/resources/templates/rva.html index f5fc046..45709fd 100644 --- a/services/pcp-ui-service/src/main/resources/templates/rva.html +++ b/services/pcp-ui-service/src/main/resources/templates/rva.html @@ -56,6 +56,10 @@ +
+ + +
diff --git a/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html b/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html index 142da35..0e2c455 100644 --- a/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html +++ b/services/pcp-ui-service/src/main/resources/templates/satellites-pdcm.html @@ -15,7 +15,22 @@

Параметры движения центра масс

Табличное представление asc-node по выбранному спутнику.
- +
+
+ + +
+ + + +
@@ -66,6 +81,84 @@ + + diff --git a/services/pcp-ui-service/src/main/resources/templates/tle-analysis.html b/services/pcp-ui-service/src/main/resources/templates/tle-analysis.html new file mode 100644 index 0000000..92f78b0 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/tle-analysis.html @@ -0,0 +1,100 @@ + + + + Анализ TLE + + + + + +
+
+
+

Анализ TLE

+
Анализ изменения TLE выбранного спутника по разнице рассчитанных радиус-векторов.
+
+
+
+ + +
+ +
+
+ +
+ +
+
+
+
+
Спутники
+
Выберите КА для формирования аналитической таблицы TLE.
+
+
+ + +
+
+ + + + + + + + + +
IDСпутникNORAD
+
+
+
+ +
+
+
+
+
Таблица анализа TLE
+
Выберите спутник слева.
+
+
+
+
+
Выберите спутник для загрузки TLE.
+
+ + + + + + + + + + + + + + +
#ЭпохаВитокNORADРадиус-вектор, м|r|, мРазница Времени, чРасхождение, м
+
+
+
+
+
+
+ +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/tle-comparison.html b/services/pcp-ui-service/src/main/resources/templates/tle-comparison.html new file mode 100644 index 0000000..ed9bfbd --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/tle-comparison.html @@ -0,0 +1,127 @@ + + + + Сравнение TLE + + + + + +
+
+
+

Сравнение TLE

+
Вставьте два набора TLE, проверьте эпохи и рассчитайте положение спутника на заданный момент времени.
+
+
+ + +
+
+ +
+ +
+
+
Ввод данных
+
Можно вставить две или три строки TLE. Заголовок определяется автоматически, строки 1 и 2 обязательны. Время расчета должно быть не раньше максимальной эпохи двух TLE.
+
+
+
+
+ + +
+
+
Минимальное допустимое время появится после разбора двух TLE.
+
+
+
+
+
+
+ + Нет данных +
+ +
+
+ Эпоха TLE + +
+
+ NORAD + +
+
+ Виток + +
+
+
+
+
+
+
+ + Нет данных +
+ +
+
+ Эпоха TLE + +
+
+ NORAD + +
+
+ Виток + +
+
+
+
+
+
+
+ +
+
+
+
Результаты
+
Задайте время не раньше максимальной эпохи TLE и нажмите “Сравнить”.
+
+
+
+
Параметры положения и разность радиус-векторов появятся здесь.
+
+ + + + + + + + + + +
ПараметрTLE 1TLE 2Разница / значение
+
+
+
+
+ + +
+ + diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index 2e062de..ac6e271 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -1,10 +1,12 @@ package space.nstart.pcp.slots_service.controller import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.doReturn import org.mockito.Mockito.doThrow +import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.server.LocalServerPort @@ -14,6 +16,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.web.reactive.function.client.WebClient import reactor.core.publisher.Mono 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 space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO @@ -34,6 +37,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO +import space.nstart.pcp.slots_service.service.BallisticsService import space.nstart.pcp.slots_service.service.GroupStateService import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatellitePdcmService @@ -77,6 +81,9 @@ class CatalogControllerTest { @MockitoBean private lateinit var tguPlanningService: TguPlanningService + @MockitoBean + private lateinit var ballisticsService: BallisticsService + @Test fun `groups page is rendered`() { val body = WebClient.create("http://localhost:$port") @@ -139,9 +146,31 @@ class CatalogControllerTest { assertTrue(body.contains("Asc-node")) assertTrue(body.contains("Спутники")) assertTrue(body.contains("Скачать CSV")) + assertTrue(body.contains("satellite-pdcm-send-ic-time")) + assertTrue(body.contains("Задать НУ")) + assertTrue(body.contains("satellite-pdcm-set-ic-modal")) + assertTrue(body.contains("value=\"FOTO\" selected")) + assertTrue(body.contains("dd.mm.yyyy hh:MM:ss.zzz")) assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css")) } + @Test + fun `tle analysis page is rendered with page script`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/tle-analysis") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Анализ TLE")) + assertTrue(body.contains("tle-analysis-satellites-body")) + assertTrue(body.contains("tle-analysis-count")) + assertTrue(body.contains("Разница Времени, ч")) + assertTrue(body.contains("value=\"5\"")) + assertTrue(body.contains("/tle_analysis_scripts.js")) + } + @Test fun `stations page is rendered`() { val body = WebClient.create("http://localhost:$port") @@ -334,6 +363,114 @@ class CatalogControllerTest { verify(satellitePdcmService).satelliteSummaries() } + @Test + fun `satellite pdcm send initial conditions endpoint parses ui time and proxies request`() { + val requestedTime = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000) + + val status = WebClient.create("http://localhost:$port") + .post() + .uri { builder -> + builder + .path("/api/catalog/satellites/pdcm/501/send-ic") + .queryParam("time", "24.04.2026 10:15:30.123") + .build() + } + .exchangeToMono { response -> Mono.just(response.statusCode()) } + .block()!! + + assertEquals(HttpStatus.ACCEPTED, status) + verify(slotService).sendSatelliteInitialConditions(501L, requestedTime) + } + + @Test + fun `satellite pdcm send initial conditions endpoint rejects invalid ui time`() { + val status = WebClient.create("http://localhost:$port") + .post() + .uri { builder -> + builder + .path("/api/catalog/satellites/pdcm/501/send-ic") + .queryParam("time", "2026-04-24T10:15:30") + .build() + } + .exchangeToMono { response -> Mono.just(response.statusCode()) } + .block()!! + + assertEquals(HttpStatus.BAD_REQUEST, status) + verify(slotService, never()).sendSatelliteInitialConditions(eq(501L), anyLocalDateTime()) + } + + @Test + fun `satellite pdcm set initial conditions endpoint proxies request to ballistics`() { + val request = SatelliteICDTO( + satelliteId = 999L, + movementModel = MovementModel.KONDOR, + ic = InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000), + revolution = 42L, + vx = 1.0, + vy = 2.0, + vz = 3.0, + x = 4.0, + y = 5.0, + z = 6.0 + ), + sBall = 0.07, + f81 = 145.2 + ) + ) + + val status = WebClient.create("http://localhost:$port") + .post() + .uri("/api/catalog/satellites/pdcm/501/initial-conditions") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchangeToMono { response -> Mono.just(response.statusCode()) } + .block()!! + + val requestCaptor = ArgumentCaptor.forClass(SatelliteICDTO::class.java) + assertEquals(HttpStatus.ACCEPTED, status) + verify(ballisticsService).receiveRv(captureSatelliteIc(requestCaptor)) + assertEquals(501L, requestCaptor.value.satelliteId) + assertEquals(MovementModel.KONDOR, requestCaptor.value.movementModel) + assertEquals(42L, requestCaptor.value.ic.orbPoint.revolution) + } + + @Test + fun `satellite pdcm current initial conditions endpoint proxies request to ballistics`() { + val response = SatelliteICDTO( + satelliteId = 501L, + movementModel = MovementModel.KONDOR, + ic = InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000), + revolution = 42L, + vx = 1.0, + vy = 2.0, + vz = 3.0, + x = 4.0, + y = 5.0, + z = 6.0 + ), + sBall = 0.07, + f81 = 145.2 + ) + ) + doReturn(response).`when`(ballisticsService).currentInitialConditions(501L) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/catalog/satellites/pdcm/501/initial-conditions") + .retrieve() + .bodyToMono(SatelliteICDTO::class.java) + .block()!! + + assertEquals(501L, actual.satelliteId) + assertEquals(MovementModel.KONDOR, actual.movementModel) + assertEquals(42L, actual.ic.orbPoint.revolution) + verify(ballisticsService).currentInitialConditions(501L) + } + @Test fun `satellite create endpoint proxies request`() { val request = SatelliteCreateDTO( @@ -568,4 +705,7 @@ class CatalogControllerTest { private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO() private fun anyInitialConditions(): InitialConditionsDTO = any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO() private fun anySlotProfile(): SatelliteSlotProfileDTO = any(SatelliteSlotProfileDTO::class.java) ?: SatelliteSlotProfileDTO() + private fun anyLocalDateTime(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN + private fun captureSatelliteIc(captor: ArgumentCaptor): SatelliteICDTO = + captor.capture() ?: SatelliteICDTO() } diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/MapControllerComplexPlanTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/MapControllerComplexPlanTest.kt index e302828..117b814 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/MapControllerComplexPlanTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/MapControllerComplexPlanTest.kt @@ -10,6 +10,8 @@ import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.web.reactive.function.client.WebClient import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO @@ -61,6 +63,18 @@ class MapControllerComplexPlanTest { private val objectMapper = ObjectMapper() + private fun stationEllipseRadius(stationId: String, orbitHeightKm: Double): Double { + val actual = WebClient.create("http://localhost:$port") + .post() + .uri("/requests/station/$stationId?view=true&orbitHeightKm=$orbitHeightKm") + .retrieve() + .bodyToMono(String::class.java) + .map(objectMapper::readTree) + .block()!! + + return actual[1]["ellipse"]["semiMajorAxis"].asDouble() + } + @Test fun `local post api requests delegates to earth service facade`() { val responseId = UUID.fromString("11111111-1111-4111-8111-111111111111") @@ -256,11 +270,34 @@ class MapControllerComplexPlanTest { .block()!! assertTrue(body.contains("cesiumContainer")) + assertTrue(body.contains("stationOrbitHeightKm")) + assertTrue(body.contains("Высота орбиты, км")) assertTrue(body.contains("dynamic_plan_map_layers.js")) assertTrue(body.contains("PcpDynamicPlanMapLayers")) verify(earthService).reqs() } + @Test + fun `station czml endpoint uses orbit height for visibility zone`() { + val stationId = "11111111-1111-4111-8111-111111111111" + doReturn( + Mono.just( + StationDTO( + id = UUID.fromString(stationId), + number = 1, + name = "Station 1", + position = PositionDTO(lat = 55.0, long = 37.0, height = 200.0), + elevationMin = 5.0 + ) + ) + ).`when`(stationService).station(stationId) + + val baseRadius = stationEllipseRadius(stationId, 500.0) + val higherOrbitRadius = stationEllipseRadius(stationId, 700.0) + + assertTrue(higherOrbitRadius > baseRadius) + } + @Test fun `dynamic plan map layer keeps only one visible run`() { val body = WebClient.create("http://localhost:$port") diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BallisticsServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BallisticsServiceTest.kt index 17b191e..6639838 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BallisticsServiceTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/BallisticsServiceTest.kt @@ -7,7 +7,10 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.ObjectProvider import org.springframework.test.util.ReflectionTestUtils import org.springframework.web.reactive.function.client.WebClient +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.net.InetSocketAddress import java.net.URI import java.time.LocalDateTime @@ -27,7 +30,8 @@ class BallisticsServiceTest { @Test fun `sat data methods forward time interval to ballistics service`() { val requests = CopyOnWriteArrayList() - val serverUrl = startServer(requests) + val requestBodies = CopyOnWriteArrayList() + val serverUrl = startServer(requests, requestBodies) val service = createService(serverUrl) val timeStart = LocalDateTime.of(2026, 4, 21, 10, 0) val timeStop = LocalDateTime.of(2026, 4, 21, 12, 0) @@ -37,6 +41,25 @@ class BallisticsServiceTest { service.points(101L, timeStart, timeStop).collectList().block() val availability = service.orbitAvailability() val exactPoint = service.exactTimePoint(101L, timeStart) + service.receiveRv( + SatelliteICDTO( + satelliteId = 101L, + movementModel = MovementModel.KONDOR, + ic = InitialConditionsDTO( + orbPoint = OrbPointDTO( + time = timeStart, + revolution = 7L, + vx = 1.0, + vy = 2.0, + vz = 3.0, + x = 4.0, + y = 5.0, + z = 6.0 + ) + ) + ) + ) + val currentInitialConditions = service.currentInitialConditions(101L) assertEquals( listOf( @@ -44,7 +67,9 @@ class BallisticsServiceTest { "/api/satellites/101/flight-line", "/api/satellites/101/orbit", "/api/satellites/orbit/availability", - "/api/satellites/101/extract-time" + "/api/satellites/101/extract-time", + "/api/satellites/receive-rv", + "/api/satellites/101/initial-conditions/current" ), requests.map { it.path } ) @@ -54,6 +79,8 @@ class BallisticsServiceTest { "time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00", "time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00", null, + null, + null, null ), requests.map { it.query } @@ -62,6 +89,9 @@ class BallisticsServiceTest { assertEquals(101L, availability.single().satelliteId) assertNotNull(exactPoint) assertEquals(timeStart, exactPoint.time) + assertEquals(true, requestBodies.single().contains(""""movementModel":"KONDOR"""")) + assertNotNull(currentInitialConditions) + assertEquals(101L, currentInitialConditions.satelliteId) } private fun createService(serverUrl: String): BallisticsService { @@ -77,7 +107,7 @@ class BallisticsServiceTest { return service } - private fun startServer(requests: MutableList): String { + private fun startServer(requests: MutableList, requestBodies: MutableList): String { val startedServer = HttpServer.create(InetSocketAddress(0), 0) listOf( "/api/satellites/101/asc-node", @@ -103,6 +133,37 @@ class BallisticsServiceTest { """[{"time":"2026-04-21T10:00:00","revolution":1,"vx":1.0,"vy":2.0,"vz":3.0,"x":4.0,"y":5.0,"z":6.0}]""" ) } + startedServer.createContext("/api/satellites/receive-rv") { exchange -> + requests.add(exchange.requestURI) + requestBodies.add(exchange.requestBody.bufferedReader().use { it.readText() }) + respond(exchange, "") + } + startedServer.createContext("/api/satellites/101/initial-conditions/current") { exchange -> + requests.add(exchange.requestURI) + respond( + exchange, + """ + { + "satelliteId": 101, + "movementModel": "KONDOR", + "ic": { + "orbPoint": { + "time": "2026-04-21T10:00:00", + "revolution": 7, + "vx": 1.0, + "vy": 2.0, + "vz": 3.0, + "x": 4.0, + "y": 5.0, + "z": 6.0 + }, + "sBall": 0.0, + "f81": 147.8 + } + } + """.trimIndent() + ) + } startedServer.start() server = startedServer return "http://localhost:${startedServer.address.port}" diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisServiceTest.kt new file mode 100644 index 0000000..25924f9 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TleAnalysisServiceTest.kt @@ -0,0 +1,121 @@ +package space.nstart.pcp.slots_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class TleAnalysisServiceTest { + + private val tleMonitoringService: TleMonitoringService = mock() + private val satelliteCatalogService: SatelliteCatalogService = mock() + private val service = TleAnalysisService(tleMonitoringService, satelliteCatalogService) + + @Test + fun `satellites uses TLE satellite id and enriches rows from catalog by norad id`() { + doReturn( + listOf( + SatelliteSummaryDTO( + id = 101, + noradId = 62138, + code = "SAT-62138", + name = "Test satellite", + typeCode = "EO", + scanTle = true + ) + ) + ).`when`(satelliteCatalogService).allSatellites() + doReturn( + listOf( + TLEExtensionDTO(satelliteId = 62138, revolution = 10, tle = TLEDTO()), + TLEExtensionDTO(satelliteId = 62138, revolution = 11, tle = TLEDTO()) + ) + ).`when`(tleMonitoringService).allTles() + + val satellites = service.satellites() + + assertEquals(1, satellites.size) + assertEquals(62138, satellites.single().id) + assertEquals(101, satellites.single().catalogId) + assertEquals(62138, satellites.single().noradId) + assertEquals("SAT-62138", satellites.single().code) + assertEquals("Test satellite", satellites.single().name) + assertEquals("EO", satellites.single().typeCode) + assertEquals(true, satellites.single().scanTle) + assertEquals(2, satellites.single().tleRecordsCount) + } + + @Test + fun `satellites falls back to catalog when TLE records are empty`() { + doReturn( + listOf( + SatelliteSummaryDTO( + id = 101, + noradId = 62138, + code = "SAT-62138", + name = "Test satellite" + ) + ) + ).`when`(satelliteCatalogService).allSatellites() + doReturn(emptyList()).`when`(tleMonitoringService).allTles() + + val satellites = service.satellites() + + assertEquals(1, satellites.size) + assertEquals(62138, satellites.single().id) + assertEquals(101, satellites.single().catalogId) + assertEquals(62138, satellites.single().noradId) + assertEquals(0, satellites.single().tleRecordsCount) + } + + @Test + fun `analyze satellite uses only requested number of latest TLE records`() { + doReturn( + listOf( + tleRecord(revolution = 1, epoch = "21274.51041667"), + tleRecord(revolution = 2, epoch = "21275.51041667"), + tleRecord(revolution = 3, epoch = "21276.51041667") + ) + ).`when`(tleMonitoringService).satelliteTles(25544) + + val response = service.analyzeSatellite(25544, tleCount = 2) + + assertEquals(2, response.rows.size) + assertEquals(listOf(2L, 3L), response.rows.map { it.revolution }) + } + + @Test + fun `analyze satellite reports epoch difference between compared TLE records`() { + doReturn( + listOf( + tleRecord(revolution = 1, epoch = "21275.51041667"), + tleRecord(revolution = 2, epoch = "21276.01041667") + ) + ).`when`(tleMonitoringService).satelliteTles(25544) + + val response = service.analyzeSatellite(25544, tleCount = 2) + + assertEquals(2, response.rows.size) + val currentRow = response.rows[1] + val discrepancy = assertNotNull(currentRow.discrepancy) + assertEquals(response.rows[0].epoch, discrepancy.previousEpoch) + assertTrue(discrepancy.previousEpoch.isBefore(currentRow.epoch)) + assertEquals(12.0, discrepancy.epochDifferenceHours, 0.001) + } + + private fun tleRecord(revolution: Long, epoch: String = "21275.51041667") = + TLEExtensionDTO( + satelliteId = 25544, + revolution = revolution, + tle = TLEDTO( + header = "ISS (ZARYA)", + first = "1 25544U 98067A $epoch .00000282 00000-0 12558-4 0 9993", + second = "2 25544 51.6435 177.7258 0003783 65.7820 55.4027 15.48915330299929" + ) + ) +} diff --git a/services/slots-service/Dockerfile b/services/slots-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/slots-service/Dockerfile +++ b/services/slots-service/Dockerfile @@ -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="" diff --git a/services/slots-service/build.gradle.kts b/services/slots-service/build.gradle.kts index bfc71e2..78d9fac 100644 --- a/services/slots-service/build.gradle.kts +++ b/services/slots-service/build.gradle.kts @@ -7,7 +7,7 @@ plugins { kotlin("plugin.lombok") id("org.springframework.boot") id("io.spring.dependency-management") - id("org.sonarqube") + //id("org.sonarqube") jacoco } @@ -103,11 +103,3 @@ 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")}") - } -} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt index 76efd43..7199e4b 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/BookingController.kt @@ -1,6 +1,7 @@ package space.nstart.pcp.slots_service.controller import org.springframework.beans.factory.annotation.Autowired +import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -28,6 +29,20 @@ class BookingController { @GetMapping fun all() = slotService.allBooked() + + @GetMapping("/search") + fun search( + @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? + ) = slotService.searchBookedSlots( + timeStart = timeStart, + timeStop = timeStop, + requestId = requestId, + satelliteIds = satelliteIds + ) + @GetMapping("/{booked_slot_id}") fun byId(@PathVariable("booked_slot_id") id : Long) = slotService.bookedById(id) @GetMapping("/by-request/{request_id}") diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt index c83c350..cd4e733 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImpl.kt @@ -2,6 +2,7 @@ package space.nstart.pcp.slots_service.model.satellite import ballistics.types.InitialConditions import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO import space.nstart.pcp.slots_service.entity.BookedSlotEntity @@ -40,10 +41,19 @@ class TestSatelliteImpl( val selectedSlots = mutableListOf() val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc - val booked = bookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) } - val bookedData = ( - bookedSlots.map { BookedSlot(it.slot.tn, it.slot.tk,it.slot.roll, 0, it.cycle) }.toMutableList() - ) + val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES } + val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) } + val bookedData = activeBookedSlots.map { bookedSlot -> + val offsetDays = bookedSlot.cycle * durationCalc + BookedSlot( + bookedSlot.slot.tn.plusDays(offsetDays), + bookedSlot.slot.tk.plusDays(offsetDays), + bookedSlot.slot.roll, + 0, + bookedSlot.cycle + ) + }.toMutableList() + val bookedIntervals = mergeBookedIntervals(bookedData.map { BookedInterval(it.t, it.tEnd) }) calculateChainLengths(bookedData) for (cycleN in cycleBegin..cycleEnd) { val currentSlots = slots.map { ent -> @@ -68,24 +78,27 @@ class TestSatelliteImpl( slot.tn >= timeStart && slot.tk <= timeStop && sign?.let { slot.revolutionSign == it.toString() } ?: true }.map { - val band = bookedData.filter { booked -> - val dif = intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), booked.t, booked.tEnd) + val isBooked = booked.any { booked -> booked.cycle == it.cycle && booked.id == it.slotId } + val insideBookedInterval = !isBooked && bookedIntervals.any { booked -> booked.contains(it.tn, it.tk) } + val band = if (insideBookedInterval) { + true + } else { + bookedData.any { booked -> + val dif = intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), booked.t, booked.tEnd) + val gam = abs(it.roll - booked.roll) - val gam = abs(it.roll - booked.roll) - - if (dif) { - if (gam < 0.01 && booked.neighbours < maxChainLength) { -// if (gam < 0.01) { -// booked.neighbours++ + if (dif) { + !(gam < 0.01 && booked.neighbours < maxChainLength) + } else { false - } else true - } else false + } + } } PreparedCoverageSlot( sourceSlotId = it.slotId ?: 0L, slot = it.toDTO( - booked.count { booked -> booked.cycle == it.cycle && booked.id == it.slotId } > 0, - band.isNotEmpty() + isBooked, + band ) ) }) @@ -95,6 +108,30 @@ class TestSatelliteImpl( return selectedSlots } + private data class BookedInterval(val start: LocalDateTime, val end: LocalDateTime) { + fun contains(slotStart: LocalDateTime, slotEnd: LocalDateTime): Boolean = + !slotStart.isBefore(start) && !slotEnd.isAfter(end) + } + + private fun mergeBookedIntervals(intervals: List): List { + if (intervals.isEmpty()) { + return emptyList() + } + val merged = mutableListOf() + intervals.sortedBy { it.start }.forEach { interval -> + val last = merged.lastOrNull() + if (last != null && !interval.start.isAfter(last.end)) { + merged[merged.lastIndex] = BookedInterval( + start = minOf(last.start, interval.start), + end = maxOf(last.end, interval.end) + ) + } else { + merged += interval + } + } + return merged + } + private fun calculateChainLengths(slots: MutableList) { if (slots.isEmpty()) return @@ -127,4 +164,12 @@ class TestSatelliteImpl( slots[j].neighbours = lastChainLength } } + + + companion object { + private val ACTIVE_BOOKED_STATUSES = setOf( + BookedSlotStatus.BOOKED.name, + BookedSlotStatus.PROCESSED.name + ) + } } diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt index 40f1bad..bdcab0c 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/BookedSlotsRepository.kt @@ -29,6 +29,29 @@ interface BookedSlotsRepository : JpaRepository{ @EntityGraph(attributePaths = ["slot"]) fun findAllByStatus(status: String): List + @EntityGraph(attributePaths = ["slot", "requests"]) + @Query(""" + select distinct bookedSlot + from BookedSlotEntity bookedSlot + where bookedSlot.slot.satelliteId in :satelliteIds + and bookedSlot.cycle between :cycleBegin and :cycleEnd + """) + fun findBookingDetailsBySatelliteIdsAndCycleBetween( + @Param("satelliteIds") satelliteIds: Collection, + @Param("cycleBegin") cycleBegin: Long, + @Param("cycleEnd") cycleEnd: Long + ): List + + @EntityGraph(attributePaths = ["slot", "requests"]) + @Query(""" + select distinct bookedSlot + from BookedSlotEntity bookedSlot + where bookedSlot.slot.satelliteId in :satelliteIds + """) + fun findBookingDetailsBySatelliteIds( + @Param("satelliteIds") satelliteIds: Collection + ): List + @Modifying @Transactional @Query("DELETE FROM booked_slot WHERE booked_slot_id =:id", nativeQuery = true) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt index 0ef5518..7efbc14 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilter.kt @@ -2,6 +2,7 @@ package space.nstart.pcp.slots_service.service import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO 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.slots_service.model.satellite.AbstractSatellite import java.time.Duration import kotlin.math.abs @@ -71,6 +72,15 @@ internal object OccupiedIntervalPrefilter { occupiedIntervals: List, satellite: AbstractSatellite ): Boolean { + val insideStrictReservedInterval = candidate.state != SlotStatus.BOOKED && + occupiedIntervals.any { occupied -> + isStrictReservedSource(occupied.source) && + isInside(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime) + } + if (insideStrictReservedInterval) { + return false + } + val conflictingDifferentRoll = occupiedIntervals.any { occupied -> !sameRoll(candidate.roll, occupied.roll) && intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi) @@ -101,5 +111,15 @@ internal object OccupiedIntervalPrefilter { mmi: Long ): Boolean = start1 <= end2.plusSeconds(mmi) && end1 >= start2.minusSeconds(mmi) + private fun isInside( + start: java.time.LocalDateTime, + end: java.time.LocalDateTime, + containerStart: java.time.LocalDateTime, + containerEnd: java.time.LocalDateTime + ): Boolean = !start.isBefore(containerStart) && !end.isAfter(containerEnd) + + private fun isStrictReservedSource(source: String?): Boolean = + source.equals("SLOTS", ignoreCase = true) + private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON } diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index 9edcbd5..2709591 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -47,6 +47,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO +import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.* import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO @@ -1451,8 +1452,8 @@ class SlotService { val cycleWindows = selectedSatellites.associate { satellite -> satellite.id to SatelliteCycleWindow( satellite = satellite, - cycleBegin = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc, - cycleEnd = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + cycleBegin = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc, + cycleEnd = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc ) } @@ -1685,16 +1686,26 @@ class SlotService { val satellite = batchContext.selectedSatellitesById[satelliteId] ?: return@forEach val preparedSlotsBySourceSlotId = batchContext.preparedSlotsBySatelliteIdAndSourceSlotId[satelliteId].orEmpty() - val preparedSlots = sourceSlotIdsForSatellite.flatMap { slotId -> + val preparedSlotsIncludingBlocked = sourceSlotIdsForSatellite.flatMap { slotId -> preparedSlotsBySourceSlotId[slotId].orEmpty() }.map { it.slot } - preparedSlotsBeforeOccupiedFilter += preparedSlots.size + preparedSlotsBeforeOccupiedFilter += preparedSlotsIncludingBlocked.size + + // BUSY слоты возникают, в частности, когда слот по времени попадает внутрь + // активного забронированного интервала. Такие слоты не должны попадать + // в дальнейший расчет покрытия: booked-слоты учитываются как BOOKED, + // а все остальные слоты внутри их времени считаются заблокированными. + val preparedSlots = preparedSlotsIncludingBlocked.filter { slot -> + slot.state == SlotStatus.AVAILABLE || slot.state == SlotStatus.BOOKED + } + val blockedByBookedSlots = preparedSlotsIncludingBlocked.size - preparedSlots.size + val filteredSlots = OccupiedIntervalPrefilter.filterCandidates( candidateSlots = preparedSlots, occupiedIntervals = batchContext.occupiedIntervalsBySatelliteId[satelliteId].orEmpty(), satellite = satellite ) - occupiedFilteredSlots += preparedSlots.size - filteredSlots.size + occupiedFilteredSlots += blockedByBookedSlots + (preparedSlots.size - filteredSlots.size) candidateSlotsBeforeSunFilter += filteredSlots.size val sunFilterResult = filterBySunAngle(filteredSlots, sun) sunFilteredSlots += sunFilterResult.filteredCount @@ -1883,6 +1894,98 @@ class SlotService { @Transactional(readOnly = true) fun allBooked() = bookedSlotsRepository.findAll().map { it.toDTO() } + + @Transactional(readOnly = true) + fun searchBookedSlots( + timeStart: LocalDateTime?, + timeStop: LocalDateTime?, + requestId: String?, + satelliteIds: List? + ): List { + if ((timeStart == null) != (timeStop == null)) { + throw CustomValidationException("Для фильтра по времени должны быть заданы timeStart и timeStop") + } + if (timeStart != null && timeStop != null && timeStop < timeStart) { + throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart") + } + + val requestedSatelliteIds = satelliteIds.orEmpty().filter { it > 0 }.distinct() + val satellites = if (requestedSatelliteIds.isEmpty()) { + satelliteCatalogClient.allSatellites() + } else { + loadSatellites(requestedSatelliteIds) + } + if (satellites.isEmpty()) { + return emptyList() + } + + val satellitesById = satellites.associateBy { it.id } + val selectedSatelliteIds = satellites.map { it.id } + val normalizedRequestId = requestId?.trim()?.takeIf { it.isNotEmpty() } + val hasInterval = timeStart != null && timeStop != null + + val bookedSlots = if (hasInterval) { + val cycleWindows = satellites.associate { satellite -> + val cycleBegin = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val cycleEnd = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc + satellite.id to (cycleBegin - 1 to cycleEnd + 1) + } + val minCycle = cycleWindows.values.minOf { it.first } + val maxCycle = cycleWindows.values.maxOf { it.second } + bookedSlotsRepository.findBookingDetailsBySatelliteIdsAndCycleBetween( + satelliteIds = selectedSatelliteIds, + cycleBegin = minCycle, + cycleEnd = maxCycle + ) + } else { + bookedSlotsRepository.findBookingDetailsBySatelliteIds(selectedSatelliteIds) + } + + return bookedSlots + .asSequence() + .filter { booked -> normalizedRequestId == null || booked.requests.orEmpty().any { it.requestId == normalizedRequestId } } + .mapNotNull { booked -> + val satellite = satellitesById[booked.slot.satelliteId] ?: return@mapNotNull null + booked.toDetails(satellite) + } + .filter { details -> + if (!hasInterval) { + true + } else { + val start = details.timeStart + val stop = details.timeStop + start != null && stop != null && stop > timeStart!! && start < timeStop!! + } + } + .sortedWith( + compareBy { it.timeStart ?: LocalDateTime.MAX } + .thenBy { it.satelliteId } + .thenBy { it.slotNum } + .thenBy { it.cycle } + .thenBy { it.bookedSlotId } + ) + .toList() + } + + private fun BookedSlotEntity.toDetails(satellite: AbstractSatellite): BookedSlotDetailsDTO { + val timeStart = slot.tn.plusDays(cycle * satellite.durationCalc) + val timeStop = slot.tk.plusDays(cycle * satellite.durationCalc) + return BookedSlotDetailsDTO( + bookedSlotId = bookedSlotId ?: 0, + satelliteId = slot.satelliteId, + slotNum = slot.slotNum, + cycle = cycle, + timeStart = timeStart, + timeStop = timeStop, + durationSeconds = Duration.between(timeStart, timeStop).seconds, + roll = slot.roll, + revolution = slot.revolution + cycle * satellite.cycleRevs, + revolutionSign = slot.revolutionSign, + status = status, + requestIds = requests.orEmpty().map { it.requestId }.distinct().sorted() + ) + } + @Transactional(readOnly = true) fun allByInterval( satelliteId: Long, @@ -1894,8 +1997,8 @@ class SlotService { } val satellite = satelliteById(satelliteId) - val startCycle = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc - val stopCycle = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc val cycleBegin = startCycle - 1 val cycleEnd = stopCycle + 1 @@ -1957,8 +2060,8 @@ class SlotService { val satellite = satelliteById(satelliteId) - val startCycle = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc - val stopCycle = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc val cycleBegin = startCycle - 1 val cycleEnd = stopCycle + 1 @@ -1982,8 +2085,8 @@ class SlotService { val selectedSatellites = loadSatellites(satelliteIds) val cycleWindows = selectedSatellites.associate { satellite -> - val startCycle = Duration.between(satellite.tnCalc, timeStart).toDays() / satellite.durationCalc - val stopCycle = Duration.between(satellite.tnCalc, timeStop).toDays() / satellite.durationCalc + val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc + val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc satellite.id to (startCycle - 1 to stopCycle + 1) } val minCycle = cycleWindows.values.minOf { it.first } diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt new file mode 100644 index 0000000..bd886eb --- /dev/null +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/model/satellite/TestSatelliteImplBookedBlockingTest.kt @@ -0,0 +1,62 @@ +package space.nstart.pcp.slots_service.model.satellite + +import org.junit.jupiter.api.Test +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus +import space.nstart.pcp.slots_service.entity.BookedSlotEntity +import space.nstart.pcp.slots_service.entity.SlotEntity +import java.time.LocalDateTime +import kotlin.test.assertEquals + +class TestSatelliteImplBookedBlockingTest { + + @Test + fun `non-booked slot inside active booked interval is marked busy and exact booked slot stays booked`() { + val baseTime = LocalDateTime.of(2026, 5, 21, 0, 0) + val satellite = TestSatelliteImpl( + id = 1L, + tnCalc = baseTime, + durationCalc = 1, + maxChainLength = 3, + maxSurveyDuration = 300, + mmi = 10 + ) + val bookedRawSlot = slot(slotId = 1L, slotNum = 1L, baseTime = baseTime, startSecond = 0, endSecond = 10) + val candidateInsideBooked = slot(slotId = 2L, slotNum = 2L, baseTime = baseTime, startSecond = 2, endSecond = 8) + val booked = BookedSlotEntity(slot = bookedRawSlot, cycle = 0).apply { + status = BookedSlotStatus.PROCESSED.name + } + + val result = satellite.prepareCoverageSlots( + slots = listOf(bookedRawSlot, candidateInsideBooked), + bookedSlots = listOf(booked), + timeStart = baseTime, + timeStop = baseTime.plusSeconds(20), + sign = null, + states = null + ).map { it.slot }.associateBy { it.slotNumber } + + assertEquals(SlotStatus.BOOKED, result.getValue(1L).state) + assertEquals(SlotStatus.BUSY, result.getValue(2L).state) + } + + private fun slot( + slotId: Long, + slotNum: Long, + baseTime: LocalDateTime, + startSecond: Long, + endSecond: Long + ) = SlotEntity( + slotId = slotId, + slotNum = slotNum, + cycle = 0, + satelliteId = 1L, + coveringType = 0, + tn = baseTime.plusSeconds(startSecond), + tk = baseTime.plusSeconds(endSecond), + roll = 10.0, + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + revolution = 1L, + revolutionSign = "ASC" + ) +} diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt index 4bd5259..02f7c89 100644 --- a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/OccupiedIntervalPrefilterTest.kt @@ -81,6 +81,48 @@ class OccupiedIntervalPrefilterTest { assertEquals(listOf(candidate), filtered) } + + @Test + fun `available candidate inside reserved slots interval is rejected even with same roll`() { + val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5) + val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 10, endOffsetSeconds = 20) + val occupied = listOf( + occupiedInterval( + roll = 10.0, + startOffsetSeconds = 0, + endOffsetSeconds = 30, + source = "SLOTS" + ) + ) + + val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite) + + assertTrue(filtered.isEmpty()) + } + + @Test + fun `booked candidate inside reserved slots interval is kept for coverage accounting`() { + val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5) + val candidate = candidateSlot( + roll = 10.0, + startOffsetSeconds = 10, + endOffsetSeconds = 20, + state = SlotStatus.BOOKED + ) + val occupied = listOf( + occupiedInterval( + roll = 10.0, + startOffsetSeconds = 0, + endOffsetSeconds = 30, + source = "SLOTS" + ) + ) + + val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite) + + assertEquals(listOf(candidate), filtered) + } + private fun testSatellite(maxSurveyDuration: Int, mmi: Long): AbstractSatellite = object : AbstractSatellite(id = 22, maxSurveyDuration = maxSurveyDuration, mmi = mmi) { override fun prepareCoverageSlots( @@ -96,26 +138,29 @@ class OccupiedIntervalPrefilterTest { private fun occupiedInterval( roll: Double, startOffsetSeconds: Long, - endOffsetSeconds: Long + endOffsetSeconds: Long, + source: String = "TEST" ) = OccupiedIntervalDTO( satelliteId = 22, startTime = baseTime().plusSeconds(startOffsetSeconds), endTime = baseTime().plusSeconds(endOffsetSeconds), roll = roll, - source = "TEST" + source = source ) private fun candidateSlot( roll: Double, startOffsetSeconds: Long, - endOffsetSeconds: Long + endOffsetSeconds: Long, + state: SlotStatus = SlotStatus.AVAILABLE ) = SlotDTO( cycle = 0, satelliteId = 22, tn = baseTime().plusSeconds(startOffsetSeconds), tk = baseTime().plusSeconds(endOffsetSeconds), roll = roll, - contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + state = state ) private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0) diff --git a/services/spring-cloud-config-server/Dockerfile b/services/spring-cloud-config-server/Dockerfile index b32686f..422a4e6 100644 --- a/services/spring-cloud-config-server/Dockerfile +++ b/services/spring-cloud-config-server/Dockerfile @@ -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" diff --git a/services/spring-cloud-config-server/build.gradle.kts b/services/spring-cloud-config-server/build.gradle.kts index 74b279f..460ad69 100644 --- a/services/spring-cloud-config-server/build.gradle.kts +++ b/services/spring-cloud-config-server/build.gradle.kts @@ -5,7 +5,7 @@ plugins { kotlin("plugin.spring") id("org.springframework.boot") id("io.spring.dependency-management") - id("org.sonarqube") + //id("org.sonarqube") jacoco } @@ -63,11 +63,3 @@ 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")}") - } -} diff --git a/services/tle-monitoring-service/Dockerfile b/services/tle-monitoring-service/Dockerfile index e1af0fd..6fecd3c 100644 --- a/services/tle-monitoring-service/Dockerfile +++ b/services/tle-monitoring-service/Dockerfile @@ -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="" diff --git a/services/tle-monitoring-service/build.gradle.kts b/services/tle-monitoring-service/build.gradle.kts index cc1cbb2..d17e3d0 100644 --- a/services/tle-monitoring-service/build.gradle.kts +++ b/services/tle-monitoring-service/build.gradle.kts @@ -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") } @@ -93,11 +93,3 @@ 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")}") - } -} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/controller/TLEController.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/controller/TLEController.kt index a19288d..015a175 100644 --- a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/controller/TLEController.kt +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/controller/TLEController.kt @@ -1,5 +1,6 @@ package space.nstart.pcp.tle_monitoring_service.controller +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -12,12 +13,14 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO import space.nstart.pcp.tle_monitoring_service.repository.TLERepository import space.nstart.pcp.tle_monitoring_service.service.CelesTrakService +import kotlin.system.measureTimeMillis @RestController @RequestMapping("v1/api/tle") class TLEController { + private val logger = LoggerFactory.getLogger(this::class.java) @Autowired private lateinit var tleRepository: TLERepository @@ -25,12 +28,57 @@ class TLEController { private lateinit var celestrakService: CelesTrakService @GetMapping() - fun all() = tleRepository.findAll().map { tle -> - val t = tle.tle.split("\r\n") - TLEExtensionDTO( - tle.satelliteId, - tle.revolution, - TLEDTO(t[0], t[1], t[2]) + fun all(): List { + logger.info("TLE API all records request started") + var records = emptyList() + val durationMs = measureTimeMillis { + records = tleRepository.findAll().map { tle -> tle.toDto() } + } + logger.info( + "TLE API all records request completed: records={}, durationMs={}", + records.size, + durationMs + ) + return records + } + + @GetMapping("/satellite/{satelliteId}") + fun bySatellite(@PathVariable satelliteId: Long): List { + logger.info("TLE API satellite records request started: satelliteId={}", satelliteId) + var records = emptyList() + val durationMs = measureTimeMillis { + records = tleRepository.findAllBySatelliteIdOrderByTimeTleAsc(satelliteId).map { tle -> tle.toDto() } + } + logger.info( + "TLE API satellite records request completed: satelliteId={}, records={}, durationMs={}", + satelliteId, + records.size, + durationMs + ) + return records + } + + private fun space.nstart.pcp.tle_monitoring_service.entity.TLEEntity.toDto(): TLEExtensionDTO { + val lines = tle.lines().map { it.trimEnd() }.filter { it.isNotBlank() } + val header = when { + lines.size >= 3 -> lines[0] + else -> null + } + val first = when { + lines.size >= 3 -> lines[1] + lines.size >= 2 -> lines[0] + else -> " ".repeat(69) + } + val second = when { + lines.size >= 3 -> lines[2] + lines.size >= 2 -> lines[1] + else -> " ".repeat(69) + } + + return TLEExtensionDTO( + satelliteId, + revolution, + TLEDTO(header, first, second) ) } @@ -51,4 +99,4 @@ class TLEController { fun tleFromSource(@RequestParam noradIn : Long, @RequestParam("message") sendMessage : Boolean) = celestrakService.getTleByNoradIdRaw(noradIn, false, sendMessage) -} \ No newline at end of file +} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/repository/TLERepository.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/repository/TLERepository.kt index ffceb66..b894bfb 100644 --- a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/repository/TLERepository.kt +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/repository/TLERepository.kt @@ -9,6 +9,8 @@ interface TLERepository : JpaRepository { fun findAllBySatelliteId(id : Long) : List + fun findAllBySatelliteIdOrderByTimeTleAsc(id: Long): List + fun countBySatelliteId(id: Long): Long fun deleteAllBySatelliteId(id: Long): Long diff --git a/settings.gradle.kts b/settings.gradle.kts index 9520fbf..db309b8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,15 +24,13 @@ include(":services:pcp-dynamic-plan-service") include(":services:spring-cloud-config-server") include(":services:pcp-srpring-boot-admin-server") -// include(":workers:worker-example") - pluginManagement { repositories { + maven { + url = uri("https://repo.nstart.cloud/repository/maven-proxy/") + } mavenCentral() gradlePluginPortal() - maven { - url = uri("https://repo.nstart.local/repository/maven-proxy/") - } } val versions_kotlin : String by settings @@ -41,6 +39,7 @@ pluginManagement { val versions_sonarqube : String by settings val versions_buildtools : String by settings + plugins { kotlin("jvm") version versions_kotlin kotlin("plugin.spring") version versions_kotlin @@ -48,7 +47,7 @@ pluginManagement { kotlin("plugin.lombok") version versions_kotlin id("org.springframework.boot") version versions_spring_boot id("io.spring.dependency-management") version versions_spring_dependency - id("org.sonarqube") version versions_sonarqube +// //id("org.sonarqube") version versions_sonarqube // id("org.graalvm.buildtools.native") version versions_buildtools } }